diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml index 2483af4cc7..ca64893f7b 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 9c891428bd..019d879aa5 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 cb895d4bdc..f09eccc460 100644 --- a/scripts/populate_tox/package_dependencies.jsonl +++ b/scripts/populate_tox/package_dependencies.jsonl @@ -553,3 +553,7 @@ {"python_version": "3.9", "dependencies": [{"metadata": {"name": "gql", "version": "3.4.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/64/1c/2facac9c192d4470960537cf8cdf92d41af10e300a8095ebc37740b4df38/gql-3.4.1-py2.py3-none-any.whl"}}, {"metadata": {"name": "aiohttp", "version": "3.8.6"}, "download_info": {"url": "https://files.pythonhosted.org/packages/69/b7/a1543a5f90b6c5cdff4d2e6b73fcaa65a568ff9cc3381c5abfadcefe38e0/aiohttp-3.8.6-cp39-cp39-macosx_11_0_arm64.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": "backoff", "version": "2.2.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl"}}, {"metadata": {"name": "botocore", "version": "1.42.97"}, "download_info": {"url": "https://files.pythonhosted.org/packages/e3/d2/8e025ba1a4e257879af72d06913272311af79673d82fa2581a351b924317/botocore-1.42.97-py3-none-any.whl"}}, {"metadata": {"name": "charset-normalizer", "version": "3.4.7"}, "download_info": {"url": "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl"}}, {"metadata": {"name": "graphql-core", "version": "3.2.11"}, "download_info": {"url": "https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl"}}, {"metadata": {"name": "jmespath", "version": "1.1.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-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": "python-dateutil", "version": "2.9.0.post0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.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": "idna", "version": "3.18"}, "download_info": {"url": "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl"}}, {"metadata": {"name": "requests-toolbelt", "version": "0.10.1"}, "download_info": {"url": "https://files.pythonhosted.org/packages/05/d3/bf87a36bff1cb88fd30a509fd366c70ec30676517ee791b2f77e0e29817a/requests_toolbelt-0.10.1-py2.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": "1.26.20"}, "download_info": {"url": "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl"}}, {"metadata": {"name": "websockets", "version": "10.4"}, "download_info": {"url": "https://files.pythonhosted.org/packages/57/d7/df17197565e8874f0a77f8211304169ad4f39ffa3e8c008a7b0bf187a238/websockets-10.4-cp39-cp39-macosx_11_0_arm64.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": "responses", "version": "0.26.2"}, "download_info": {"url": "https://files.pythonhosted.org/packages/7c/28/693e1d9ebf72baa062ded80d837a035b86ce75eda5a269379e9e2b1008a8/responses-0.26.2-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": "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.6.17"}, "download_info": {"url": "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-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": "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": "six", "version": "1.17.0"}, "download_info": {"url": "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.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"}}], "install_targets_hash": "ec03e83b12e89730c3e80a2126d3d0ca"} {"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": "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 7ba78b5883..46eca7065f 100644 --- a/scripts/populate_tox/releases.jsonl +++ b/scripts/populate_tox/releases.jsonl @@ -2,3 +2,7 @@ {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">= 3.7", "version": "1.24.96", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "boto3-1.24.96-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "boto3-1.24.96.tar.gz"}]} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "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.8", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">=3.8", "version": "1.35.99", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "boto3-1.35.99-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "boto3-1.35.99.tar.gz"}]} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Natural Language :: English", "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", "Programming Language :: Python :: Free Threading :: 2 - Beta"], "name": "boto3", "requires_python": ">=3.10", "version": "1.43.118", "yanked": false}, "urls": [{"packagetype": "bdist_wheel", "filename": "boto3-1.43.118-py3-none-any.whl"}, {"packagetype": "sdist", "filename": "boto3-1.43.118.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 index d5fa55e500..a7bd46578b 100644 --- a/sentry_sdk/crons/utils.py +++ b/sentry_sdk/crons/utils.py @@ -1,6 +1,8 @@ +from datetime import timedelta, timezone from typing import TYPE_CHECKING if TYPE_CHECKING: + from datetime import tzinfo from typing import Optional, Tuple from sentry_sdk._types import MonitorConfigSchedule, MonitorConfigScheduleUnit @@ -27,3 +29,23 @@ def _get_interval_schedule(seconds: float) -> "Optional[MonitorConfigSchedule]": "unit": unit, } return None + + +def _get_timezone_name(tz: "tzinfo") -> "Optional[str]": + """ + Return the IANA name of the timezone, or None if it has none. + """ + # zoneinfo and pytz + name = getattr(tz, "key", None) or getattr(tz, "zone", None) + if isinstance(name, str): + # tzlocal's zone when it can't find the system's. + return None if name == "local" else name + + if isinstance(tz, timezone): + offset = tz.utcoffset(None) + if offset % timedelta(hours=1): + return None + hours = offset // timedelta(hours=1) + return "Etc/GMT%+d" % -hours if hours else "UTC" + + 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..265a11bbeb --- /dev/null +++ b/sentry_sdk/integrations/apscheduler.py @@ -0,0 +1,374 @@ +import math +import re +import threading +import weakref +from functools import 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, _get_timezone_name +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, 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, + ) + + +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]": + # Sentry schedules have no end, so finite triggers would alert as missed. + if getattr(trigger, "end_date", None) is not None: + return None + + monitor_config: "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 + monitor_config = { + "schedule": {"type": "crontab", "value": crontab}, + "timezone": timezone, + } + elif isinstance(trigger, IntervalTrigger): + schedule = _get_interval_schedule(trigger.interval.total_seconds()) + if schedule is None: + return None + monitor_config = {"schedule": schedule} + else: + # DateTrigger (one-off) and combining triggers have no schedule to monitor. + return None + + # Jitter delays runs by up to that many seconds; add it to Sentry's + # default 1 minute margin. + jitter = getattr(trigger, "jitter", None) + if jitter: + monitor_config["checkin_margin"] = 1 + math.ceil(jitter / 60) + + return monitor_config + + +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/sentry_sdk/integrations/arq.py b/sentry_sdk/integrations/arq.py index 623dda3258..ed1c78572f 100644 --- a/sentry_sdk/integrations/arq.py +++ b/sentry_sdk/integrations/arq.py @@ -1,10 +1,10 @@ import sys import time -from datetime import timedelta, timezone import sentry_sdk from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS from sentry_sdk.crons import monitor +from sentry_sdk.crons.utils import _get_timezone_name from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.scope import should_send_default_pii @@ -393,7 +393,7 @@ def _get_crontab(cron_job: "CronJob") -> "Optional[str]": return " ".join(fields) # type: ignore[arg-type] -def _get_timezone_name(worker: "Worker") -> "Optional[str]": +def _get_worker_timezone_name(worker: "Worker") -> "Optional[str]": tz = getattr(worker, "timezone", None) if tz is None: # Before arq 0.25, cron jobs ran in naive local time. @@ -401,27 +401,15 @@ def _get_timezone_name(worker: "Worker") -> "Optional[str]": return "UTC" return None - # zoneinfo and pytz - name = getattr(tz, "key", None) or getattr(tz, "zone", None) - if isinstance(name, str): - return name - # arq defaults to the system's fixed UTC offset at startup. - if isinstance(tz, timezone): - offset = tz.utcoffset(None) - if offset % timedelta(hours=1): - return None - hours = offset // timedelta(hours=1) - return "Etc/GMT%+d" % -hours if hours else "UTC" - - return None + return _get_timezone_name(tz) def _get_monitor_config( cron_job: "CronJob", worker: "Worker" ) -> "Optional[MonitorConfig]": crontab = _get_crontab(cron_job) - timezone_name = _get_timezone_name(worker) + timezone_name = _get_worker_timezone_name(worker) if crontab is None or timezone_name is None: return None return { diff --git a/setup.py b/setup.py index 8a066a2506..c0369adb6f 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..63d25c8414 --- /dev/null +++ b/tests/integrations/apscheduler/test_apscheduler.py @@ -0,0 +1,481 @@ +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" + + +@pytest.mark.parametrize( + "offset, expected", + [ + (timedelta(hours=2), "Etc/GMT-2"), + (timedelta(hours=-7), "Etc/GMT+7"), + (timedelta(0), "UTC"), + ], +) +def test_cron_trigger_fixed_offset_timezone( + sentry_init, capture_envelopes, offset, expected +): + try: + trigger = CronTrigger(hour=9, timezone=timezone(offset)) + except TypeError: + pytest.skip("APScheduler < 3.7 only takes pytz timezones.") + + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, trigger) + + assert check_ins[0]["monitor_config"]["timezone"] == expected + + +def _non_whole_hour_cron_trigger(): + try: + return CronTrigger(hour=9, timezone=timezone(timedelta(hours=5, minutes=30))) + except TypeError: + # APScheduler < 3.7 only takes pytz timezones. + import pytz + + return CronTrigger(hour=9, timezone=pytz.FixedOffset(330)) + + +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+05:30", and without one it would + # read the crontab as UTC. + check_ins = _run_once(envelopes, _non_whole_hour_cron_trigger()) + + assert check_ins == [] + + +@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 == [] + + +@pytest.mark.parametrize( + "trigger_cls,trigger_kwargs", + [(CronTrigger, {"minute": "*/5"}), (IntervalTrigger, {"minutes": 5})], +) +@pytest.mark.parametrize("jitter, checkin_margin", [(1, 2), (60, 2), (61, 3), (300, 6)]) +def test_trigger_jitter_adds_checkin_margin( + sentry_init, capture_envelopes, trigger_cls, trigger_kwargs, jitter, checkin_margin +): + try: + trigger = trigger_cls(timezone="UTC", jitter=jitter, **trigger_kwargs) + except TypeError: + pytest.skip("APScheduler < 3.5 has no jitter.") + + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, trigger) + + assert check_ins[0]["monitor_config"]["checkin_margin"] == checkin_margin + + +@pytest.mark.parametrize( + "trigger_cls,trigger_kwargs", + [(CronTrigger, {"minute": "*/5"}), (IntervalTrigger, {"minutes": 5})], +) +def test_trigger_without_jitter_has_no_checkin_margin( + sentry_init, capture_envelopes, trigger_cls, trigger_kwargs +): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + check_ins = _run_once(envelopes, trigger_cls(timezone="UTC", **trigger_kwargs)) + + assert "checkin_margin" not in check_ins[0]["monitor_config"] + + +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 == [] + + +@pytest.mark.parametrize( + "trigger_cls,trigger_kwargs", + [(CronTrigger, {"minute": "*/5"}), (IntervalTrigger, {"minutes": 5})], +) +def test_trigger_with_end_date_is_not_monitored( + sentry_init, capture_envelopes, trigger_cls, trigger_kwargs +): + sentry_init(integrations=[APSchedulerIntegration(monitor_jobs=True)]) + envelopes = capture_envelopes() + + trigger = trigger_cls( + timezone="UTC", end_date=_utcnow() + timedelta(days=1), **trigger_kwargs + ) + check_ins = _run_once(envelopes, trigger) + + 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 c5e53170e2..357c01f00b 100644 --- a/tests/test_crons.py +++ b/tests/test_crons.py @@ -1,11 +1,12 @@ import uuid +from datetime import timedelta, timezone, tzinfo from unittest import mock import pytest import sentry_sdk from sentry_sdk.crons import capture_checkin -from sentry_sdk.crons.utils import _get_interval_schedule +from sentry_sdk.crons.utils import _get_interval_schedule, _get_timezone_name @sentry_sdk.monitor(monitor_slug="abc123") @@ -544,3 +545,39 @@ def test_get_interval_schedule(seconds, expected): @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 + + +class _NamedZone(tzinfo): + def __init__(self, key): + self.key = key + + +@pytest.mark.parametrize( + "tz, expected", + [ + (timezone.utc, "UTC"), + (timezone(timedelta(0)), "UTC"), + (timezone(timedelta(hours=2)), "Etc/GMT-2"), + (timezone(timedelta(hours=-7)), "Etc/GMT+7"), + (timezone(timedelta(hours=5, minutes=30)), None), + (_NamedZone("Europe/Vienna"), "Europe/Vienna"), + # tzlocal's zone when it can't find the system's. + (_NamedZone("local"), None), + (_NamedZone(None), None), + ], +) +def test_get_timezone_name(tz, expected): + assert _get_timezone_name(tz) == expected + + +def test_get_timezone_name_zoneinfo(): + zoneinfo = pytest.importorskip("zoneinfo") + assert _get_timezone_name(zoneinfo.ZoneInfo("Europe/Vienna")) == "Europe/Vienna" + assert _get_timezone_name(zoneinfo.ZoneInfo("UTC")) == "UTC" + + +def test_get_timezone_name_pytz(): + pytz = pytest.importorskip("pytz") + assert _get_timezone_name(pytz.timezone("Europe/Vienna")) == "Europe/Vienna" + assert _get_timezone_name(pytz.utc) == "UTC" + assert _get_timezone_name(pytz.FixedOffset(120)) is None diff --git a/tox.ini b/tox.ini index 72073b1419..22ae4ea5be 100644 --- a/tox.ini +++ b/tox.ini @@ -265,6 +265,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 @@ -14882,6 +14888,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 @@ -19953,6 +19988,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