diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..d6316209 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,49 @@ +name: Linter +permissions: + pull-requests: read + checks: write + +on: + push: + branches: + - "master" + - "renovate/**" + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + # python-lint + - name: Install the latest version of ruff + uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0 + with: + version: "latest" + args: "format --check --diff" + - name: Run Ruff linter + run: | + # check the formatted codes + ruff check --select I --fix + ruff format + # run the linters + ruff check . + # Dockerfile lint + - name: hadolint + uses: reviewdog/action-hadolint@1b2cfa6ba72072ad35158d7ff3aa49bbdc03506d # v1.51.0 + with: + hadolint_ignore: DL3007 SC2114 + reporter: github-check + + # misspell + - name: misspell + uses: reviewdog/action-misspell@d6429416b12b09b4e2768307d53bef58d172e962 # v1.27.0 + with: + locale: "US" + reporter: github-check diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml new file mode 100644 index 00000000..531bfce9 --- /dev/null +++ b/.github/workflows/unittest.yml @@ -0,0 +1,74 @@ +name: Unittest +permissions: {} + +on: + push: + branches: + - "master" + - "renovate/**" + paths: + - '.github/workflows/unittest.yml' + - 'weather_briefing/**' + - 'tests/**' + - 'uv.lock' + - 'pyproject.toml' + pull_request: + paths: + - '.github/workflows/unittest.yml' + - 'weather_briefing/**' + - 'tests/**' + - 'uv.lock' + - 'pyproject.toml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + os: [ubuntu-latest, macOS-latest] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install the latest version of uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + version: '0.11.28' + - name: Test with python ${{ matrix.python-version }} + run: | + uv sync --frozen + uv run pytest + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.14' + - name: Install the latest version of uv and set the python version + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + version: '0.11.28' + - name: Generate Coverage Reports + run: | + uv sync --frozen + uv run pytest --cov --cov-branch --cov-report=xml + - name: Upload results to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/pyproject.toml b/pyproject.toml index 2c20bc2b..09067e6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ dev = [ "anyio>=4.11,<5", "pytest>=9,<10", + "pytest-cov>=7,<8", "ruff>=0.5,<1", ] diff --git a/tests/test_config.py b/tests/test_config.py index b82d752a..e1473f5c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -54,9 +54,10 @@ def test_weather_provider_order_can_be_configured(monkeypatch) -> None: settings = Settings.from_env() - assert weather_providers_for( - _resolved_location(mainland=True), settings.weather_providers - ) == ("open-meteo", "qweather") + assert weather_providers_for(_resolved_location(mainland=True), settings.weather_providers) == ( + "open-meteo", + "qweather", + ) def test_non_mainland_weather_providers_default_to_open_meteo_only(monkeypatch) -> None: @@ -65,14 +66,10 @@ def test_non_mainland_weather_providers_default_to_open_meteo_only(monkeypatch) settings = Settings.from_env() - assert weather_providers_for( - _resolved_location(mainland=False), settings.weather_providers - ) == ("open-meteo",) + assert weather_providers_for(_resolved_location(mainland=False), settings.weather_providers) == ("open-meteo",) -def test_optional_rss_sources_are_loaded_from_named_file( - monkeypatch, tmp_path: Path -) -> None: +def test_optional_rss_sources_are_loaded_from_named_file(monkeypatch, tmp_path: Path) -> None: _required_environment(monkeypatch) source_file = tmp_path / "rss-sources.json" source_file.write_text( @@ -87,9 +84,7 @@ def test_optional_rss_sources_are_loaded_from_named_file( assert [feed.id for feed in settings.feeds] == ["test"] -def test_location_file_supports_multiple_places_and_optional_coordinates( - monkeypatch, tmp_path: Path -) -> None: +def test_location_file_supports_multiple_places_and_optional_coordinates(monkeypatch, tmp_path: Path) -> None: _required_environment(monkeypatch) location_file = tmp_path / "locations.json" location_file.write_text( @@ -108,18 +103,13 @@ def test_location_file_supports_multiple_places_and_optional_coordinates( assert settings.locations[1].longitude == 116.380556 -def test_rss_source_location_ids_must_reference_configured_locations( - monkeypatch, tmp_path: Path -) -> None: +def test_rss_source_location_ids_must_reference_configured_locations(monkeypatch, tmp_path: Path) -> None: _required_environment(monkeypatch) location_file = tmp_path / "locations.json" - location_file.write_text( - '[{"id":"beijing","name":"Beijing"}]', encoding="utf-8" - ) + location_file.write_text('[{"id":"beijing","name":"Beijing"}]', encoding="utf-8") source_file = tmp_path / "rss-sources.json" source_file.write_text( - '[{"id":"feed","name":"Feed","url":"https://example.invalid/feed",' - '"location_ids":["shanghai"]}]', + '[{"id":"feed","name":"Feed","url":"https://example.invalid/feed","location_ids":["shanghai"]}]', encoding="utf-8", ) monkeypatch.setenv("BRIEFING_LOCATIONS_FILE", str(location_file)) diff --git a/tests/test_content_cleaners.py b/tests/test_content_cleaners.py index b1f36cf5..63c542cb 100644 --- a/tests/test_content_cleaners.py +++ b/tests/test_content_cleaners.py @@ -21,8 +21,6 @@ def test_html_cleaner_removes_page_chrome_without_rewriting_article_text() -> No def test_html_cleaner_decodes_entities_to_visible_text() -> None: - cleaned = HTMLContentCleaner().clean( - "
风力 7–8 级
", ContentCleaningRules() - ) + cleaned = HTMLContentCleaner().clean("风力 7–8 级
", ContentCleaningRules()) assert cleaned == "风力 7–8 级" diff --git a/tests/test_geocoding.py b/tests/test_geocoding.py index ad6b814f..5faa554f 100644 --- a/tests/test_geocoding.py +++ b/tests/test_geocoding.py @@ -36,9 +36,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - result = await OpenMeteoGeocodingProvider(client).geocode( - LocationSpec("beijing", "北京市西城区中南海") - ) + result = await OpenMeteoGeocodingProvider(client).geocode(LocationSpec("beijing", "北京市西城区中南海")) assert result.latitude == 39.911389 assert result.country_code == "CN" @@ -76,9 +74,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - result = await OpenMeteoGeocodingProvider(client).geocode( - LocationSpec("example", "中国北京市西城区中南海") - ) + result = await OpenMeteoGeocodingProvider(client).geocode(LocationSpec("example", "中国北京市西城区中南海")) assert result.latitude == 39.911389 assert result.longitude == 116.380556 @@ -135,9 +131,9 @@ def handler(request: httpx.Request) -> httpx.Response: ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - result = await NominatimGeocodingProvider( - client, user_agent="weather-briefing-test/1" - ).geocode(LocationSpec("beijing", "北京市西城区中南海地区")) + result = await NominatimGeocodingProvider(client, user_agent="weather-briefing-test/1").geocode( + LocationSpec("beijing", "北京市西城区中南海地区") + ) assert result.latitude == 39.911389 assert result.longitude == 116.380556 @@ -164,9 +160,9 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: ) original_name = "中国北京市西城区中南海1号" - result = await PrecisionReducingGeocodingProvider( - RoadLevelGeocoder() - ).geocode(LocationSpec("example", original_name)) + result = await PrecisionReducingGeocodingProvider(RoadLevelGeocoder()).geocode( + LocationSpec("example", original_name) + ) assert queries == [original_name, "中国北京市西城区中南海"] assert result.name == original_name diff --git a/tests/test_publishers.py b/tests/test_publishers.py index cac77671..acbee265 100644 --- a/tests/test_publishers.py +++ b/tests/test_publishers.py @@ -60,6 +60,4 @@ async def test_telegram_rejects_oversized_single_message_before_delivery() -> No async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200))) as client: publisher = TelegramPublisher(client, "runtime-token", "runtime-chat") with pytest.raises(DeliveryError, match="exceeds"): - await publisher.publish( - RenderedMessage("short markup", 4097), single_message=True - ) + await publisher.publish(RenderedMessage("short markup", 4097), single_message=True) diff --git a/tests/test_service.py b/tests/test_service.py index 03adb5bd..f5a37472 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -98,11 +98,7 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic "conclusions": [conclusion], "active_warnings": [], "resolved_warning_ids": [], - "advice": ( - [conclusion] - if payload["mode"] == "daily" or self._include_hourly_advice - else [] - ), + "advice": ([conclusion] if payload["mode"] == "daily" or self._include_hourly_advice else []), "disaster_tracking": [], "should_publish": self._should_publish, } diff --git a/tests/test_weather_context.py b/tests/test_weather_context.py index cc11dbde..afb0c83b 100644 --- a/tests/test_weather_context.py +++ b/tests/test_weather_context.py @@ -118,18 +118,14 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "metadata": { - "attributions": ["https://developer.qweather.com/attribution.html"] - }, + "metadata": {"attributions": ["https://developer.qweather.com/attribution.html"]}, "indexes": [ { "code": "cn-mee", "name": "中国环境空气质量指数", "aqi": 68, "category": "良", - "health": { - "advice": {"generalPopulation": "可以正常进行户外活动。"} - }, + "health": {"advice": {"generalPopulation": "可以正常进行户外活动。"}}, } ], "pollutants": [ @@ -201,7 +197,7 @@ def handler(request: httpx.Request) -> httpx.Response: "us_aqi": 42, "us_aqi_pm2_5": 35, "pm2_5": 9.5, - } + }, }, ) diff --git a/uv.lock b/uv.lock index 510c79ec..92784429 100644 --- a/uv.lock +++ b/uv.lock @@ -156,6 +156,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/83/6051c2a2feab48ae5bd27c84ef047191d2d4a3172f689e38eaa48ed17db1/coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67", size = 927640, upload-time = "2026-07-12T20:58:19.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/ee/5095e07a0986930174fc153fd0bb115f7f2724f5344cc672e016d4f23a4e/coverage-7.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6506330b4a8dcf53b95bd84d8d0e817107cdb3fc1438e835029cdf0bc6612eb0", size = 221320, upload-time = "2026-07-12T20:56:16.036Z" }, + { url = "https://files.pythonhosted.org/packages/43/7a/7e2598ce98433a214020a61c0755d5961f9f26df0c630dc73e06b86d3416/coverage-7.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8a51a8ec382c39d939cba0ab07ae949077ae4e842343bd4eed22d432358cea9", size = 221823, upload-time = "2026-07-12T20:56:17.54Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4f/67dac6a69139b490a239d91b6d5ef127982ffb89159769241656ab879ee5/coverage-7.15.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18ea20e3922d7f8ca9e0ef1084408d08c4ad62d5e531cb9c1f6896a99297ebea", size = 252242, upload-time = "2026-07-12T20:56:18.868Z" }, + { url = "https://files.pythonhosted.org/packages/05/37/5e439725a96de592309725550c8df639c359c209dcb4b152ed46032d4c0d/coverage-7.15.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aab9902a64b8390e3b56e539fddae1d79a267807fe5cb0c18d7d2f544ce867e2", size = 254153, upload-time = "2026-07-12T20:56:20.118Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/671ba9e15f9651a99962fb588a1fe4fb267cae4bb85f9bb4ac4413c6f7ef/coverage-7.15.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cc316264317b07a9e90d7f2b4188a15e36e9b54e651081b791b0515fa612a29", size = 256261, upload-time = "2026-07-12T20:56:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3c/88305322b4d015b4536b7174b8f9b87f850f01af9d89008cdbf984b65ff2/coverage-7.15.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d4e47e7eea81a8ccf060a07627654151d929da62c7b715738387c200905cec89", size = 258222, upload-time = "2026-07-12T20:56:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/d02e42333a57f266ded61ed4fb3821da1f2dc143734aaa3dcc9c117204c5/coverage-7.15.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6829d9a3b55ad2b73ef5fda8302e5be03683789e88b1a079dcf4a773229c21d", size = 252368, upload-time = "2026-07-12T20:56:24.475Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d0/94ebc010926a191d83e83b1f1236f8bd0d14d0eb98b5c324aa4be2589a8e/coverage-7.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:764e045811f9c8cda436641f3f088283351d331a519b5807f19041cd0a68da1c", size = 253953, upload-time = "2026-07-12T20:56:26.036Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/2c8553191da0c2da2c43ff294fb9bab57a5b0fae03560304a82a8b5addc3/coverage-7.15.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:09b3b088aa24489c4082bcc35fcc8224281ab94a653dfb6d3f0c8165b0d628ab", size = 252016, upload-time = "2026-07-12T20:56:27.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/20f47986d8360fa1a31d9858dd2ba0be009d44ecfa8d02120b1eb93c028b/coverage-7.15.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7911b02f57053adf8164ae63edb1c26574d24dfccabadc5268cf69310a69a358", size = 255783, upload-time = "2026-07-12T20:56:28.921Z" }, + { url = "https://files.pythonhosted.org/packages/17/36/fb61983b5259f78fa5e62ee74e91fe4de4c556fcbc9a3b9c9021c2f8b57b/coverage-7.15.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:664e279ed40599b8ed16f4db18d92a7e212c73129672bec8f5d96d4da48d2404", size = 251735, upload-time = "2026-07-12T20:56:30.465Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2e/d109d8d2d6c726ba2e78125297073918a2a91464f0ea777e5398fa3cf75c/coverage-7.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:34fe7cf79d5f1f87f2e8ce7dc1c32950841f50e10d0120f263856acfad66de34", size = 252645, upload-time = "2026-07-12T20:56:32.076Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/5b3219cbb46135e14673427f2bf5890c4da4e6f655243692f3448aa3f42c/coverage-7.15.1-cp311-cp311-win32.whl", hash = "sha256:5e2d2536d2f57a354aa382ed303ac0e2e5c9522a508c05b998d26181b94163a7", size = 223414, upload-time = "2026-07-12T20:56:33.432Z" }, + { url = "https://files.pythonhosted.org/packages/5b/80/24e13ade0b6df8090e2492f9c55044bf1423f7ef28c76fc1af8c827a61cb/coverage-7.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:c337da8fca7ea93ab43f3868cfcde6cf6dad32c3906b273cfbad5d7390bc423b", size = 223888, upload-time = "2026-07-12T20:56:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9e/34659930ae380491af48e2f5f5f9a2e99cd9fb7de3d30f27be5ac5425137/coverage-7.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:db3403fdb7a94d5eb73e099befad8104d2a7d110a0f0d99df0de61c5d1fa756c", size = 223435, upload-time = "2026-07-12T20:56:36.302Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/32c1826309beaf4604c54accef108fdd611e5e5e93f2f5192f050cd5f6bd/coverage-7.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9476292594309db922cc841dd13b303b3c388f4c25d279884f7e2341c681f80", size = 221497, upload-time = "2026-07-12T20:56:37.628Z" }, + { url = "https://files.pythonhosted.org/packages/db/5c/b88ce0d68fa550c7f3b58617fbf363bce64df5bf8295a01b627e4696e022/coverage-7.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c579056b0de461b3a62318b63d0b6ce90aed7f8158d3f00da094df82f29d189", size = 221854, upload-time = "2026-07-12T20:56:39.033Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/8509fd2a66fc4e0a829f76a0f0b1dc3cc163368352435b5f243168658077/coverage-7.15.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:23214bdbe226f2b0e9c66a7d6a1d59d4a88045dcf86e702cf0fe0d0935e3d615", size = 253359, upload-time = "2026-07-12T20:56:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/e5/81/c7009ed7ea9765adb2b9d095054d748266fae5f07ac6c5f925f33715fcde/coverage-7.15.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df164be93b46b4825cc39339440a05edc54c4d1d865ba4a60fd43d151a2a1cd3", size = 256096, upload-time = "2026-07-12T20:56:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/21/52/dc8ee03968a5ba86e2da5aa48ddc9e3747bd65d63825fdce2d96acb9c5ff/coverage-7.15.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a524fca1a6f08927d9dc2d4c873cfb7bd7202c247f08b14bdc02424071b8b304", size = 257211, upload-time = "2026-07-12T20:56:43.513Z" }, + { url = "https://files.pythonhosted.org/packages/b8/27/95d7623908da8937deb53d48efcdbf423907a47540e63c62fa21372c652b/coverage-7.15.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d70f3542cd38de85a9e257dcb1ac4c1ab4b6d7d2c2a645809207556628755d1c", size = 259473, upload-time = "2026-07-12T20:56:44.974Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3b/730d761928de97d585465680b568ae69622fb40716babadeabffe75cb51b/coverage-7.15.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d78aa537237212c4313aabe5e964b66acc86350ed19ebc56a3e202df33b6077b", size = 253759, upload-time = "2026-07-12T20:56:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fc/6b9277acff1f9484b6c12857af5774689d1a6a95e13265f7405329d2f5da/coverage-7.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a318112bb4f79d9d04766196d5a3388caa825908a6a9b052aa87de3d9aea7c61", size = 255131, upload-time = "2026-07-12T20:56:48.073Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f2/c704f86129594ba34e25a64695d2068c71d51c2b98907184d716c94f4aec/coverage-7.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e55d24cada901963eed5bc89fa562aa033f0d84b9d3de4ecf363737c13aed11e", size = 253275, upload-time = "2026-07-12T20:56:49.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/29/80fee8af47de4a6dce71ccf2938491f444687a756af258a56d8469b8f1b0/coverage-7.15.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c78f0cea7275342cf2adc2ad5fdd0aafa106ad91e66d573568f2fcf62c41df5", size = 257345, upload-time = "2026-07-12T20:56:51.038Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/a1e7d7ed1b48a8adf8fd5154d9e83fcc5ad8e6ff20ae00e44865057dce8d/coverage-7.15.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:86bd37eabe39977216f630a7fc1b698e7f5e81a191c7186013245c6c3d313f9d", size = 252844, upload-time = "2026-07-12T20:56:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8c/a4bc26e6ee207d412f3678f04d74be1550e83140563ca0e4997510579712/coverage-7.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6db15c217693bdc3ca0b84de1ba9afafe1c14c26a8a29d77f4ed0de2b6132e2", size = 254716, upload-time = "2026-07-12T20:56:53.968Z" }, + { url = "https://files.pythonhosted.org/packages/11/9d/8ad0266ecfada6353cf6627a1a02294cf55a907521b6ee0bd7b770cfd659/coverage-7.15.1-cp312-cp312-win32.whl", hash = "sha256:359f3fbe09a51500c51966596ee4ee4070b356552c70b3b2420eb200d68e0f76", size = 223554, upload-time = "2026-07-12T20:56:55.583Z" }, + { url = "https://files.pythonhosted.org/packages/81/6d/24224929e06c6e05a93f738bc5f9e8e6ab658f8f1d9b823e7b85430e28b8/coverage-7.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:fa75dc099c126e941a9c0baa8ebd2cbc78bd778687534fe410baf754f6d9e374", size = 224087, upload-time = "2026-07-12T20:56:57.041Z" }, + { url = "https://files.pythonhosted.org/packages/35/23/f81441dd01de88e53c97842e706907b307d9078918c3f4998b11e9ac7250/coverage-7.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:26f89cf6d0634375f454fa71057945ad18edb0f1607a90fecf22c57dc3dc289a", size = 223472, upload-time = "2026-07-12T20:56:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/6fa289d7993a2a39f1b283ddb58c4bfec80f7800be654b8ba8a9f6a07c63/coverage-7.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71ac4ca1658ca99160fd58cc6967110e989c34b04627f24ed6ec9f70fb24571a", size = 221519, upload-time = "2026-07-12T20:57:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/0db4902a0588234a70ab0218073c0b20fbc5c740aa35f91d360160a2ebc9/coverage-7.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26a40cbf2b13bd94af53ee02a424cb3bb96a9edfac0d00834bd068512a62714b", size = 221895, upload-time = "2026-07-12T20:57:01.867Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/3719783865092dac5e08df842730305ee9ab1973ae7ddb6fbdf27d401f30/coverage-7.15.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4c5a5eff4ad4f9f7088fd3fc7a66d98d06566ee294b3b053309fb0a3b45be1e", size = 252882, upload-time = "2026-07-12T20:57:03.459Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5e/caf3abbdbb22629626160ffc9c017eb995b7cb11c0be46b974834cef1792/coverage-7.15.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:962aa56c1c9b016d681265880eb6acc9966029d2c4c559319cc43a1abbb9b59a", size = 255479, upload-time = "2026-07-12T20:57:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f1/d60f375bfe095fef944f0f19427aefdbf9bdd5a9571c41a4bf6e2f5fdb81/coverage-7.15.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1678eb2dc57a8ce67601b029582ef6d41e9e6ca22692aaeccd4107e40f27386c", size = 256715, upload-time = "2026-07-12T20:57:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/d7/17/8b0cbc90d02dc5adad4d9034c1824ec3fa567771b4c39d9c1e3f9b1431b8/coverage-7.15.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1174900a43f6f8c425fee10d7dbddc308adefcdc78aaced32357f5ab750a0e90", size = 258845, upload-time = "2026-07-12T20:57:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/92/29/c5e69f5fb75c322e9a3e4ef64d02eebfc3d66efceccc8514ff80a3c13a56/coverage-7.15.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98847557a6859cadf693792ce89f440cb89692993f60dc6d3a7e35f3d340216f", size = 253098, upload-time = "2026-07-12T20:57:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/57/21144252fdd0c01d707d48fbcea13a80b0b7c42ced3f299f885ab8978c3a/coverage-7.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8697b2edb57143546a24389efc11e1b000cd5800fc20d84f04edb601e4a7cfb8", size = 254844, upload-time = "2026-07-12T20:57:11.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/2a/499a28a322b0ce6768328e6c5bb2e2ad00ac068a7c7adb2ecd8533c8c5d9/coverage-7.15.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6827ac0519be3fe91bf96b4060eb00d1d24f82649b29862cd75a3cfca248b02a", size = 252807, upload-time = "2026-07-12T20:57:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/928a95da5da8b60f2b00e1482c7787b3316188e6d2d227fb8e124ada43a1/coverage-7.15.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2de8ecbbc77c7e4d22572779920ed8979c69168675e96be3a548c996568c6c31", size = 256965, upload-time = "2026-07-12T20:57:14.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/10/889adbc1b8c9f866ed51e18a98bcafc0259fb9d29b81f50a719407c64ea8/coverage-7.15.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b25f0f0fa5260df9d7bb55d47c8bdc23fa3382c1a18f7c9cae122e6c320b1ad", size = 252628, upload-time = "2026-07-12T20:57:15.892Z" }, + { url = "https://files.pythonhosted.org/packages/1a/30/a5e1871e5d93416511f8e359d1ccebfe0cbb050a1bbf7dd20228533ec0cf/coverage-7.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a2effcbd93ae340a58db718fe4181d967f84d352c4cefeaab4ff82ce813901a", size = 254399, upload-time = "2026-07-12T20:57:17.703Z" }, + { url = "https://files.pythonhosted.org/packages/2d/26/c36fbffd549dadbdd1a75827528fb00a4c46aa3187b007b750b1e2cebbf2/coverage-7.15.1-cp313-cp313-win32.whl", hash = "sha256:895e65c96aef0cecea250f6e35e9a32f11375514e1a0cb5210e0fda128c04e8e", size = 223564, upload-time = "2026-07-12T20:57:19.253Z" }, + { url = "https://files.pythonhosted.org/packages/16/fc/becbb9d2c4206d242b9b1e1e8e24a42f7926c0200dd3c788b9fab4bb96d5/coverage-7.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6d0a28b63a0d75f9ed5118105d1154fc3aa40a8605a30d5d87e3d043ad90fe7", size = 224106, upload-time = "2026-07-12T20:57:21.108Z" }, + { url = "https://files.pythonhosted.org/packages/d3/30/1cfc641461369b6858799fca61c0a8b5edc490c519bf7c636ffa6bbf556f/coverage-7.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:b4ee9818e8bae3544379ad2c09b851c4fb886aaa8860d57a1c1316ddcb16db49", size = 223497, upload-time = "2026-07-12T20:57:22.734Z" }, + { url = "https://files.pythonhosted.org/packages/f0/46/81961952e7aebfb38ad0ae4264e8954cc607a7af9e7ac111f9fa986595cc/coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8", size = 221560, upload-time = "2026-07-12T20:57:24.282Z" }, + { url = "https://files.pythonhosted.org/packages/13/d2/ee14d715889f216baf47301d9f469e08fff6995552aaf67e897b282865f6/coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4", size = 221894, upload-time = "2026-07-12T20:57:25.87Z" }, + { url = "https://files.pythonhosted.org/packages/f3/38/f830bc6e6c2c5f23f43847125e6c650d378872f7eeba8d49f1d42193e8a9/coverage-7.15.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5bbe2a06e0a5e1404d9ffbdb49b819bbd6a3bb198ebea4c8dfe7ad9f1e1c2e81", size = 252938, upload-time = "2026-07-12T20:57:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/0d3dd963631259d794c898735d5436e68d6a8d40749c419a07ff7c171469/coverage-7.15.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde0fe24083d0b7b3dbafa7a09f0796410af1afa2523f28f5f208d8340a4aaca", size = 255445, upload-time = "2026-07-12T20:57:29.234Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fd/aabed228557565c958259251b89bab8c5669b31291fa63b3e2154ebb017a/coverage-7.15.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f89f7453d6d46db14cf233e2cd8edcd78de2b9c49d4f1dc109590b4e5dbfbb74", size = 256790, upload-time = "2026-07-12T20:57:30.826Z" }, + { url = "https://files.pythonhosted.org/packages/bc/aa/1cc888e5d3623e603c4e5399653cb25728bb2b40d7519188a3e293d24620/coverage-7.15.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc3656c9ecc27b36bd0907455b77f83c0069ca9ad4a66dec892b76c696eb6047", size = 259104, upload-time = "2026-07-12T20:57:32.63Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/fc16d5f5e53098dae41efa21e8ccc611a9b4fe922750dd03dc56db552182/coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9", size = 252956, upload-time = "2026-07-12T20:57:34.316Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f3/52384668c3de4519ca770bf1975a89e4d6eb5aa2faf0da0577a14008cba4/coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652", size = 254797, upload-time = "2026-07-12T20:57:35.947Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/54b807e7c1868178e902fd8360b5d4e559394462f97285c50edf1c4608db/coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4", size = 252762, upload-time = "2026-07-12T20:57:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/dde8adf0338e3ace738757dccf1ce817e5fdcadfae77e1b48a77e5a3b265/coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c", size = 257037, upload-time = "2026-07-12T20:57:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/179dd88cf60a0aeeee16a970ffe250dccea8b80ed4beab4c5d3f6c41ad4b/coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633", size = 252577, upload-time = "2026-07-12T20:57:41.363Z" }, + { url = "https://files.pythonhosted.org/packages/2c/37/8a593d69ab521beb6a105a2017cac4ba94425ee0a8349e29c3c0b522d24f/coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41", size = 254235, upload-time = "2026-07-12T20:57:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/bc9b3bced66f2cdad4bf5e57ae51c54ea226e8aaaebfc9370a9a11877bf3/coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b", size = 223771, upload-time = "2026-07-12T20:57:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/4d20337bed61915d14349e62b88d5e4144d5a9872b64adbe90e9906db6db/coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a", size = 224257, upload-time = "2026-07-12T20:57:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/7b/df/bbfeae4948f3ded516f92b32f2d57952427fc5ecfc0924487bb6ee6a5f38/coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74", size = 223683, upload-time = "2026-07-12T20:57:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/35/65/0b431856064e387d1f5cf474625e4a0465e907024d42f35de6af19ced0be/coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b", size = 222298, upload-time = "2026-07-12T20:57:49.882Z" }, + { url = "https://files.pythonhosted.org/packages/a6/96/50eac9bd49df8a3df5f3d38746d1bf332299dffb554486c94ebd55c9dc49/coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49", size = 222561, upload-time = "2026-07-12T20:57:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5b/6ba1c4a27e10b8816fd2622b98162c83d3bdf1185097360373611bf96364/coverage-7.15.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:620482ef1c9f4e61f962e159325fe77dea59d16e39d9c9470d069053b244d864", size = 263923, upload-time = "2026-07-12T20:57:53.392Z" }, + { url = "https://files.pythonhosted.org/packages/e4/59/fe03ade97a3ca2d890e98c572cf48a99fda9adba85757c34b823f41efe1e/coverage-7.15.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d385fc9b054e309ad3cecdc77b586d2af0c98aeec2fdb3773544586f366e817c", size = 266043, upload-time = "2026-07-12T20:57:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/16/e0/55c4b1217a572a43e13b39e1eb78d0da29fb23679003bd0cdf22c50b1978/coverage-7.15.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1198bca9c0dd7c188aae1f185b0c0b5fc4f0a2b6909000858c29550320bdb07", size = 268465, upload-time = "2026-07-12T20:57:57.017Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/ee47944f76afc03909119b036fe9e0da8cbd274a5141287de79791a0fb6d/coverage-7.15.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d0297e6a070eadb49df7cddd0ab6f420b8b689dd8904c7dd815a323168fa57e", size = 269584, upload-time = "2026-07-12T20:57:58.958Z" }, + { url = "https://files.pythonhosted.org/packages/cb/8a/6b4d9779c7b2e21c3d12c3425e3261aa7411399319e27aa402dfec4db5d0/coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e", size = 263019, upload-time = "2026-07-12T20:58:00.979Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1e/db5c7fa0c8ba5ece390a1e1a3f30db71d440240a80589df28e66a7503c40/coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf", size = 265916, upload-time = "2026-07-12T20:58:03.005Z" }, + { url = "https://files.pythonhosted.org/packages/83/53/fe5176682b00709b13fab36addd26883139d0dea430816fea412e69255e2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82", size = 263520, upload-time = "2026-07-12T20:58:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e7/16f15127be93fbc70c667df5ec5dce934fc76c9b0888d84969a5d5341e2c/coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82", size = 267254, upload-time = "2026-07-12T20:58:06.824Z" }, + { url = "https://files.pythonhosted.org/packages/cd/73/e5119111f6f065376395a525f7ce6e9174d83f3db6d217ea0211a61cca4d/coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7", size = 262366, upload-time = "2026-07-12T20:58:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9c/6d0a81182df18a73b081e7a8630f0e2a52b12dfd7898c6ab839551a454d2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594", size = 264680, upload-time = "2026-07-12T20:58:10.359Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a2/bac0cbd4450638f1be2041a464b1766c8cc94abf705a2df6f1c8d4be870d/coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070", size = 224077, upload-time = "2026-07-12T20:58:12.065Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b2/d83c5403155172a43ba47c08641bad3f89822d8405102423a41339d2c857/coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f", size = 224908, upload-time = "2026-07-12T20:58:13.956Z" }, + { url = "https://files.pythonhosted.org/packages/cc/41/442b74cad832cc77712080585455482e7cc4f4a9a13192f65731dcd18231/coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b", size = 224219, upload-time = "2026-07-12T20:58:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/34/98/07a67cf1a26e795d617ed5c540c042b0ac87b72f810c30c07f076cf334f3/coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c", size = 213284, upload-time = "2026-07-12T20:58:18.079Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "49.0.0" @@ -410,6 +499,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -489,6 +592,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -537,6 +694,7 @@ dependencies = [ dev = [ { name = "anyio" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -555,5 +713,6 @@ requires-dist = [ dev = [ { name = "anyio", specifier = ">=4.11,<5" }, { name = "pytest", specifier = ">=9,<10" }, + { name = "pytest-cov", specifier = ">=7,<8" }, { name = "ruff", specifier = ">=0.5,<1" }, ] diff --git a/weather_briefing/air_quality.py b/weather_briefing/air_quality.py index ddfddb8d..2d228871 100644 --- a/weather_briefing/air_quality.py +++ b/weather_briefing/air_quality.py @@ -79,11 +79,7 @@ async def fetch( def air_quality_to_document(snapshot: AirQualitySnapshot) -> SourceDocument: - observed_at = ( - snapshot.observed_at.to_iso8601_string() - if snapshot.observed_at is not None - else "不可用" - ) + observed_at = snapshot.observed_at.to_iso8601_string() if snapshot.observed_at is not None else "不可用" concentration = "不可用" if snapshot.pm25_concentration is not None and snapshot.pm25_unit: concentration = f"{snapshot.pm25_concentration:g} {snapshot.pm25_unit}" diff --git a/weather_briefing/cli.py b/weather_briefing/cli.py index dce9d73c..3ed15c8b 100644 --- a/weather_briefing/cli.py +++ b/weather_briefing/cli.py @@ -80,9 +80,7 @@ async def run(kind: str, enforce_window: bool, at: str | None = None) -> None: if enforce_window and not _in_schedule(kind, now, settings): print(f"Skipping delayed {kind} run outside configured local-time window") return - async with httpx.AsyncClient( - timeout=settings.http_timeout_seconds, follow_redirects=True - ) as client: + async with httpx.AsyncClient(timeout=settings.http_timeout_seconds, follow_redirects=True) as client: delivery = _delivery_provider(settings, client) llm_provider = _llm_provider(settings, client) resolver = CachedLocationResolver( @@ -102,10 +100,7 @@ async def run(kind: str, enforce_window: bool, at: str | None = None) -> None: ), settings.geocoding_cache_path, ) - resolutions = [ - await resolver.resolve_with_metadata(location) - for location in settings.locations - ] + resolutions = [await resolver.resolve_with_metadata(location) for location in settings.locations] for resolution in resolutions: location = resolution.location if location.precision_reduced and not resolution.from_cache: @@ -115,9 +110,7 @@ async def run(kind: str, enforce_window: bool, at: str | None = None) -> None: ) locations = tuple(resolution.location for resolution in resolutions) for location in locations: - with SQLiteStateStore( - _location_state_path(settings.state_path, location, len(locations)) - ) as state: + with SQLiteStateStore(_location_state_path(settings.state_path, location, len(locations))) as state: service = BriefingService( settings, location, @@ -196,9 +189,7 @@ def _weather_context_provider( if not providers: raise ValueError("No configured weather provider is available") weather_provider: WeatherContextProvider = ( - providers[0] - if len(providers) == 1 - else FallbackWeatherContextProvider(*providers) + providers[0] if len(providers) == 1 else FallbackWeatherContextProvider(*providers) ) return AirQualitySupplementingWeatherProvider( weather_provider, @@ -217,9 +208,7 @@ def _qweather_is_configured(settings: Settings) -> bool: ) -def _location_state_path( - base_path: Path, location: ResolvedLocation, location_count: int -) -> Path: +def _location_state_path(base_path: Path, location: ResolvedLocation, location_count: int) -> Path: if location_count == 1: return base_path suffix = base_path.suffix or ".sqlite3" @@ -235,26 +224,20 @@ def _precision_reduction_notice(location: ResolvedLocation, locations_path: Path ) -def _build_weather_provider( - name: str, settings: Settings, client: httpx.AsyncClient -) -> WeatherContextProvider: +def _build_weather_provider(name: str, settings: Settings, client: httpx.AsyncClient) -> WeatherContextProvider: builder = WEATHER_PROVIDER_BUILDERS.get(name) if builder is None: raise ValueError(f"Unsupported weather provider: {name}") return builder(settings, client) -def _build_qweather( - settings: Settings, client: httpx.AsyncClient -) -> WeatherContextProvider: +def _build_qweather(settings: Settings, client: httpx.AsyncClient) -> WeatherContextProvider: project_id = settings.qweather_project_id credential_id = settings.qweather_credential_id private_key = settings.qweather_private_key base_url = settings.qweather_base_url if not project_id or not credential_id or not private_key or not base_url: - raise ValueError( - "QWeather provider requires project, credential, private key, and API host settings" - ) + raise ValueError("QWeather provider requires project, credential, private key, and API host settings") return QWeatherProvider( client, authenticator=QWeatherJWTAuthenticator( @@ -268,9 +251,7 @@ def _build_qweather( ) -def _build_open_meteo( - settings: Settings, client: httpx.AsyncClient -) -> WeatherContextProvider: +def _build_open_meteo(settings: Settings, client: httpx.AsyncClient) -> WeatherContextProvider: return OpenMeteoProvider( client, weather_base_url=settings.open_meteo_weather_base_url, @@ -279,9 +260,7 @@ def _build_open_meteo( ) -def _aqicn_provider( - settings: Settings, client: httpx.AsyncClient -) -> AirQualityProvider | None: +def _aqicn_provider(settings: Settings, client: httpx.AsyncClient) -> AirQualityProvider | None: if not settings.aqicn_api_token: return None return AQICNProvider( @@ -291,17 +270,13 @@ def _aqicn_provider( ) -WEATHER_PROVIDER_BUILDERS: dict[ - str, Callable[[Settings, httpx.AsyncClient], WeatherContextProvider] -] = { +WEATHER_PROVIDER_BUILDERS: dict[str, Callable[[Settings, httpx.AsyncClient], WeatherContextProvider]] = { "qweather": _build_qweather, "open-meteo": _build_open_meteo, } -def _parse_run_time( - value: str | None, timezone: pendulum.Timezone -) -> pendulum.DateTime: +def _parse_run_time(value: str | None, timezone: pendulum.Timezone) -> pendulum.DateTime: if value is None: return pendulum.now(timezone) return parse_aware_datetime(value, context="Run time").in_timezone(timezone) diff --git a/weather_briefing/config.py b/weather_briefing/config.py index c3946849..27347396 100644 --- a/weather_briefing/config.py +++ b/weather_briefing/config.py @@ -277,9 +277,7 @@ def from_env(cls) -> Settings: item.strip() for item in os.getenv( "QWEATHER_INDEX_TYPES", - ",".join( - reference_string_tuple("provider_defaults.json", "qweather_lifestyle_index_types") - ), + ",".join(reference_string_tuple("provider_defaults.json", "qweather_lifestyle_index_types")), ).split(",") if item.strip() ), diff --git a/weather_briefing/content_cleaners.py b/weather_briefing/content_cleaners.py index 3080a18f..e989e549 100644 --- a/weather_briefing/content_cleaners.py +++ b/weather_briefing/content_cleaners.py @@ -38,16 +38,11 @@ def clean(self, content: str, rules: ContentCleaningRules) -> str: except SelectorSyntaxError as exc: raise ContentCleaningError("Invalid content removal selector") from exc try: - patterns = tuple( - re.compile(pattern) - for pattern in (*default_patterns, *rules.remove_patterns) - ) + patterns = tuple(re.compile(pattern) for pattern in (*default_patterns, *rules.remove_patterns)) except re.error as exc: raise ContentCleaningError("Invalid content removal pattern") from exc lines = (line.strip() for line in soup.get_text("\n").splitlines()) - return "\n".join( - line for line in lines if line and not any(pattern.search(line) for pattern in patterns) - ) + return "\n".join(line for line in lines if line and not any(pattern.search(line) for pattern in patterns)) @cache diff --git a/weather_briefing/geocoding.py b/weather_briefing/geocoding.py index 2d41eb9d..6a08501e 100644 --- a/weather_briefing/geocoding.py +++ b/weather_briefing/geocoding.py @@ -28,9 +28,7 @@ def _mainland_china_rules() -> tuple[float, float, float, float, frozenset[str]] try: latitude = reference_value("geography.json", "mainland_china_service_bounds", "latitude") longitude = reference_value("geography.json", "mainland_china_service_bounds", "longitude") - excluded_areas = reference_string_tuple( - "geography.json", "mainland_china_excluded_administrative_areas" - ) + excluded_areas = reference_string_tuple("geography.json", "mainland_china_excluded_administrative_areas") rules = ( float(latitude["minimum"]), float(latitude["maximum"]), @@ -50,10 +48,7 @@ def _mainland_china_rules() -> tuple[float, float, float, float, frozenset[str]] def possibly_mainland_china(latitude: float, longitude: float) -> bool: latitude_min, latitude_max, longitude_min, longitude_max, _ = _mainland_china_rules() - return ( - latitude_min <= latitude <= latitude_max - and longitude_min <= longitude <= longitude_max - ) + return latitude_min <= latitude <= latitude_max and longitude_min <= longitude <= longitude_max class OpenMeteoGeocodingProvider: @@ -88,8 +83,7 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: ( item for item in results - if isinstance(item, dict) - and _open_meteo_result_matches(location.name, item) + if isinstance(item, dict) and _open_meteo_result_matches(location.name, item) ), None, ) @@ -103,9 +97,7 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: except GeocodingError: raise except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc: - raise GeocodingError( - f"Geocoding request or response validation failed for: {location.name}" - ) from exc + raise GeocodingError(f"Geocoding request or response validation failed for: {location.name}") from exc return ResolvedLocation( id=location.id, name=location.name, @@ -162,17 +154,14 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: ( item for item in results - if isinstance(item, dict) - and _nominatim_result_matches(location.name, item) + if isinstance(item, dict) and _nominatim_result_matches(location.name, item) ), None, ) if result is not None: break except httpx.HTTPError as exc: - raise GeocodingError( - f"Nominatim request failed for: {location.name}" - ) from exc + raise GeocodingError(f"Nominatim request failed for: {location.name}") from exc if result is None: raise GeocodingError(f"No Nominatim result for location: {location.name}") try: @@ -180,13 +169,9 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: latitude = float(result["lat"]) longitude = float(result["lon"]) country_code = str(address.get("country_code", "")).upper() or None - administrative_area = str( - address.get("state") or address.get("province") or "" - ).strip() or None + administrative_area = str(address.get("state") or address.get("province") or "").strip() or None except (KeyError, TypeError, ValueError, AttributeError) as exc: - raise GeocodingError( - f"Nominatim response validation failed for: {location.name}" - ) from exc + raise GeocodingError(f"Nominatim response validation failed for: {location.name}") from exc return ResolvedLocation( id=location.id, name=location.name, @@ -213,9 +198,7 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: return await provider.geocode(location) except GeocodingError as exc: errors.append(exc) - raise GeocodingError( - f"No geocoder could resolve location: {location.name}" - ) from errors[-1] + raise GeocodingError(f"No geocoder could resolve location: {location.name}") from errors[-1] class PrecisionReducingGeocodingProvider: @@ -239,9 +222,7 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: name=location.name, precision_reduced=True, ) - raise GeocodingError( - f"No geocoder could resolve location at a safe precision: {location.name}" - ) from last_error + raise GeocodingError(f"No geocoder could resolve location at a safe precision: {location.name}") from last_error class CachedLocationResolver: @@ -263,9 +244,7 @@ async def resolve_with_metadata(self, location: LocationSpec) -> LocationResolut country_code=None, administrative_area=None, timezone=None, - is_mainland_china=possibly_mainland_china( - location.latitude, location.longitude - ), + is_mainland_china=possibly_mainland_china(location.latitude, location.longitude), ), from_cache=False, ) @@ -276,13 +255,9 @@ async def resolve_with_metadata(self, location: LocationSpec) -> LocationResolut try: return LocationResolution(ResolvedLocation(**cached), from_cache=True) except TypeError as exc: - raise GeocodingError( - f"Invalid cached geocoding record for location: {location.name}" - ) from exc + raise GeocodingError(f"Invalid cached geocoding record for location: {location.name}") from exc if cached is not None: - raise GeocodingError( - f"Invalid cached geocoding record for location: {location.name}" - ) + raise GeocodingError(f"Invalid cached geocoding record for location: {location.name}") resolved = await self._provider.geocode(location) cache[cache_key] = asdict(resolved) self._write_cache(cache) @@ -306,9 +281,7 @@ def _write_cache(self, cache: dict[str, Any]) -> None: temporary.replace(self._cache_path) -def _is_geocoded_mainland( - country_code: str | None, administrative_area: str | None -) -> bool: +def _is_geocoded_mainland(country_code: str | None, administrative_area: str | None) -> bool: if country_code != "CN": return False normalized = (administrative_area or "").casefold() @@ -328,8 +301,7 @@ def _nominatim_result_matches(name: str, result: dict[str, Any]) -> bool: def _open_meteo_result_matches(name: str, result: dict[str, Any]) -> bool: result_description = " ".join( - str(result.get(field, "")) - for field in ("name", "admin1", "admin2", "admin3", "admin4", "country") + str(result.get(field, "")) for field in ("name", "admin1", "admin2", "admin3", "admin4", "country") ).casefold() return _specific_location_name(name).casefold() in result_description diff --git a/weather_briefing/llm.py b/weather_briefing/llm.py index f8fad3df..a2d6fd2e 100644 --- a/weather_briefing/llm.py +++ b/weather_briefing/llm.py @@ -90,6 +90,7 @@ def parse_result( valid_source_ids: set[str], ) -> BriefingResult: require_aware_datetime(now, context="Briefing result time") + def conclusions(key: str) -> tuple[Conclusion, ...]: values = payload.get(key, []) if not isinstance(values, list): diff --git a/weather_briefing/publishers.py b/weather_briefing/publishers.py index 6733d225..40ffdb8a 100644 --- a/weather_briefing/publishers.py +++ b/weather_briefing/publishers.py @@ -10,9 +10,7 @@ class Publisher(Protocol): - async def publish( - self, message: RenderedMessage, *, single_message: bool = False - ) -> None: ... + async def publish(self, message: RenderedMessage, *, single_message: bool = False) -> None: ... @dataclass(frozen=True, slots=True) @@ -52,9 +50,7 @@ async def publish_alert(self, title: str, body: str) -> None: class StdoutPublisher: - async def publish( - self, message: RenderedMessage, *, single_message: bool = False - ) -> None: + async def publish(self, message: RenderedMessage, *, single_message: bool = False) -> None: print(message.body) @@ -70,16 +66,10 @@ def __init__(self, client: httpx.AsyncClient, token: str, chat_id: str) -> None: self._url = f"https://api.telegram.org/bot{token}/sendMessage" self._chat_id = chat_id - async def publish( - self, message: RenderedMessage, *, single_message: bool = False - ) -> None: + async def publish(self, message: RenderedMessage, *, single_message: bool = False) -> None: if single_message and message.visible_length > self.MAX_MESSAGE_LENGTH: raise DeliveryError("Telegram single message exceeds the platform limit") - chunks = ( - (message.body,) - if single_message - else _split_message(message.body, self.MAX_MESSAGE_LENGTH) - ) + chunks = (message.body,) if single_message else _split_message(message.body, self.MAX_MESSAGE_LENGTH) for chunk in chunks: try: response = await self._client.post( diff --git a/weather_briefing/reference_data.py b/weather_briefing/reference_data.py index 74f6605d..5e491e1b 100644 --- a/weather_briefing/reference_data.py +++ b/weather_briefing/reference_data.py @@ -40,11 +40,7 @@ def reference_value(filename: str, *path: str) -> Any: def reference_string_tuple(filename: str, *path: str) -> tuple[str, ...]: value = reference_value(filename, *path) - if ( - not isinstance(value, list) - or not value - or not all(isinstance(item, str) and item.strip() for item in value) - ): + if not isinstance(value, list) or not value or not all(isinstance(item, str) and item.strip() for item in value): joined_path = ".".join(path) raise ReferenceDataError(f"Reference data field must be a non-empty string list: {filename}:{joined_path}") return tuple(value) diff --git a/weather_briefing/render.py b/weather_briefing/render.py index a34640ef..c90bbd0e 100644 --- a/weather_briefing/render.py +++ b/weather_briefing/render.py @@ -40,9 +40,7 @@ def render_briefing( if result.active_warnings: lines.extend(["当前生效的气象预警", ""]) for warning in result.active_warnings: - links = " ".join( - _html_link(source_urls[source_id]) for source_id in warning.source_ids - ) + links = " ".join(_html_link(source_urls[source_id]) for source_id in warning.source_ids) lines.append( f"• {_html_text(warning.title)}({_html_text(warning.status)}):" f"{_html_text(warning.detail)} {links}".rstrip() @@ -82,9 +80,7 @@ def render_briefing( lines.extend(["当前生效的气象预警", ""]) for warning in result.active_warnings: sources = " ".join(source_urls[item] for item in warning.source_ids) - lines.append( - f"- {warning.title}({warning.status}):{warning.detail} {sources}".rstrip() - ) + lines.append(f"- {warning.title}({warning.status}):{warning.detail} {sources}".rstrip()) lines.append("") lines.extend(_plain_items("天气信息", result.conclusions, source_urls)) lines.extend(_plain_items("灾害动态", result.disaster_tracking, source_urls)) @@ -106,9 +102,7 @@ def _html_link(url: str) -> str: return f'来源' -def _html_items( - title: str, items: tuple[Conclusion, ...], source_urls: dict[str, str] -) -> list[str]: +def _html_items(title: str, items: tuple[Conclusion, ...], source_urls: dict[str, str]) -> list[str]: if not items: return [] lines = [f"{_html_text(title)}", ""] @@ -119,9 +113,7 @@ def _html_items( return lines -def _plain_items( - title: str, items: tuple[Conclusion, ...], source_urls: dict[str, str] -) -> list[str]: +def _plain_items(title: str, items: tuple[Conclusion, ...], source_urls: dict[str, str]) -> list[str]: if not items: return [] lines = [title, ""] diff --git a/weather_briefing/service.py b/weather_briefing/service.py index 19bcd9ae..b74cc6a0 100644 --- a/weather_briefing/service.py +++ b/weather_briefing/service.py @@ -40,9 +40,7 @@ def __init__( self._weather_context_provider = weather_context_provider async def run(self, kind: str, now: pendulum.DateTime | None = None) -> str | None: - current_time = require_aware_datetime( - now or pendulum.now(self._settings.timezone), context="Briefing run time" - ) + current_time = require_aware_datetime(now or pendulum.now(self._settings.timezone), context="Briefing run time") try: body = await self._run(kind, current_time) except Exception: @@ -122,9 +120,7 @@ async def _run(self, kind: str, now: pendulum.DateTime) -> str | None: historical_context, active_warnings, ) - briefing_limit = self._delivery.briefing_limit( - self._settings.briefing_max_characters - ) + briefing_limit = self._delivery.briefing_limit(self._settings.briefing_max_characters) payload["output_constraints"] = {"briefing_max_characters": briefing_limit} valid_source_ids = {article.id for article in source_articles} | {document.id for document in reference_context} @@ -136,8 +132,7 @@ def validate_length(candidate: BriefingResult) -> None: raise LLMError("daily briefing must set should_publish=true") if candidate_message.visible_length > briefing_limit: raise LLMError( - f"briefing has {candidate_message.visible_length} visible characters; " - f"limit is {briefing_limit}" + f"briefing has {candidate_message.visible_length} visible characters; limit is {briefing_limit}" ) result = await self._summarize(payload, now, valid_source_ids, validator=validate_length) diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index 19afbd1c..adfb4bd9 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -46,13 +46,9 @@ def __init__( self._project_id = project_id self._credential_id = credential_id try: - self._private_key = base64.b64decode( - private_key_base64, validate=True - ).decode("utf-8") + self._private_key = base64.b64decode(private_key_base64, validate=True).decode("utf-8") except (binascii.Error, UnicodeDecodeError): - raise ValueError( - "QWeather private key must be a Base64-encoded UTF-8 PEM" - ) from None + raise ValueError("QWeather private key must be a Base64-encoded UTF-8 PEM") from None self._lifetime_seconds = lifetime_seconds self._clock = clock @@ -103,9 +99,7 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh weather_payload = weather_response.json() if weather_payload.get("code") != "200": raise WeatherContextError("QWeather returned a non-success weather status") - weather_forecast = tuple( - _format_qweather_day(item) for item in weather_payload.get("daily", ())[:2] - ) + weather_forecast = tuple(_format_qweather_day(item) for item in weather_payload.get("daily", ())[:2]) if not weather_forecast: raise WeatherContextError("QWeather returned no daily forecast") @@ -122,14 +116,9 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh indices_payload = indices_response.json() if indices_payload.get("code") != "200": raise WeatherContextError("QWeather returned a non-success indices status") - lifestyle_advice = tuple( - _format_qweather_lifestyle(item) - for item in indices_payload.get("daily", ()) - ) + lifestyle_advice = tuple(_format_qweather_lifestyle(item) for item in indices_payload.get("daily", ())) source_url = str( - weather_payload.get("fxLink") - or indices_payload.get("fxLink") - or "https://www.qweather.com/" + weather_payload.get("fxLink") or indices_payload.get("fxLink") or "https://www.qweather.com/" ) observed_at = parse_datetime_with_default_timezone( str(weather_payload.get("updateTime") or indices_payload["updateTime"]), @@ -181,9 +170,7 @@ async def _fetch_air_quality( pm25_concentration=float(concentration["value"]), pm25_unit=str(concentration["unit"]), category=str(index.get("category", "未知")), - health_guidance=str( - advice.get("generalPopulation") or health.get("effect", "") - ), + health_guidance=str(advice.get("generalPopulation") or health.get("effect", "")), ) except (httpx.HTTPError, KeyError, TypeError, ValueError): return None @@ -229,16 +216,12 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh if self._api_key: params["apikey"] = self._api_key try: - response = await self._client.get( - f"{self._weather_base_url}/v1/forecast", params=params - ) + response = await self._client.get(f"{self._weather_base_url}/v1/forecast", params=params) response.raise_for_status() payload = response.json() daily = cast(dict[str, list[object]], payload["daily"]) times = daily["time"] - weather_forecast = tuple( - _format_open_meteo_day(daily, index) for index in range(min(2, len(times))) - ) + weather_forecast = tuple(_format_open_meteo_day(daily, index) for index in range(min(2, len(times)))) if not weather_forecast: raise WeatherContextError("Open-Meteo returned no daily forecast") observed_at = parse_datetime_with_default_timezone( @@ -261,9 +244,7 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh air_quality=air_quality, ) - async def _fetch_air_quality( - self, latitude: float, longitude: float - ) -> AirQualitySnapshot | None: + async def _fetch_air_quality(self, latitude: float, longitude: float) -> AirQualitySnapshot | None: params: dict[str, str | int | float] = { "latitude": latitude, "longitude": longitude, @@ -273,9 +254,7 @@ async def _fetch_air_quality( if self._api_key: params["apikey"] = self._api_key try: - response = await self._client.get( - f"{self._air_quality_base_url}/v1/air-quality", params=params - ) + response = await self._client.get(f"{self._air_quality_base_url}/v1/air-quality", params=params) response.raise_for_status() payload = response.json() current = cast(dict[str, Any], payload["current"]) @@ -332,9 +311,7 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh if snapshot.air_quality is not None: return snapshot if self._air_quality_provider is None: - raise WeatherContextError( - "Weather source did not provide air quality; configure AQICN_API_TOKEN" - ) + raise WeatherContextError("Weather source did not provide air quality; configure AQICN_API_TOKEN") try: air_quality = await self._air_quality_provider.fetch( latitude, @@ -345,9 +322,7 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh ), ) except AirQualityError: - raise WeatherContextError( - "Weather source did not provide air quality and AQICN fallback failed" - ) from None + raise WeatherContextError("Weather source did not provide air quality and AQICN fallback failed") from None return replace(snapshot, air_quality=air_quality)