diff --git a/docs/design.md b/docs/design.md index 12ed9516..7d4ec2ee 100644 --- a/docs/design.md +++ b/docs/design.md @@ -24,6 +24,20 @@ - `DeliveryProvider`:渲染并发送结果; - `SQLiteStateStore`:保存文章、简报、预警和运行状态。 +包职责如下: + +- `config` 在环境变量和私密文件边界完成解析与校验; +- `geocoding` 包含定位协议、候选匹配、外部服务适配器和缓存解析器; +- `weather` 包含平台无关协议、各天气服务适配器、能力组合和来源文档转换; +- `llm` 只包含模型协议、结构化 schema、any-llm 兼容适配器和结果解析,不依赖天气领域; +- `delivery` 分离平台无关投递协议、渲染器和具体平台适配器; +- `application` 保存历史上下文预算、模型输入构造和输出契约修复等应用策略; +- `composition` 负责根据配置组装外部服务,`cli` 只负责命令分派、运行生命周期和调度; +- `persistence` 把 schema、固定格式序列化和运行时诊断与事务存储分开,业务结果仍由单个 `SQLiteStateStore` 原子提交; +- `data` 保存随程序发布的提示词、端点、分类和本地化资源,读取与领域校验由使用这些资源的功能模块负责。 + +包的 `__init__` 只导出有意支持的功能接口,测试直接引用行为的所有者模块。 + ## 配置入口 运行配置来自环境变量和两个私密 JSON 文件: diff --git a/tests/test_cli.py b/tests/test_cli.py index 56e3bb9e..10b673a5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,16 +18,8 @@ from weather_briefing.cli import ( _LOGGER, _SENSITIVE_SDK_LOGGERS, - PUBLISHER_BUILDERS, - WEATHER_PROVIDER_BUILDERS, - _aqicn_provider, _briefing_delivery_policy, _briefing_sent_today, - _build_jma, - _build_nea, - _build_open_meteo, - _build_qweather, - _build_weather_provider, _configure_logging, _delivery_provider, _hour_in_cron, @@ -38,20 +30,30 @@ _parse_forecast_date, _parse_run_time, _precision_reduction_notice, - _qweather_is_configured, _save_resolved_location_fields, _weather_context_provider, - _weather_provider_metadata, build_parser, daemon, main, run, ) +from weather_briefing.composition.providers import ( + PUBLISHER_BUILDERS, + WEATHER_PROVIDER_BUILDERS, + _build_jma, + _build_nea, + _build_open_meteo, + _build_qweather, +) +from weather_briefing.composition.providers import aqicn_provider as _aqicn_provider +from weather_briefing.composition.providers import build_weather_provider as _build_weather_provider +from weather_briefing.composition.providers import qweather_is_configured as _qweather_is_configured +from weather_briefing.composition.providers import weather_provider_metadata as _weather_provider_metadata from weather_briefing.config import ConfigurationError, Settings from weather_briefing.models import LocationSpec, ResolvedLocation from weather_briefing.registries import PublisherName, WeatherProviderName from weather_briefing.state import SQLiteRuntimeDiagnostics, SQLiteStateStore -from weather_briefing.weather_context import QWeatherProvider +from weather_briefing.weather import QWeatherProvider _REQUIRED_SENSITIVE_SDK_LOGGERS = frozenset({"any_llm", "openai", "httpx", "httpcore"}) @@ -1101,7 +1103,7 @@ async def test_deepseek_with_custom_base_url( calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] sdk_client = SimpleNamespace() monkeypatch.setattr( - "weather_briefing.cli.create_any_llm_provider", + "weather_briefing.llm.any_llm.create_any_llm_provider", lambda *args, **kwargs: calls.append((args, kwargs)) or sdk_client, ) settings = _make_fake_settings( @@ -1125,7 +1127,7 @@ async def test_deepseek_with_custom_base_url( async def test_deepseek_without_base_url(self, monkeypatch) -> None: calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] monkeypatch.setattr( - "weather_briefing.cli.create_any_llm_provider", + "weather_briefing.llm.any_llm.create_any_llm_provider", lambda *args, **kwargs: calls.append((args, kwargs)) or SimpleNamespace(), ) settings = _make_fake_settings(llm_provider="deepseek", llm_base_url=None) @@ -1137,7 +1139,7 @@ async def test_deepseek_without_base_url(self, monkeypatch) -> None: async def test_arbitrary_any_llm_provider_is_forwarded(self, monkeypatch) -> None: calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] monkeypatch.setattr( - "weather_briefing.cli.create_any_llm_provider", + "weather_briefing.llm.any_llm.create_any_llm_provider", lambda *args, **kwargs: calls.append((args, kwargs)) or SimpleNamespace(), ) settings = replace(_make_fake_settings(llm_provider="mistral"), api_key=None, llm_base_url=None) @@ -1325,7 +1327,7 @@ def test_weather_provider_metadata_rejects_unregistered_provider() -> None: async def test_no_weather_provider_available(monkeypatch, async_client: httpx.AsyncClient) -> None: monkeypatch.setattr( - "weather_briefing.cli.weather_providers_for", + "weather_briefing.config.environment.weather_providers_for", lambda *_: ("qweather",), ) settings = _make_fake_settings( diff --git a/tests/test_geocoding.py b/tests/test_geocoding.py index 4d3ec794..3ccb764b 100644 --- a/tests/test_geocoding.py +++ b/tests/test_geocoding.py @@ -7,6 +7,7 @@ import httpx import pytest +from weather_briefing.data.resources import ReferenceDataError, reference_value from weather_briefing.geocoding import ( CachedLocationResolver, FallbackGeocodingProvider, @@ -26,7 +27,6 @@ specific_location_name as _specific_location_name, ) from weather_briefing.models import LocationSpec, ResolvedLocation -from weather_briefing.reference_data import ReferenceDataError, reference_value class _NeverCalledGeocoder: diff --git a/weather_briefing/cli.py b/weather_briefing/cli.py index 1c7f1d85..49b36149 100644 --- a/weather_briefing/cli.py +++ b/weather_briefing/cli.py @@ -9,26 +9,24 @@ import sqlite3 import subprocess import sys -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Iterator, Sequence from contextlib import AsyncExitStack, contextmanager from datetime import UTC, date, datetime from pathlib import Path from typing import Any -import httpx import pendulum from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from dotenv import load_dotenv from . import __version__ -from .air_quality import ( - AirQualityProvider, - AQICNProvider, -) from .api_client import LoggedAsyncClient -from .capabilities import CapabilityName, CapabilityProviderSet, ProviderCapabilities -from .config import ConfigurationError, Settings, backfill_location_fields, state_path_from_env, weather_providers_for +from .composition.providers import delivery_provider as _delivery_provider +from .composition.providers import llm_provider as _llm_provider +from .composition.providers import weather_context_provider as _weather_context_provider +from .config import ConfigurationError, Settings, backfill_location_fields, state_path_from_env +from .delivery import RenderedTextDiagnostics from .geocoding import ( CachedLocationResolver, FallbackGeocodingProvider, @@ -36,31 +34,11 @@ OpenMeteoGeocodingProvider, PrecisionReducingGeocodingProvider, ) -from .llm import AnyLLMStructuredProvider, create_any_llm_provider from .models import ResolvedLocation -from .publishers import DeliveryProvider, RenderedTextDiagnostics, StdoutPublisher, TelegramPublisher -from .regional_weather import ( - JMA_LANGUAGE_SUPPORT, - NEA_LANGUAGE_SUPPORT, - JMAJapanForecastProvider, - NEASingaporeNowcastProvider, -) -from .registries import LOCAL_WEATHER_CAPABILITY_PROVIDERS, PublisherName, WeatherProviderName -from .render import PlainTextRenderer, TelegramHTMLRenderer from .service import BriefingService from .sources import RSSSource from .state import SQLiteRuntimeDiagnostics, SQLiteStateStore from .time_utils import parse_aware_datetime -from .weather_context import ( - OPEN_METEO_LANGUAGE_SUPPORT, - QWEATHER_LANGUAGE_SUPPORT, - FallbackWeatherContextProvider, - LoggedWeatherContextProvider, - OpenMeteoProvider, - QWeatherJWTAuthenticator, - QWeatherProvider, - WeatherContextProvider, -) def build_parser() -> argparse.ArgumentParser: @@ -367,228 +345,6 @@ async def run( _LOGGER.info("Location %s %s skipped (no content)", location.id, kind) -def _llm_provider( - settings: Settings, - diagnostics: RenderedTextDiagnostics | None = None, -) -> AnyLLMStructuredProvider: - return create_any_llm_provider( - settings.llm_provider, - settings.llm_model, - settings.llm_max_output_tokens, - api_key=settings.api_key, - api_base=settings.llm_base_url, - diagnostics=diagnostics, - ) - - -def _delivery_provider( - settings: Settings, - client: httpx.AsyncClient, - diagnostics: RenderedTextDiagnostics | None = None, -) -> DeliveryProvider: - builder = PUBLISHER_BUILDERS.get(settings.publisher) - if builder is None: - raise ValueError(f"Unsupported publisher: {settings.publisher}") - return builder(settings, client, diagnostics) - - -def _build_stdout_publisher( - settings: Settings, - client: httpx.AsyncClient, - diagnostics: RenderedTextDiagnostics | None, -) -> DeliveryProvider: - return DeliveryProvider(PlainTextRenderer(), StdoutPublisher(), diagnostics=diagnostics) - - -def _build_telegram_publisher( - settings: Settings, - client: httpx.AsyncClient, - diagnostics: RenderedTextDiagnostics | None, -) -> DeliveryProvider: - if not settings.telegram_bot_token or not settings.telegram_chat_id: - raise ValueError("Telegram publisher requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID") - return DeliveryProvider( - TelegramHTMLRenderer(), - TelegramPublisher( - client, - settings.telegram_bot_token, - settings.telegram_chat_id, - diagnostics, - ), - single_message_limit=TelegramPublisher.MAX_MESSAGE_LENGTH, - diagnostics=diagnostics, - ) - - -PUBLISHER_BUILDERS: dict[ - str, - Callable[[Settings, httpx.AsyncClient, RenderedTextDiagnostics | None], DeliveryProvider], -] = { - PublisherName.STDOUT: _build_stdout_publisher, - PublisherName.TELEGRAM: _build_telegram_publisher, -} - -_WEATHER_PROVIDER_METADATA: dict[str, ProviderCapabilities] = { - WeatherProviderName.QWEATHER: ProviderCapabilities( - provider_id=WeatherProviderName.QWEATHER, - provider_name="QWeather", - capabilities=frozenset( - { - CapabilityName.WEATHER, - CapabilityName.AIR_QUALITY, - CapabilityName.LIFESTYLE, - } - ), - language_support=QWEATHER_LANGUAGE_SUPPORT, - ), - WeatherProviderName.OPEN_METEO: ProviderCapabilities( - provider_id=WeatherProviderName.OPEN_METEO, - provider_name="Open-Meteo", - capabilities=frozenset( - { - CapabilityName.WEATHER, - CapabilityName.AIR_QUALITY, - CapabilityName.ALLERGEN, - } - ), - language_support=OPEN_METEO_LANGUAGE_SUPPORT, - ), - WeatherProviderName.NEA_SINGAPORE: ProviderCapabilities( - provider_id=WeatherProviderName.NEA_SINGAPORE, - provider_name="Singapore NEA", - capabilities=frozenset({CapabilityName.NOWCAST}), - language_support=NEA_LANGUAGE_SUPPORT, - ), - WeatherProviderName.JMA_JAPAN: ProviderCapabilities( - provider_id=WeatherProviderName.JMA_JAPAN, - provider_name="Japan JMA", - capabilities=frozenset({CapabilityName.WEATHER}), - language_support=JMA_LANGUAGE_SUPPORT, - ), -} - - -def _weather_provider_metadata(names: Sequence[str]) -> ProviderCapabilities: - """Describe capabilities common to every active fallback provider.""" - metadata: list[ProviderCapabilities] = [] - for name in names: - item = _WEATHER_PROVIDER_METADATA.get(name) - if item is None: - raise ValueError(f"Weather provider {name!r} has no capability metadata") - metadata.append(item) - if len(metadata) == 1: - return metadata[0] - capabilities = metadata[0].capabilities - for item in metadata[1:]: - capabilities &= item.capabilities - return ProviderCapabilities( - provider_id="weather-composite", - provider_name="Weather provider composite", - capabilities=capabilities, - ) - - -def _weather_context_provider( - settings: Settings, - client: httpx.AsyncClient, - location: ResolvedLocation, -) -> CapabilityProviderSet: - names = weather_providers_for(location, settings.weather_providers) - if ( - settings.weather_providers is not None - and WeatherProviderName.NEA_SINGAPORE in settings.weather_providers - and WeatherProviderName.NEA_SINGAPORE not in names - ): - reason = "missing-country-code" if location.country_code is None else "known-non-singapore-country" - _LOGGER.warning("Skipping explicit NEA provider reason=%s", reason) - jma_available = location.jma_office_code is not None and location.country_code in {None, "JP"} - if settings.weather_providers is not None and WeatherProviderName.JMA_JAPAN in names and not jma_available: - reason = "missing-jma-office-code" if location.jma_office_code is None else "known-non-japan-country" - _LOGGER.warning("Skipping explicit JMA provider reason=%s", reason) - main_names = [name for name in names if name not in LOCAL_WEATHER_CAPABILITY_PROVIDERS] - supplement_names = [ - name - for name in names - if name in LOCAL_WEATHER_CAPABILITY_PROVIDERS and (name != WeatherProviderName.JMA_JAPAN or jma_available) - ] - if not main_names: - main_names, supplement_names = supplement_names, [] - providers: list[WeatherContextProvider] = [] - active_names: list[str] = [] - for name in main_names: - if name == WeatherProviderName.QWEATHER and not _qweather_is_configured(settings): - if settings.weather_providers is not None: - raise ValueError("Explicit QWeather provider is missing JWT configuration") - continue - providers.append( - LoggedWeatherContextProvider( - name, - _build_weather_provider( - name, - settings, - client, - location.summary_language, - jma_office_code=location.jma_office_code, - ), - ) - ) - active_names.append(name) - if not providers: - raise ValueError("No configured weather provider is available") - supplements = tuple( - LoggedWeatherContextProvider( - name, - _build_weather_provider( - name, - settings, - client, - location.summary_language, - jma_office_code=location.jma_office_code, - ), - ) - for name in supplement_names - ) - _LOGGER.info( - "Weather provider order providers=%s", - ",".join( - name - for name in (*main_names, *supplement_names) - if name != WeatherProviderName.QWEATHER or _qweather_is_configured(settings) - ), - ) - weather_provider: WeatherContextProvider = ( - providers[0] if len(providers) == 1 else FallbackWeatherContextProvider(*providers) - ) - air_quality_provider = _aqicn_provider(settings, client) - return CapabilityProviderSet( - weather=weather_provider, - weather_metadata=_weather_provider_metadata(active_names), - air_quality=air_quality_provider, - air_quality_metadata=( - ProviderCapabilities( - provider_id="air-quality:aqicn", - provider_name="AQICN", - capabilities=frozenset({CapabilityName.AIR_QUALITY}), - ) - if air_quality_provider is not None - else None - ), - supplements=supplements, - supplement_metadata=tuple(_WEATHER_PROVIDER_METADATA[name] for name in supplement_names), - ) - - -def _qweather_is_configured(settings: Settings) -> bool: - return all( - ( - settings.qweather_project_id, - settings.qweather_credential_id, - settings.qweather_private_key, - settings.qweather_base_url, - ) - ) - - def _location_state_path(base_path: Path, location: ResolvedLocation, location_count: int) -> Path: if location_count == 1: return base_path @@ -606,96 +362,6 @@ def _precision_reduction_notice(location: ResolvedLocation, locations_path: Path ) -def _build_weather_provider( - name: str, - settings: Settings, - client: httpx.AsyncClient, - output_language: str = "en", - *, - jma_office_code: str | None = None, -) -> WeatherContextProvider: - builder = WEATHER_PROVIDER_BUILDERS.get(name) - if builder is None: - raise ValueError(f"Unsupported weather provider: {name}") - if name == WeatherProviderName.QWEATHER: - return _build_qweather(settings, client, output_language=output_language) - if name == WeatherProviderName.NEA_SINGAPORE: - return NEASingaporeNowcastProvider( - client, - api_key=settings.nea_api_key, - ) - if name == WeatherProviderName.JMA_JAPAN: - return _build_jma(settings, client, office_code=jma_office_code) - return builder(settings, client) - - -def _build_qweather( - settings: Settings, - client: httpx.AsyncClient, - *, - output_language: str = "en", -) -> 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") - return QWeatherProvider( - client, - authenticator=QWeatherJWTAuthenticator( - project_id=project_id, - credential_id=credential_id, - private_key_base64=private_key, - lifetime_seconds=settings.qweather_jwt_lifetime_seconds, - ), - base_url=base_url, - output_language=output_language, - ) - - -def _build_nea(settings: Settings, client: httpx.AsyncClient) -> WeatherContextProvider: - return NEASingaporeNowcastProvider(client, api_key=settings.nea_api_key) - - -def _build_jma( - settings: Settings, - client: httpx.AsyncClient, - *, - office_code: str | None = None, -) -> WeatherContextProvider: - if office_code is None: - raise ValueError("JMA provider requires locations.json jma_office_code") - return JMAJapanForecastProvider( - client, - office_code=office_code, - ) - - -def _build_open_meteo(settings: Settings, client: httpx.AsyncClient) -> WeatherContextProvider: - return OpenMeteoProvider( - client, - api_key=settings.open_meteo_api_key, - ) - - -def _aqicn_provider(settings: Settings, client: httpx.AsyncClient) -> AirQualityProvider | None: - if not settings.aqicn_api_token: - return None - return AQICNProvider( - client, - token=settings.aqicn_api_token, - ) - - -WEATHER_PROVIDER_BUILDERS: dict[str, Callable[[Settings, httpx.AsyncClient], WeatherContextProvider]] = { - WeatherProviderName.QWEATHER: _build_qweather, - WeatherProviderName.OPEN_METEO: _build_open_meteo, - WeatherProviderName.NEA_SINGAPORE: _build_nea, - WeatherProviderName.JMA_JAPAN: _build_jma, -} - - def _parse_run_time(value: str | None, timezone: pendulum.Timezone) -> pendulum.DateTime: if value is None: return pendulum.now(timezone) diff --git a/weather_briefing/composition/__init__.py b/weather_briefing/composition/__init__.py new file mode 100644 index 00000000..878cb493 --- /dev/null +++ b/weather_briefing/composition/__init__.py @@ -0,0 +1 @@ +"""Application dependency composition.""" diff --git a/weather_briefing/composition/providers.py b/weather_briefing/composition/providers.py new file mode 100644 index 00000000..491b01cc --- /dev/null +++ b/weather_briefing/composition/providers.py @@ -0,0 +1,329 @@ +"""Runtime composition of LLM, delivery, and weather providers.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Sequence + +import httpx + +from ..air_quality import AirQualityProvider, AQICNProvider +from ..capabilities import CapabilityName, CapabilityProviderSet, ProviderCapabilities +from ..config import Settings +from ..config import environment as config_environment +from ..delivery import ( + DeliveryProvider, + PlainTextRenderer, + RenderedTextDiagnostics, + StdoutPublisher, + TelegramHTMLRenderer, + TelegramPublisher, +) +from ..llm import AnyLLMStructuredProvider, SensitiveLLMDiagnostics, any_llm +from ..models import ResolvedLocation +from ..registries import LOCAL_WEATHER_CAPABILITY_PROVIDERS, PublisherName, WeatherProviderName +from ..weather import ( + JMA_LANGUAGE_SUPPORT, + NEA_LANGUAGE_SUPPORT, + OPEN_METEO_LANGUAGE_SUPPORT, + QWEATHER_LANGUAGE_SUPPORT, + FallbackWeatherContextProvider, + JMAJapanForecastProvider, + LoggedWeatherContextProvider, + NEASingaporeNowcastProvider, + OpenMeteoProvider, + QWeatherJWTAuthenticator, + QWeatherProvider, + WeatherContextProvider, +) + +_LOGGER = logging.getLogger("weather_briefing") + + +def llm_provider( + settings: Settings, + diagnostics: SensitiveLLMDiagnostics | None = None, +) -> AnyLLMStructuredProvider: + """Build the configured any-llm adapter.""" + return any_llm.create_any_llm_provider( + settings.llm_provider, + settings.llm_model, + settings.llm_max_output_tokens, + api_key=settings.api_key, + api_base=settings.llm_base_url, + diagnostics=diagnostics, + ) + + +def delivery_provider( + settings: Settings, + client: httpx.AsyncClient, + diagnostics: RenderedTextDiagnostics | None = None, +) -> DeliveryProvider: + """Build the configured publisher and renderer pair.""" + builder = PUBLISHER_BUILDERS.get(settings.publisher) + if builder is None: + raise ValueError(f"Unsupported publisher: {settings.publisher}") + return builder(settings, client, diagnostics) + + +def _build_stdout_publisher( + settings: Settings, + client: httpx.AsyncClient, + diagnostics: RenderedTextDiagnostics | None, +) -> DeliveryProvider: + return DeliveryProvider(PlainTextRenderer(), StdoutPublisher(), diagnostics=diagnostics) + + +def _build_telegram_publisher( + settings: Settings, + client: httpx.AsyncClient, + diagnostics: RenderedTextDiagnostics | None, +) -> DeliveryProvider: + if not settings.telegram_bot_token or not settings.telegram_chat_id: + raise ValueError("Telegram publisher requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID") + return DeliveryProvider( + TelegramHTMLRenderer(), + TelegramPublisher(client, settings.telegram_bot_token, settings.telegram_chat_id, diagnostics), + single_message_limit=TelegramPublisher.MAX_MESSAGE_LENGTH, + diagnostics=diagnostics, + ) + + +PUBLISHER_BUILDERS: dict[ + str, + Callable[[Settings, httpx.AsyncClient, RenderedTextDiagnostics | None], DeliveryProvider], +] = { + PublisherName.STDOUT: _build_stdout_publisher, + PublisherName.TELEGRAM: _build_telegram_publisher, +} + +_WEATHER_PROVIDER_METADATA: dict[str, ProviderCapabilities] = { + WeatherProviderName.QWEATHER: ProviderCapabilities( + provider_id=WeatherProviderName.QWEATHER, + provider_name="QWeather", + capabilities=frozenset({CapabilityName.WEATHER, CapabilityName.AIR_QUALITY, CapabilityName.LIFESTYLE}), + language_support=QWEATHER_LANGUAGE_SUPPORT, + ), + WeatherProviderName.OPEN_METEO: ProviderCapabilities( + provider_id=WeatherProviderName.OPEN_METEO, + provider_name="Open-Meteo", + capabilities=frozenset({CapabilityName.WEATHER, CapabilityName.AIR_QUALITY, CapabilityName.ALLERGEN}), + language_support=OPEN_METEO_LANGUAGE_SUPPORT, + ), + WeatherProviderName.NEA_SINGAPORE: ProviderCapabilities( + provider_id=WeatherProviderName.NEA_SINGAPORE, + provider_name="Singapore NEA", + capabilities=frozenset({CapabilityName.NOWCAST}), + language_support=NEA_LANGUAGE_SUPPORT, + ), + WeatherProviderName.JMA_JAPAN: ProviderCapabilities( + provider_id=WeatherProviderName.JMA_JAPAN, + provider_name="Japan JMA", + capabilities=frozenset({CapabilityName.WEATHER}), + language_support=JMA_LANGUAGE_SUPPORT, + ), +} + + +def weather_provider_metadata(names: Sequence[str]) -> ProviderCapabilities: + """Describe capabilities common to every active fallback provider.""" + metadata: list[ProviderCapabilities] = [] + for name in names: + item = _WEATHER_PROVIDER_METADATA.get(name) + if item is None: + raise ValueError(f"Weather provider {name!r} has no capability metadata") + metadata.append(item) + if len(metadata) == 1: + return metadata[0] + capabilities = metadata[0].capabilities + for item in metadata[1:]: + capabilities &= item.capabilities + return ProviderCapabilities( + provider_id="weather-composite", + provider_name="Weather provider composite", + capabilities=capabilities, + ) + + +def weather_context_provider( + settings: Settings, + client: httpx.AsyncClient, + location: ResolvedLocation, +) -> CapabilityProviderSet: + """Compose complete and supplemental weather providers for one location.""" + names = config_environment.weather_providers_for(location, settings.weather_providers) + if ( + settings.weather_providers is not None + and WeatherProviderName.NEA_SINGAPORE in settings.weather_providers + and WeatherProviderName.NEA_SINGAPORE not in names + ): + reason = "missing-country-code" if location.country_code is None else "known-non-singapore-country" + _LOGGER.warning("Skipping explicit NEA provider reason=%s", reason) + jma_available = location.jma_office_code is not None and location.country_code in {None, "JP"} + if settings.weather_providers is not None and WeatherProviderName.JMA_JAPAN in names and not jma_available: + reason = "missing-jma-office-code" if location.jma_office_code is None else "known-non-japan-country" + _LOGGER.warning("Skipping explicit JMA provider reason=%s", reason) + main_names = [name for name in names if name not in LOCAL_WEATHER_CAPABILITY_PROVIDERS] + supplement_names = [ + name + for name in names + if name in LOCAL_WEATHER_CAPABILITY_PROVIDERS and (name != WeatherProviderName.JMA_JAPAN or jma_available) + ] + if not main_names: + main_names, supplement_names = supplement_names, [] + providers: list[WeatherContextProvider] = [] + active_names: list[str] = [] + for name in main_names: + if name == WeatherProviderName.QWEATHER and not qweather_is_configured(settings): + if settings.weather_providers is not None: + raise ValueError("Explicit QWeather provider is missing JWT configuration") + continue + providers.append( + LoggedWeatherContextProvider( + name, + build_weather_provider( + name, + settings, + client, + location.summary_language, + jma_office_code=location.jma_office_code, + ), + ) + ) + active_names.append(name) + if not providers: + raise ValueError("No configured weather provider is available") + supplements = tuple( + LoggedWeatherContextProvider( + name, + build_weather_provider( + name, + settings, + client, + location.summary_language, + jma_office_code=location.jma_office_code, + ), + ) + for name in supplement_names + ) + _LOGGER.info( + "Weather provider order providers=%s", + ",".join( + name + for name in (*main_names, *supplement_names) + if name != WeatherProviderName.QWEATHER or qweather_is_configured(settings) + ), + ) + weather_provider: WeatherContextProvider = ( + providers[0] if len(providers) == 1 else FallbackWeatherContextProvider(*providers) + ) + air_quality_provider = aqicn_provider(settings, client) + return CapabilityProviderSet( + weather=weather_provider, + weather_metadata=weather_provider_metadata(active_names), + air_quality=air_quality_provider, + air_quality_metadata=( + ProviderCapabilities( + provider_id="air-quality:aqicn", + provider_name="AQICN", + capabilities=frozenset({CapabilityName.AIR_QUALITY}), + ) + if air_quality_provider is not None + else None + ), + supplements=supplements, + supplement_metadata=tuple(_WEATHER_PROVIDER_METADATA[name] for name in supplement_names), + ) + + +def qweather_is_configured(settings: Settings) -> bool: + """Return whether all QWeather JWT settings are present.""" + return all( + ( + settings.qweather_project_id, + settings.qweather_credential_id, + settings.qweather_private_key, + settings.qweather_base_url, + ) + ) + + +def build_weather_provider( + name: str, + settings: Settings, + client: httpx.AsyncClient, + output_language: str = "en", + *, + jma_office_code: str | None = None, +) -> WeatherContextProvider: + """Build one configured weather provider adapter.""" + builder = WEATHER_PROVIDER_BUILDERS.get(name) + if builder is None: + raise ValueError(f"Unsupported weather provider: {name}") + if name == WeatherProviderName.QWEATHER: + return _build_qweather(settings, client, output_language=output_language) + if name == WeatherProviderName.NEA_SINGAPORE: + return NEASingaporeNowcastProvider(client, api_key=settings.nea_api_key) + if name == WeatherProviderName.JMA_JAPAN: + return _build_jma(settings, client, office_code=jma_office_code) + return builder(settings, client) + + +def _build_qweather( + settings: Settings, + client: httpx.AsyncClient, + *, + output_language: str = "en", +) -> 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") + return QWeatherProvider( + client, + authenticator=QWeatherJWTAuthenticator( + project_id=project_id, + credential_id=credential_id, + private_key_base64=private_key, + lifetime_seconds=settings.qweather_jwt_lifetime_seconds, + ), + base_url=base_url, + output_language=output_language, + ) + + +def _build_nea(settings: Settings, client: httpx.AsyncClient) -> WeatherContextProvider: + return NEASingaporeNowcastProvider(client, api_key=settings.nea_api_key) + + +def _build_jma( + settings: Settings, + client: httpx.AsyncClient, + *, + office_code: str | None = None, +) -> WeatherContextProvider: + if office_code is None: + raise ValueError("JMA provider requires locations.json jma_office_code") + return JMAJapanForecastProvider(client, office_code=office_code) + + +def _build_open_meteo(settings: Settings, client: httpx.AsyncClient) -> WeatherContextProvider: + return OpenMeteoProvider(client, api_key=settings.open_meteo_api_key) + + +def aqicn_provider(settings: Settings, client: httpx.AsyncClient) -> AirQualityProvider | None: + """Build the optional AQICN supplement.""" + if not settings.aqicn_api_token: + return None + return AQICNProvider(client, token=settings.aqicn_api_token) + + +WEATHER_PROVIDER_BUILDERS: dict[str, Callable[[Settings, httpx.AsyncClient], WeatherContextProvider]] = { + WeatherProviderName.QWEATHER: _build_qweather, + WeatherProviderName.OPEN_METEO: _build_open_meteo, + WeatherProviderName.NEA_SINGAPORE: _build_nea, + WeatherProviderName.JMA_JAPAN: _build_jma, +} diff --git a/weather_briefing/config/environment.py b/weather_briefing/config/environment.py index 054e3c4f..79d87723 100644 --- a/weather_briefing/config/environment.py +++ b/weather_briefing/config/environment.py @@ -8,8 +8,8 @@ from apscheduler.triggers.cron import CronTrigger +from ..data.resources import reference_string_tuple from ..models import ResolvedLocation -from ..reference_data import reference_string_tuple from ..registries import LOCAL_WEATHER_CAPABILITY_PROVIDERS, PublisherName, WeatherProviderName from .base import ConfigurationError diff --git a/weather_briefing/geocoding/matching.py b/weather_briefing/geocoding/matching.py index bb357530..a532fb1d 100644 --- a/weather_briefing/geocoding/matching.py +++ b/weather_briefing/geocoding/matching.py @@ -5,7 +5,7 @@ import re from functools import cache -from ..reference_data import ReferenceDataError, reference_string_tuple, reference_value +from ..data.resources import ReferenceDataError, reference_string_tuple, reference_value @cache diff --git a/weather_briefing/publishers.py b/weather_briefing/publishers.py deleted file mode 100644 index e4712a39..00000000 --- a/weather_briefing/publishers.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Compatibility exports for delivery providers.""" - -from .delivery import ( - DeliveryError, - DeliveryProvider, - RenderedTextDiagnostics, - StdoutPublisher, - TelegramPublisher, -) -from .delivery.base import Publisher - -__all__ = [ - "DeliveryError", - "DeliveryProvider", - "Publisher", - "RenderedTextDiagnostics", - "StdoutPublisher", - "TelegramPublisher", -] diff --git a/weather_briefing/reference_data.py b/weather_briefing/reference_data.py deleted file mode 100644 index ab2837dd..00000000 --- a/weather_briefing/reference_data.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Compatibility exports for packaged reference data.""" - -from .data.resources import ( - ReferenceDataError, - load_reference_data, - reference_string, - reference_string_tuple, - reference_value, -) -from .localization import localization_table -from .weather.open_meteo_reference import open_meteo_weather_code_descriptions - -__all__ = [ - "ReferenceDataError", - "load_reference_data", - "localization_table", - "open_meteo_weather_code_descriptions", - "reference_string", - "reference_string_tuple", - "reference_value", -] diff --git a/weather_briefing/regional_weather.py b/weather_briefing/regional_weather.py deleted file mode 100644 index 00c726ee..00000000 --- a/weather_briefing/regional_weather.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Compatibility exports for regional weather providers.""" - -from .weather import ( - JMA_LANGUAGE_SUPPORT, - NEA_LANGUAGE_SUPPORT, - JMAJapanForecastProvider, - NEASingaporeNowcastProvider, - RegionalWeatherProviderError, -) - -__all__ = [ - "JMA_LANGUAGE_SUPPORT", - "NEA_LANGUAGE_SUPPORT", - "JMAJapanForecastProvider", - "NEASingaporeNowcastProvider", - "RegionalWeatherProviderError", -] diff --git a/weather_briefing/render.py b/weather_briefing/render.py deleted file mode 100644 index f1d67bbc..00000000 --- a/weather_briefing/render.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Compatibility exports for delivery renderers.""" - -from .delivery import PlainTextRenderer, TelegramHTMLRenderer -from .delivery.renderers import MessageRenderer - -__all__ = ["MessageRenderer", "PlainTextRenderer", "TelegramHTMLRenderer"] diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py deleted file mode 100644 index 41d2c392..00000000 --- a/weather_briefing/weather_context.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Compatibility exports for weather context providers.""" - -from .weather import ( - OPEN_METEO_LANGUAGE_SUPPORT, - QWEATHER_LANGUAGE_SUPPORT, - AirQualitySupplementingWeatherProvider, - DatedWeatherContextProvider, - FallbackWeatherContextProvider, - LoggedWeatherContextProvider, - OpenMeteoProvider, - QWeatherAuthenticator, - QWeatherJWTAuthenticator, - QWeatherProvider, - UnsupportedForecastDateError, - WeatherContextError, - WeatherContextProvider, - fetch_weather_context, - snapshot_to_documents, -) - -__all__ = [ - "OPEN_METEO_LANGUAGE_SUPPORT", - "QWEATHER_LANGUAGE_SUPPORT", - "AirQualitySupplementingWeatherProvider", - "DatedWeatherContextProvider", - "FallbackWeatherContextProvider", - "LoggedWeatherContextProvider", - "OpenMeteoProvider", - "QWeatherAuthenticator", - "QWeatherJWTAuthenticator", - "QWeatherProvider", - "UnsupportedForecastDateError", - "WeatherContextError", - "WeatherContextProvider", - "fetch_weather_context", - "snapshot_to_documents", -]