diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml index cff09610e6..789bb19baf 100644 --- a/.github/workflows/test-integrations-tasks.yml +++ b/.github/workflows/test-integrations-tasks.yml @@ -53,6 +53,10 @@ jobs: - name: Setup Test Env run: | uv sync + - name: Test apscheduler + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-apscheduler" - name: Test arq run: | set -x # print commands that are executed diff --git a/pyproject.toml b/pyproject.toml index ff63660eec..655547e3ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -245,6 +245,10 @@ ignore_missing_imports = true module = "huey.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "apscheduler.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "openai.*" ignore_missing_imports = true diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 533f9e4946..4de3d8db71 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -39,6 +39,11 @@ "python": ">=3.8", "num_versions": 2, }, + "apscheduler": { + "package": "apscheduler", + # 4.x is a rewrite with a different API and isn't supported. + "include": "<4", + }, "arq": { "package": "arq", "deps": { diff --git a/scripts/populate_tox/package_dependencies.jsonl b/scripts/populate_tox/package_dependencies.jsonl index bb9b545488..8a61e84957 100644 --- a/scripts/populate_tox/package_dependencies.jsonl +++ b/scripts/populate_tox/package_dependencies.jsonl @@ -638,3 +638,7 @@ {"python_version": "3.9", "dependencies": [{"metadata": {"name": "langchain", "version": "0.3.30"}, "download_info": {"url": "https://files.pythonhosted.org/packages/da/54/c0775c29bf9ae27c8cbf8484ad600edd2e787a0eacf3a374cb6561613de9/langchain-0.3.30-py3-none-any.whl"}}, {"metadata": {"name": "async-timeout", "version": "4.0.3"}, "download_info": {"url": "https://files.pythonhosted.org/packages/a7/fa/e01228c2938de91d47b307831c62ab9e4001e747789d0b05baf779a6488c/async_timeout-4.0.3-py3-none-any.whl"}}, {"metadata": {"name": "langchain-core", "version": "0.3.86"}, "download_info": {"url": "https://files.pythonhosted.org/packages/0c/93/ba19ca54701c6118e68f8785949b6c0eab1df3a5cfa5310508cc86877994/langchain_core-0.3.86-py3-none-any.whl"}}, {"metadata": {"name": "jsonpatch", "version": "1.33"}, "download_info": {"url": "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl"}}, {"metadata": {"name": "langchain-text-splitters", "version": "0.3.11"}, "download_info": {"url": "https://files.pythonhosted.org/packages/58/0d/41a51b40d24ff0384ec4f7ab8dd3dcea8353c05c973836b5e289f1465d4f/langchain_text_splitters-0.3.11-py3-none-any.whl"}}, {"metadata": {"name": "langsmith", "version": "0.4.37"}, "download_info": {"url": "https://files.pythonhosted.org/packages/14/e8/edff4de49cf364eb9ee88d13da0a555844df32438413bf53d90d507b97cd/langsmith-0.4.37-py3-none-any.whl"}}, {"metadata": {"name": "httpx", "version": "0.28.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl"}}, {"metadata": {"name": "httpcore", "version": "1.0.9"}, "download_info": {"url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl"}}, {"metadata": {"name": "packaging", "version": "25.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl"}}, {"metadata": {"name": "pydantic", "version": "2.13.4"}, "download_info": {"url": "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl"}}, {"metadata": {"name": "pydantic_core", "version": "2.46.4"}, "download_info": {"url": "https://files.pythonhosted.org/packages/41/60/e70fa1ee03e243bdfd4b1fddf1e1f2a8fba681df3034b51b9376c0fb5bf5/pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "PyYAML", "version": "6.0.3"}, "download_info": {"url": "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "requests", "version": "2.32.5"}, "download_info": {"url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"}}, {"metadata": {"name": "charset-normalizer", "version": "3.5.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/d0/91/bc145e42f93d6601b9a26f5421af2d7c3093ae6e6d03b8e583c9cebbf530/charset_normalizer-3.5.1-cp39-cp39-macosx_10_9_universal2.whl"}}, {"metadata": {"name": "idna", "version": "3.19"}, "download_info": {"url": "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl"}}, {"metadata": {"name": "SQLAlchemy", "version": "2.0.52"}, "download_info": {"url": "https://files.pythonhosted.org/packages/48/4b/c5c0fe0b2d606b34618933f7448ebf93e1aeb60d896149a8061b7ac5bfd9/sqlalchemy-2.0.52-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "tenacity", "version": "9.1.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl"}}, {"metadata": {"name": "typing_extensions", "version": "4.16.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl"}}, {"metadata": {"name": "urllib3", "version": "2.6.3"}, "download_info": {"url": "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl"}}, {"metadata": {"name": "uuid_utils", "version": "0.15.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/1d/c8/5a05247c1b7cbf49849523e1a26e3a8a76c0c1ec6dc84c7cc7b9a8c66503/uuid_utils-0.15.0-cp39-cp39-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl"}}, {"metadata": {"name": "pytest-asyncio", "version": "1.2.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl"}}, {"metadata": {"name": "backports.asyncio.runner", "version": "1.2.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl"}}, {"metadata": {"name": "pytest", "version": "8.4.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl"}}, {"metadata": {"name": "pluggy", "version": "1.6.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl"}}, {"metadata": {"name": "openai", "version": "2.48.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/b5/2a/dcb891114e303c4379d4a498f10222e33eee540bcef4e1e493bd0af2b242/openai-2.48.0-py3-none-any.whl"}}, {"metadata": {"name": "anyio", "version": "4.12.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl"}}, {"metadata": {"name": "distro", "version": "1.9.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl"}}, {"metadata": {"name": "jiter", "version": "0.16.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/69/79/7b84250429f0e262b935c627a8522cab31b5303e617400b14e808b599bdb/jiter-0.16.0-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "langchain-openai", "version": "0.3.35"}, "download_info": {"url": "https://files.pythonhosted.org/packages/d8/d5/c90c5478215c20ee71d8feaf676f7ffd78d0568f8c98bd83f81ce7562ed7/langchain_openai-0.3.35-py3-none-any.whl"}}, {"metadata": {"name": "tiktoken", "version": "0.14.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/ff/b2/9a49f8a9923167adfb25429415a66b46b6c22a9ff357a3393c1e334408b4/tiktoken-0.14.0-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "langchain-community", "version": "0.3.31"}, "download_info": {"url": "https://files.pythonhosted.org/packages/e6/0a/b8848db67ad7c8d4652cb6f4cb78d49b5b5e6e8e51d695d62025aa3f7dbc/langchain_community-0.3.31-py3-none-any.whl"}}, {"metadata": {"name": "aiohttp", "version": "3.13.5"}, "download_info": {"url": "https://files.pythonhosted.org/packages/d5/34/8413eafee3421ade2d6ce9e7c0da1213e1d7f0049be09dcdc342b03a39ba/aiohttp-3.13.5-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "dataclasses-json", "version": "0.6.7"}, "download_info": {"url": "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl"}}, {"metadata": {"name": "httpx-sse", "version": "0.4.3"}, "download_info": {"url": "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl"}}, {"metadata": {"name": "marshmallow", "version": "3.26.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl"}}, {"metadata": {"name": "multidict", "version": "6.7.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/83/9c/1d2a283d9c6f31e260cb6c2fccadc3edcf6c4c14ee0929cd2af4d2606dd7/multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "pydantic-settings", "version": "2.11.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl"}}, {"metadata": {"name": "typing-inspect", "version": "0.9.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl"}}, {"metadata": {"name": "yarl", "version": "1.22.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/32/58/b8055273c203968e89808413ea4c984988b6649baabf10f4522e67c22d2f/yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "aiohappyeyeballs", "version": "2.6.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl"}}, {"metadata": {"name": "aiosignal", "version": "1.4.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl"}}, {"metadata": {"name": "annotated-types", "version": "0.7.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl"}}, {"metadata": {"name": "attrs", "version": "26.1.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl"}}, {"metadata": {"name": "certifi", "version": "2026.7.22"}, "download_info": {"url": "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl"}}, {"metadata": {"name": "exceptiongroup", "version": "1.3.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl"}}, {"metadata": {"name": "frozenlist", "version": "1.8.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/4b/de/ad9d82ca8e5fa8f0c636e64606553c79e2b859ad253030b62a21fe9986f5/frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "h11", "version": "0.16.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl"}}, {"metadata": {"name": "iniconfig", "version": "2.1.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl"}}, {"metadata": {"name": "jsonpointer", "version": "3.0.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl"}}, {"metadata": {"name": "mypy_extensions", "version": "1.1.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl"}}, {"metadata": {"name": "numpy", "version": "2.0.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl"}}, {"metadata": {"name": "orjson", "version": "3.11.5"}, "download_info": {"url": "https://files.pythonhosted.org/packages/50/c7/7b682849dd4c9fb701a981669b964ea700516ecbd8e88f62aae07c6852bd/orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl"}}, {"metadata": {"name": "propcache", "version": "0.4.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/7c/19/b65d98ae21384518b291d9939e24a8aeac4fdb5101b732576f8f7540e834/propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "Pygments", "version": "2.21.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl"}}, {"metadata": {"name": "python-dotenv", "version": "1.2.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl"}}, {"metadata": {"name": "requests-toolbelt", "version": "1.0.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl"}}, {"metadata": {"name": "tomli", "version": "2.4.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl"}}, {"metadata": {"name": "tqdm", "version": "4.70.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl"}}, {"metadata": {"name": "typing-inspection", "version": "0.4.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl"}}, {"metadata": {"name": "zstandard", "version": "0.25.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/ca/aa/ceb8d79cbad6dabd4cb1178ca853f6a4374d791c5e0241a0988173e2a341/zstandard-0.25.0-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "regex", "version": "2026.1.15"}, "download_info": {"url": "https://files.pythonhosted.org/packages/4b/d2/a2fef3717deaff647d7de2bccf899a576c7eaf042b6b271fc4474515fe97/regex-2026.1.15-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "sniffio", "version": "1.3.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl"}}], "install_targets_hash": "f4e4bb9460bd93d74ce9abd6823fc735"} {"python_version": "3.9", "dependencies": [{"metadata": {"name": "asyncpg", "version": "0.31.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/13/38/bbb09ea041a935dc7720e283b6876c3487ace4b180b8d58c07db6cd8c941/asyncpg-0.31.0-cp39-cp39-macosx_11_0_arm64.whl"}}, {"metadata": {"name": "pytest-asyncio", "version": "1.2.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl"}}, {"metadata": {"name": "backports.asyncio.runner", "version": "1.2.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl"}}, {"metadata": {"name": "pytest", "version": "8.4.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl"}}, {"metadata": {"name": "pluggy", "version": "1.6.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl"}}, {"metadata": {"name": "async-timeout", "version": "5.0.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl"}}, {"metadata": {"name": "exceptiongroup", "version": "1.3.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl"}}, {"metadata": {"name": "iniconfig", "version": "2.1.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl"}}, {"metadata": {"name": "packaging", "version": "26.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl"}}, {"metadata": {"name": "Pygments", "version": "2.20.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl"}}, {"metadata": {"name": "tomli", "version": "2.4.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl"}}, {"metadata": {"name": "typing_extensions", "version": "4.16.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl"}}], "install_targets_hash": "f737563b075b37dba35f4a4e7631e9cd"} {"python_version": "3.9", "dependencies": [{"metadata": {"name": "redis", "version": "6.4.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl"}}, {"metadata": {"name": "pytest", "version": "7.4.4"}, "download_info": {"url": "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl"}}, {"metadata": {"name": "pluggy", "version": "1.6.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl"}}, {"metadata": {"name": "fakeredis", "version": "2.36.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/fd/4d/e6e40f93031adbf654d34e542bd396e9f3bc1c6209b40c920d58c9e1317a/fakeredis-2.36.2-py3-none-any.whl"}}, {"metadata": {"name": "pytest-asyncio", "version": "0.23.8"}, "download_info": {"url": "https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl"}}, {"metadata": {"name": "async-timeout", "version": "5.0.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl"}}, {"metadata": {"name": "exceptiongroup", "version": "1.3.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl"}}, {"metadata": {"name": "sortedcontainers", "version": "2.4.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl"}}, {"metadata": {"name": "tomli", "version": "2.4.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl"}}, {"metadata": {"name": "typing_extensions", "version": "4.16.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl"}}, {"metadata": {"name": "iniconfig", "version": "2.1.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl"}}, {"metadata": {"name": "packaging", "version": "26.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl"}}], "install_targets_hash": "f973477f4ba55b7a5455bcb45caae3a9"} +{"python_version": "3.13", "dependencies": [{"metadata": {"name": "APScheduler", "version": "3.11.3"}, "download_info": {"url": "https://sfw.security.sentry.io/pypi/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl"}}, {"metadata": {"name": "tzlocal", "version": "5.4.4"}, "download_info": {"url": "https://sfw.security.sentry.io/pypi/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl"}}], "install_targets_hash": "43d3f462bb7d0175b6f3d866339fd7ed"} +{"python_version": "3.14", "dependencies": [{"metadata": {"name": "APScheduler", "version": "3.11.3"}, "download_info": {"url": "https://sfw.security.sentry.io/pypi/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl"}}, {"metadata": {"name": "tzlocal", "version": "5.4.4"}, "download_info": {"url": "https://sfw.security.sentry.io/pypi/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl"}}], "install_targets_hash": "43d3f462bb7d0175b6f3d866339fd7ed"} +{"python_version": "3.14t", "dependencies": [{"metadata": {"name": "APScheduler", "version": "3.11.3"}, "download_info": {"url": "https://sfw.security.sentry.io/pypi/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl"}}, {"metadata": {"name": "tzlocal", "version": "5.4.4"}, "download_info": {"url": "https://sfw.security.sentry.io/pypi/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl"}}], "install_targets_hash": "43d3f462bb7d0175b6f3d866339fd7ed"} +{"python_version": "3.8", "dependencies": [{"metadata": {"name": "APScheduler", "version": "3.11.3"}, "download_info": {"url": "https://sfw.security.sentry.io/pypi/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl"}}, {"metadata": {"name": "tzlocal", "version": "5.2"}, "download_info": {"url": "https://sfw.security.sentry.io/pypi/packages/97/3f/c4c51c55ff8487f2e6d0e618dba917e3c3ee2caae6cf0fbb59c9b1876f2e/tzlocal-5.2-py3-none-any.whl"}}, {"metadata": {"name": "backports.zoneinfo", "version": "0.2.1"}, "download_info": {"url": "https://sfw.security.sentry.io/pypi/packages/ad/85/475e514c3140937cf435954f78dedea1861aeab7662d11de232bdaa90655/backports.zoneinfo-0.2.1.tar.gz"}}], "install_targets_hash": "43d3f462bb7d0175b6f3d866339fd7ed"} diff --git a/scripts/populate_tox/releases.jsonl b/scripts/populate_tox/releases.jsonl index 7b73f9b5de..80b8924325 100644 --- a/scripts/populate_tox/releases.jsonl +++ b/scripts/populate_tox/releases.jsonl @@ -246,3 +246,7 @@ {"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "typer", "requires_python": ">=3.7", "version": "0.15.4", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "typer-0.15.4-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "typer-0.15.4.tar.gz"}]} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "typer", "requires_python": ">=3.10", "version": "0.27.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "typer-0.27.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "typer-0.27.2.tar.gz"}]} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "typesafe-sdk", "requires_python": ">=3.10", "version": "0.7.2", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "typesafe_sdk-0.7.2-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "typesafe_sdk-0.7.2.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "APScheduler", "requires_python": ">=3.8", "version": "3.11.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "apscheduler-3.11.3-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "apscheduler-3.11.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6"], "name": "APScheduler", "requires_python": "", "version": "3.3.1", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "APScheduler-3.3.1-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "APScheduler-3.3.1.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6"], "name": "APScheduler", "requires_python": "", "version": "3.5.3", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "APScheduler-3.5.3-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "APScheduler-3.5.3.tar.gz"}]} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7"], "name": "APScheduler", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", "version": "3.7.0", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "APScheduler-3.7.0-py2.py3-none-any.whl"}, {"packagetype": "sdist", "filename": "APScheduler-3.7.0.tar.gz"}]} diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index 60f294c911..75dbd331e7 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -136,6 +136,7 @@ "requests", ], "Tasks": [ + "apscheduler", "arq", "beam", "celery", diff --git a/sentry_sdk/crons/utils.py b/sentry_sdk/crons/utils.py new file mode 100644 index 0000000000..d5fa55e500 --- /dev/null +++ b/sentry_sdk/crons/utils.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional, Tuple + + from sentry_sdk._types import MonitorConfigSchedule, MonitorConfigScheduleUnit + + +_INTERVAL_UNITS: "Tuple[Tuple[MonitorConfigScheduleUnit, int], ...]" = ( + ("day", 60 * 60 * 24), + ("hour", 60 * 60), + ("minute", 60), +) + + +def _get_interval_schedule(seconds: float) -> "Optional[MonitorConfigSchedule]": + """ + Express an interval in the largest unit that divides it evenly. + + Returns None if the interval isn't a whole number of minutes. + """ + for unit, unit_seconds in _INTERVAL_UNITS: + if seconds >= unit_seconds and seconds % unit_seconds == 0: + return { + "type": "interval", + "value": int(seconds // unit_seconds), + "unit": unit, + } + return None diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index e61365cc7f..2d4d056cb0 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -121,6 +121,7 @@ def iter_default_integrations( "aiohttp": (3, 4), "aiomysql": (0, 3, 0), "anthropic": (0, 16), + "apscheduler": (3, 3), "ariadne": (0, 20), "arq": (0, 23), "asyncpg": (0, 23), diff --git a/sentry_sdk/integrations/apscheduler.py b/sentry_sdk/integrations/apscheduler.py new file mode 100644 index 0000000000..5fbe0ebcc4 --- /dev/null +++ b/sentry_sdk/integrations/apscheduler.py @@ -0,0 +1,390 @@ +import re +import threading +import weakref +from functools import lru_cache, wraps +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk.crons import MonitorStatus, capture_checkin +from sentry_sdk.crons.utils import _get_interval_schedule +from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version +from sentry_sdk.utils import ( + capture_internal_exceptions, + logger, + match_regex_list, + now, + package_version, +) + +if TYPE_CHECKING: + from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple + + from sentry_sdk._types import MonitorConfig + +try: + from apscheduler.events import ( + EVENT_JOB_ERROR, + EVENT_JOB_EXECUTED, + EVENT_JOB_MISSED, + ) + from apscheduler.executors.base import BaseExecutor, MaxInstancesReachedError + from apscheduler.triggers.cron import CronTrigger + from apscheduler.triggers.cron.expressions import ( + AllExpression, + RangeExpression, + ) + from apscheduler.triggers.interval import IntervalTrigger +except ImportError: + raise DidNotEnable("APScheduler is not installed, or is not version 3.x") + + +class APSchedulerIntegration(Integration): + identifier = "apscheduler" + + def __init__( + self, + monitor_jobs: bool = False, + exclude_jobs: "Optional[List[str]]" = None, + ) -> None: + self.monitor_jobs = monitor_jobs + self.exclude_jobs = exclude_jobs + + @staticmethod + def setup_once() -> None: + version = package_version("apscheduler") + _check_minimum_version(APSchedulerIntegration, version) + if version is not None and version[0] >= 4: + raise DidNotEnable("APScheduler 4 is not supported.") + + _patch_submit_job() + + +class _PendingCheckIn: + __slots__ = ("monitor_slug", "monitor_config", "start", "opened", "check_in_id") + + def __init__( + self, monitor_slug: str, monitor_config: "MonitorConfig", start: float + ) -> None: + self.monitor_slug = monitor_slug + self.monitor_config = monitor_config + self.start = start + self.opened = False + self.check_in_id: "Optional[str]" = None + + def open(self) -> None: + self.opened = True + self.check_in_id = capture_checkin( + monitor_slug=self.monitor_slug, + monitor_config=self.monitor_config, + status=MonitorStatus.IN_PROGRESS, + ) + + +class _CronsListener: + """ + Tracks the check-ins of submitted runs and closes them. + + Executors dispatch one `EVENT_JOB_EXECUTED`, `EVENT_JOB_ERROR` or + `EVENT_JOB_MISSED` per submitted run time, in the scheduler's process, for + every executor type. + """ + + MASK = EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_MISSED + + def __init__(self) -> None: + self.lock = threading.Lock() + # Keyed by (job id, scheduled run time). + self.pending: "Dict[Tuple[str, Any], _PendingCheckIn]" = {} + + def add(self, job_id: str, run_time: "Any", pending: "_PendingCheckIn") -> None: + with self.lock: + self.pending[(job_id, run_time)] = pending + + def open(self, job_id: str, run_time: "Any") -> None: + # Under the lock, so `in_progress` is always sent before `finish` + # sends the closing check-in. + with self.lock: + pending = self.pending.get((job_id, run_time)) + if pending is not None and not pending.opened: + pending.open() + + def discard(self, job_id: str, run_time: "Any") -> None: + with self.lock: + self.pending.pop((job_id, run_time), None) + + def finish(self, job_id: str, run_time: "Any", status: str) -> None: + with self.lock: + pending = self.pending.pop((job_id, run_time), None) + if pending is None: + return + + # Synchronous executors finish the run before `submit_job` returns. + if not pending.opened: + pending.open() + + capture_checkin( + monitor_slug=pending.monitor_slug, + monitor_config=pending.monitor_config, + check_in_id=pending.check_in_id, + duration=now() - pending.start, + status=status, + ) + + def __call__(self, event: "Any") -> None: + with capture_internal_exceptions(): + status = ( + MonitorStatus.OK + if event.code == EVENT_JOB_EXECUTED + else MonitorStatus.ERROR + ) + self.finish(event.job_id, event.scheduled_run_time, status) + + +# One listener per scheduler. Weak keys so we don't keep schedulers alive. +_listeners: "weakref.WeakKeyDictionary[Any, _CronsListener]" = ( + weakref.WeakKeyDictionary() +) +_listeners_lock = threading.Lock() + + +def _get_listener(scheduler: "Any") -> "_CronsListener": + with _listeners_lock: + listener = _listeners.get(scheduler) + if listener is None: + listener = _CronsListener() + scheduler.add_listener(listener, _CronsListener.MASK) + _listeners[scheduler] = listener + return listener + + +# APScheduler assigns `uuid4().hex` when a job is added without an `id`. +_GENERATED_JOB_ID = re.compile(r"[0-9a-f]{32}") + +# Generated job ids already warned about. +_warned_job_ids: "Set[str]" = set() + + +def _has_generated_id(job: "Any") -> bool: + return _GENERATED_JOB_ID.fullmatch(job.id) is not None + + +def _get_monitor_slug(job: "Any") -> str: + # Generated ids change on every restart, which would create a new monitor + # each time. Fall back to the job name (the function name by default). + if _has_generated_id(job): + return job.name + return job.id + + +def _warn_generated_id(job: "Any") -> None: + if job.id in _warned_job_ids: + return + _warned_job_ids.add(job.id) + logger.warning( + "APScheduler job %r has no explicit `id`, so its name is used as its " + "monitor slug, and jobs with the same name share a monitor. Pass `id` " + "to `add_job` to give it its own monitor.", + job.name, + ) + + +@lru_cache(maxsize=None) +def _get_known_timezones() -> "FrozenSet[str]": + try: + from zoneinfo import available_timezones + except ImportError: + try: + from backports.zoneinfo import available_timezones # type: ignore + except ImportError: + try: + import pytz # type: ignore + except ImportError: + return frozenset(("UTC",)) + # A lazy list that frozenset() alone would read as empty. + return frozenset(name for name in pytz.all_timezones) + return frozenset(available_timezones()) + + +def _get_timezone_name(timezone: "Any") -> "Optional[str]": + """ + Return the IANA name of the timezone, or None if it has none, e.g. + `datetime.timezone(timedelta(hours=2))` ("UTC+02:00"), `pytz.FixedOffset` + or tzlocal's "local". + """ + name = str(timezone) + if name in _get_known_timezones(): + return name + return None + + +def _get_crontab_day_of_week(field: "Any") -> "Optional[str]": + # APScheduler counts weekdays from Monday = 0, cron from Sunday = 0. Emit + # Monday = 1 ... Sunday = 7, which cron also accepts, so ranges stay ranges. + values = [] + for expr in field.expressions: + if type(expr) is AllExpression: + if expr.step is None: + return "*" + first, last, step = 0, 6, expr.step + elif isinstance(expr, RangeExpression): + first = expr.first + last = 6 if expr.last is None else expr.last + step = expr.step + else: + return None + + value = str(first + 1) + if last != first: + value += f"-{last + 1}" + if step: + value += f"/{step}" + values.append(value) + + return ",".join(values) + + +def _get_crontab_field(field: "Any") -> "Optional[str]": + for expr in field.expressions: + # Rules out "last", "1st mon" and the like. + if not (type(expr) is AllExpression or isinstance(expr, RangeExpression)): + return None + return str(field) + + +def _get_crontab(trigger: "CronTrigger") -> "Optional[str]": + fields = {field.name: field for field in trigger.fields} + + # Sentry only takes 5-field crontabs. + if str(fields["second"]) != "0": + return None + if str(fields["year"]) != "*" or str(fields["week"]) != "*": + return None + + day = _get_crontab_field(fields["day"]) + day_of_week = _get_crontab_day_of_week(fields["day_of_week"]) + if day is None or day_of_week is None: + return None + + # APScheduler requires both to match, cron requires either. + if day != "*" and day_of_week != "*": + return None + + minute = _get_crontab_field(fields["minute"]) + hour = _get_crontab_field(fields["hour"]) + month = _get_crontab_field(fields["month"]) + if minute is None or hour is None or month is None: + return None + + return f"{minute} {hour} {day} {month} {day_of_week}" + + +def _get_monitor_config(trigger: "Any") -> "Optional[MonitorConfig]": + if isinstance(trigger, CronTrigger): + crontab = _get_crontab(trigger) + if crontab is None: + return None + # Without a timezone Sentry would read the crontab as UTC. + timezone = _get_timezone_name(trigger.timezone) + if timezone is None: + return None + return { + "schedule": {"type": "crontab", "value": crontab}, + "timezone": timezone, + } + + if isinstance(trigger, IntervalTrigger): + schedule = _get_interval_schedule(trigger.interval.total_seconds()) + if schedule is None: + return None + return {"schedule": schedule} + + # DateTrigger (one-off) and combining triggers have no schedule to monitor. + return None + + +def _begin_check_ins( + integration: "APSchedulerIntegration", + executor: "Any", + job: "Any", + run_times: "List[Any]", +) -> "Optional[_CronsListener]": + monitor_slug = _get_monitor_slug(job) + if match_regex_list(monitor_slug, integration.exclude_jobs): + return None + + monitor_config = _get_monitor_config(job.trigger) + if monitor_config is None: + logger.debug( + "Not monitoring APScheduler job %r: its trigger %r can't be " + "expressed as a Sentry Crons schedule, or its timezone has no " + "IANA name.", + job.id, + job.trigger, + ) + return None + + if _has_generated_id(job): + _warn_generated_id(job) + + listener = _get_listener(executor._scheduler) + start = now() + + # Each run time gets its own execution event, so its own check-in. + for run_time in run_times: + listener.add( + job.id, run_time, _PendingCheckIn(monitor_slug, monitor_config, start) + ) + + return listener + + +def _patch_submit_job() -> None: + """ + Send an `in_progress` check-in, with the monitor config derived from the + job's trigger, when the scheduler hands a due job to its executor. + `_CronsListener` sends the closing `ok`/`error` check-in. + + `submit_job` is called in the scheduler's process for every executor type + and is the last place where the `Job` (and so its trigger) is at hand. + The check-ins are opened only once it accepts the job, so runs skipped + because of `max_instances` get none. + """ + old_submit_job = BaseExecutor.submit_job + + @wraps(old_submit_job) + def sentry_submit_job( + self: "Any", job: "Any", run_times: "List[Any]", *args: "Any", **kwargs: "Any" + ) -> "Any": + integration = sentry_sdk.get_client().get_integration(APSchedulerIntegration) + if integration is None or not integration.monitor_jobs: + return old_submit_job(self, job, run_times, *args, **kwargs) + + listener = None + with capture_internal_exceptions(): + listener = _begin_check_ins(integration, self, job, run_times) + + try: + result = old_submit_job(self, job, run_times, *args, **kwargs) + except MaxInstancesReachedError: + # The run is skipped, not failed. + if listener is not None: + with capture_internal_exceptions(): + for run_time in run_times: + listener.discard(job.id, run_time) + raise + except BaseException: + # Not submitted, so no execution event will close the check-ins. + if listener is not None: + with capture_internal_exceptions(): + for run_time in run_times: + listener.finish(job.id, run_time, MonitorStatus.ERROR) + raise + + if listener is not None: + with capture_internal_exceptions(): + for run_time in run_times: + listener.open(job.id, run_time) + + return result + + BaseExecutor.submit_job = sentry_submit_job diff --git a/setup.py b/setup.py index 0c6eadbc92..9d0ca0f770 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ def get_file_text(file_name): extras_require={ "aiohttp": ["aiohttp>=3.5"], "anthropic": ["anthropic>=0.16"], + "apscheduler": ["apscheduler>=3.3,<4"], "arq": ["arq>=0.23"], "asyncpg": ["asyncpg>=0.23"], "beam": ["apache-beam>=2.12"], diff --git a/tests/integrations/apscheduler/__init__.py b/tests/integrations/apscheduler/__init__.py new file mode 100644 index 0000000000..2454f29039 --- /dev/null +++ b/tests/integrations/apscheduler/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("apscheduler") diff --git a/tests/integrations/apscheduler/test_apscheduler.py b/tests/integrations/apscheduler/test_apscheduler.py new file mode 100644 index 0000000000..7fe0f90671 --- /dev/null +++ b/tests/integrations/apscheduler/test_apscheduler.py @@ -0,0 +1,417 @@ +import asyncio +import threading +from datetime import datetime, timedelta, timezone +from unittest import mock + +import pytest +from apscheduler.events import ( + EVENT_JOB_ERROR, + EVENT_JOB_EXECUTED, + EVENT_JOB_MAX_INSTANCES, + EVENT_JOB_MISSED, +) +from apscheduler.executors.pool import ProcessPoolExecutor, ThreadPoolExecutor +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.triggers.interval import IntervalTrigger + +from sentry_sdk.integrations import apscheduler as apscheduler_integration +from sentry_sdk.integrations.apscheduler import APSchedulerIntegration + + +def _utcnow(): + # Naive UTC; the schedulers below run in UTC. APScheduler < 3.7 only takes + # pytz timezones, so avoid passing tzinfo objects. + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _check_ins(envelopes): + return [ + item.payload.json + for envelope in envelopes + for item in envelope.items + if item.headers.get("type") == "check_in" + ] + + +def ok_job(): + pass + + +def failing_job(): + raise ValueError("oops") + + +def _run_once(envelopes, trigger, func=ok_job, executor="debug", **job_kwargs): + """Run a job once through a real scheduler and return the check-ins.""" + finished = threading.Event() + + scheduler = BackgroundScheduler(timezone="UTC") + scheduler.add_executor(executor, alias="default") + # Added before the integration's listener, which is registered on the + # first submitted job, but both run in the same thread for each event. + scheduler.add_listener( + lambda event: finished.set(), + EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_MISSED, + ) + job_kwargs.setdefault("id", "my-job") + job_kwargs.setdefault("next_run_time", _utcnow()) + job_kwargs.setdefault("misfire_grace_time", None) + scheduler.add_job(func, trigger, **job_kwargs) + scheduler.start() + try: + assert finished.wait(timeout=10) + finally: + # Waits for the executor, so for the integration's listener, too. + scheduler.shutdown(wait=True) + + return _check_ins(envelopes) + + +def test_cron_job_ok(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, CronTrigger(minute="*/5", timezone="UTC")) + + assert len(check_ins) == 2 + in_progress, ok = check_ins + + assert in_progress["monitor_slug"] == "my-job" + assert in_progress["status"] == "in_progress" + assert in_progress["monitor_config"] == { + "schedule": {"type": "crontab", "value": "*/5 * * * *"}, + "timezone": "UTC", + } + + assert ok["monitor_slug"] == "my-job" + assert ok["status"] == "ok" + assert ok["check_in_id"] == in_progress["check_in_id"] + assert ok["monitor_config"] == in_progress["monitor_config"] + assert ok["duration"] >= 0 + + +def test_cron_job_error(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once( + envelopes, CronTrigger(minute="*/5", timezone="UTC"), failing_job + ) + + assert [c["status"] for c in check_ins] == ["in_progress", "error"] + assert check_ins[0]["check_in_id"] == check_ins[1]["check_in_id"] + + +def test_missed_run_is_error(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once( + envelopes, + CronTrigger(minute="*/5", timezone="UTC"), + next_run_time=_utcnow() - timedelta(minutes=1), + misfire_grace_time=1, + ) + + assert [c["status"] for c in check_ins] == ["in_progress", "error"] + + +def test_monitoring_is_opt_in(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration()]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, CronTrigger(minute="*/5", timezone="UTC")) + + assert check_ins == [] + + +def test_exclude_jobs(sentry_init, capture_envelopes): + sentry_init( + integrations=[APSchedulerIntegration(monitor_jobs=True, exclude_jobs=["my-.*"])] + ) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, CronTrigger(minute="*/5", timezone="UTC")) + + assert check_ins == [] + + +def test_generated_job_id_uses_job_name(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + with mock.patch.object(apscheduler_integration.logger, "warning") as warning: + check_ins = _run_once( + envelopes, CronTrigger(minute="*/5", timezone="UTC"), id=None + ) + + assert [c["monitor_slug"] for c in check_ins] == ["ok_job", "ok_job"] + warning.assert_called_once() + assert "has no explicit `id`" in warning.call_args[0][0] + + +def test_generated_job_id_warns_once(monkeypatch): + monkeypatch.setattr(apscheduler_integration, "_warned_job_ids", set()) + + class FakeJob: + id = "0123456789abcdef0123456789abcdef" + name = "ok_job" + + with mock.patch.object(apscheduler_integration.logger, "warning") as warning: + apscheduler_integration._warn_generated_id(FakeJob()) + apscheduler_integration._warn_generated_id(FakeJob()) + + warning.assert_called_once() + + +@pytest.mark.parametrize( + "cron_kwargs, expected", + [ + ({"minute": "*/5"}, "*/5 * * * *"), + ({"hour": 3}, "0 3 * * *"), + ({"hour": "9-17", "minute": "30"}, "30 9-17 * * *"), + ({"day": 1, "hour": 0}, "0 0 1 * *"), + ({"month": "1-3", "day": 15}, "0 0 15 1-3 *"), + ({"day_of_week": "mon-fri", "hour": 9}, "0 9 * * 1-5"), + ({"day_of_week": "sat,sun", "hour": 9}, "0 9 * * 6,7"), + ({"day_of_week": "0", "hour": 9}, "0 9 * * 1"), + ({"day_of_week": "*/2", "hour": 9}, "0 9 * * 1-7/2"), + ], +) +def test_cron_trigger_monitor_config( + sentry_init, capture_envelopes, cron_kwargs, expected +): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, CronTrigger(timezone="UTC", **cron_kwargs)) + + assert check_ins[0]["monitor_config"]["schedule"] == { + "type": "crontab", + "value": expected, + } + + +@pytest.mark.parametrize( + "cron_kwargs", + [ + # Seconds and years have no 5-field crontab equivalent. + {"second": "*/30"}, + {"year": 2030}, + {"week": 2}, + # APScheduler ANDs day and day_of_week, cron ORs them. + {"day": 1, "day_of_week": "mon"}, + {"day": "last"}, + {"day": "1st mon"}, + ], +) +def test_cron_trigger_without_crontab_equivalent( + sentry_init, capture_envelopes, cron_kwargs +): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, CronTrigger(timezone="UTC", **cron_kwargs)) + + assert check_ins == [] + + +def test_cron_trigger_timezone(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, CronTrigger(hour=9, timezone="Europe/Vienna")) + + assert check_ins[0]["monitor_config"]["timezone"] == "Europe/Vienna" + + +def _fixed_offset_cron_trigger(): + try: + return CronTrigger(hour=9, timezone=timezone(timedelta(hours=2))) + except TypeError: + # APScheduler < 3.7 only takes pytz timezones. + import pytz + + return CronTrigger(hour=9, timezone=pytz.FixedOffset(120)) + + +def test_cron_trigger_without_iana_timezone(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + # Sentry rejects timezones like "UTC+02:00", and without one it would + # read the crontab as UTC. + check_ins = _run_once(envelopes, _fixed_offset_cron_trigger()) + + assert check_ins == [] + + +@pytest.mark.parametrize( + "tz, expected", + [ + ("UTC", "UTC"), + ("Europe/Vienna", "Europe/Vienna"), + (timezone.utc, "UTC"), + (timezone(timedelta(hours=2)), None), + ("local", None), + ], +) +def test_get_timezone_name(tz, expected): + assert apscheduler_integration._get_timezone_name(tz) == expected + + +@pytest.mark.parametrize( + "interval_kwargs, expected", + [ + ({"minutes": 1}, (1, "minute")), + ({"minutes": 90}, (90, "minute")), + ({"hours": 2}, (2, "hour")), + ({"days": 1}, (1, "day")), + ({"weeks": 1}, (7, "day")), + ], +) +def test_interval_trigger_monitor_config( + sentry_init, capture_envelopes, interval_kwargs, expected +): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, IntervalTrigger(timezone="UTC", **interval_kwargs)) + + value, unit = expected + assert check_ins[0]["monitor_config"] == { + "schedule": {"type": "interval", "value": value, "unit": unit}, + } + assert [c["status"] for c in check_ins] == ["in_progress", "ok"] + + +@pytest.mark.parametrize("interval_kwargs", [{"seconds": 30}, {"seconds": 90}]) +def test_interval_trigger_not_whole_minutes( + sentry_init, capture_envelopes, interval_kwargs +): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, IntervalTrigger(timezone="UTC", **interval_kwargs)) + + assert check_ins == [] + + +def test_date_trigger_is_not_monitored(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, DateTrigger(run_date=_utcnow(), timezone="UTC")) + + assert check_ins == [] + + +def test_thread_pool_executor(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once( + envelopes, + CronTrigger(minute="*/5", timezone="UTC"), + executor=ThreadPoolExecutor(), + ) + + assert [c["status"] for c in check_ins] == ["in_progress", "ok"] + assert check_ins[0]["check_in_id"] == check_ins[1]["check_in_id"] + + +def test_max_instances_skip_sends_no_check_in(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + started = threading.Event() + release = threading.Event() + skipped = threading.Event() + finished = threading.Event() + + def blocking_job(): + started.set() + assert release.wait(timeout=10) + + scheduler = BackgroundScheduler(timezone="UTC") + scheduler.add_executor(ThreadPoolExecutor(), alias="default") + scheduler.add_listener(lambda event: skipped.set(), EVENT_JOB_MAX_INSTANCES) + scheduler.add_listener(lambda event: finished.set(), EVENT_JOB_EXECUTED) + scheduler.add_job( + blocking_job, + CronTrigger(minute="*/5", timezone="UTC"), + id="my-job", + next_run_time=_utcnow(), + misfire_grace_time=None, + max_instances=1, + ) + scheduler.start() + try: + assert started.wait(timeout=10) + # Due again while the first run is still going. + scheduler.modify_job("my-job", next_run_time=_utcnow()) + assert skipped.wait(timeout=10) + release.set() + assert finished.wait(timeout=10) + finally: + release.set() + scheduler.shutdown(wait=True) + + check_ins = _check_ins(envelopes) + assert [c["status"] for c in check_ins] == ["in_progress", "ok"] + assert check_ins[0]["check_in_id"] == check_ins[1]["check_in_id"] + + +def test_process_pool_executor(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once( + envelopes, + CronTrigger(minute="*/5", timezone="UTC"), + failing_job, + executor=ProcessPoolExecutor(1), + ) + + assert [c["status"] for c in check_ins] == ["in_progress", "error"] + assert check_ins[0]["check_in_id"] == check_ins[1]["check_in_id"] + + +async def async_ok_job(): + await asyncio.sleep(0) + + +def test_asyncio_scheduler(sentry_init, capture_envelopes): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + async def main(): + scheduler = AsyncIOScheduler(timezone="UTC") + scheduler.add_job( + async_ok_job, + CronTrigger(minute="*/5", timezone="UTC"), + id="my-async-job", + next_run_time=_utcnow(), + misfire_grace_time=None, + ) + scheduler.start() + try: + for _ in range(500): + if len(_check_ins(envelopes)) >= 2: + break + await asyncio.sleep(0.01) + finally: + scheduler.shutdown() + + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(main()) + finally: + loop.close() + + check_ins = _check_ins(envelopes) + assert [c["status"] for c in check_ins] == ["in_progress", "ok"] + assert [c["monitor_slug"] for c in check_ins] == ["my-async-job"] * 2 diff --git a/tests/test_crons.py b/tests/test_crons.py index 035b0df801..c5e53170e2 100644 --- a/tests/test_crons.py +++ b/tests/test_crons.py @@ -5,6 +5,7 @@ import sentry_sdk from sentry_sdk.crons import capture_checkin +from sentry_sdk.crons.utils import _get_interval_schedule @sentry_sdk.monitor(monitor_slug="abc123") @@ -517,3 +518,29 @@ async def test_contextmanager_error_async(sentry_init): assert fake_capture_checkin.call_args[1]["status"] == "error" assert fake_capture_checkin.call_args[1]["duration"] assert fake_capture_checkin.call_args[1]["check_in_id"] + + +@pytest.mark.parametrize( + "seconds, expected", + [ + (60, (1, "minute")), + (90 * 60, (90, "minute")), + (2 * 60 * 60, (2, "hour")), + (36 * 60 * 60, (36, "hour")), + (24 * 60 * 60, (1, "day")), + (7 * 24 * 60 * 60, (7, "day")), + (300.0, (5, "minute")), + ], +) +def test_get_interval_schedule(seconds, expected): + value, unit = expected + assert _get_interval_schedule(seconds) == { + "type": "interval", + "value": value, + "unit": unit, + } + + +@pytest.mark.parametrize("seconds", [0, 0.5, 30, 59, 90, 61.5, 3601]) +def test_get_interval_schedule_not_whole_minutes(seconds): + assert _get_interval_schedule(seconds) is None diff --git a/tox.ini b/tox.ini index 98ad950355..65cd8f9f24 100644 --- a/tox.ini +++ b/tox.ini @@ -264,6 +264,12 @@ envlist = # ~~~ Tasks ~~~ + {py3.6}-apscheduler-v3.3.1 + {py3.6}-apscheduler-v3.5.3 + {py3.6,py3.7}-apscheduler-v3.7.0 + {py3.8,py3.13,py3.14,py3.14t}-apscheduler-v3.11.3 + {py3.8,py3.13,py3.14,py3.14t}-apscheduler-latest + {py3.7,py3.9,py3.10}-arq-v0.23 {py3.9,py3.13,py3.14,py3.14t}-arq-v0.28.0 {py3.9,py3.13,py3.14,py3.14t}-arq-latest @@ -14832,6 +14838,35 @@ deps = # ~~~ Tasks ~~~ + apscheduler-v3.3.1: apscheduler==3.3.1 + + apscheduler-v3.5.3: apscheduler==3.5.3 + + apscheduler-v3.7.0: apscheduler==3.7.0 + + apscheduler-v3.11.3: apscheduler==3.11.3 + + py3.8-apscheduler-v3.11.3: tzlocal==5.2 + py3.8-apscheduler-v3.11.3: backports.zoneinfo==0.2.1 + + py3.13-apscheduler-v3.11.3: tzlocal==5.4.4 + + py3.14-apscheduler-v3.11.3: tzlocal==5.4.4 + + py3.14t-apscheduler-v3.11.3: tzlocal==5.4.4 + + apscheduler-latest: apscheduler==3.11.3 + + py3.8-apscheduler-latest: tzlocal==5.2 + py3.8-apscheduler-latest: backports.zoneinfo==0.2.1 + + py3.13-apscheduler-latest: tzlocal==5.4.4 + + py3.14-apscheduler-latest: tzlocal==5.4.4 + + py3.14t-apscheduler-latest: tzlocal==5.4.4 + + arq-v0.23: arq==0.23 py3.9-arq-v0.23: pydantic==1.10.26 @@ -19957,6 +19992,7 @@ setenv = aiohttp: _TESTPATH=tests/integrations/aiohttp aiomysql: _TESTPATH=tests/integrations/aiomysql anthropic: _TESTPATH=tests/integrations/anthropic + apscheduler: _TESTPATH=tests/integrations/apscheduler ariadne: _TESTPATH=tests/integrations/ariadne arq: _TESTPATH=tests/integrations/arq asyncpg: _TESTPATH=tests/integrations/asyncpg