diff --git a/pyproject.toml b/pyproject.toml index baff2ea0..9a8c8d6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,4 +48,11 @@ line-length = 120 target-version = "py311" [tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "SIM"] +select = ["E", "F", "I", "UP", "B", "SIM", "D"] +ignore = ["D203", "D206", "D300"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D"] diff --git a/weather_briefing/air_quality.py b/weather_briefing/air_quality.py index bc980287..c6b966e1 100644 --- a/weather_briefing/air_quality.py +++ b/weather_briefing/air_quality.py @@ -1,3 +1,5 @@ +"""Air-quality providers, normalization, and health guidance.""" + from __future__ import annotations from functools import cache @@ -17,15 +19,21 @@ class AirQualityError(RuntimeError): class AirQualityProvider(Protocol): + """Fetch provider-neutral air-quality context for coordinates.""" + async def fetch( self, latitude: float, longitude: float, timezone: str, - ) -> AirQualitySnapshot: ... + ) -> AirQualitySnapshot: + """Fetch air-quality context for a location and its timezone.""" + ... class AQICNProvider: + """Fetch U.S. EPA AQI observations from AQICN.""" + def __init__( self, client: httpx.AsyncClient, @@ -33,6 +41,7 @@ def __init__( token: str, base_url: str, ) -> None: + """Configure AQICN access with an injected HTTP client and token.""" self._client = client self._token = token self._base_url = base_url @@ -43,6 +52,7 @@ async def fetch( longitude: float, timezone: str, ) -> AirQualitySnapshot: + """Fetch and normalize the AQICN observation for a location.""" try: response = await self._client.get( f"{self._base_url}/feed/geo:{latitude};{longitude}/", @@ -81,6 +91,7 @@ async def fetch( def air_quality_to_document(snapshot: AirQualitySnapshot) -> SourceDocument: + """Convert an air-quality snapshot into a citable source document.""" 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: @@ -102,6 +113,7 @@ def air_quality_to_document(snapshot: AirQualitySnapshot) -> SourceDocument: def health_guidance(aqi: int) -> tuple[str, str]: + """Return the configured category and health guidance for an AQI.""" for maximum_aqi, category, guidance in _guidance_bands(): if maximum_aqi is None or aqi <= maximum_aqi: return category, guidance diff --git a/weather_briefing/allergen.py b/weather_briefing/allergen.py index fe920077..6c1ad935 100644 --- a/weather_briefing/allergen.py +++ b/weather_briefing/allergen.py @@ -1,3 +1,5 @@ +"""Allergen reference data, guidance, and source conversion.""" + from __future__ import annotations from functools import cache @@ -18,6 +20,7 @@ def allergen_guidance(concentration: float) -> tuple[str, str]: def allergen_to_document(snapshot: AllergenSnapshot) -> SourceDocument: + """Convert an allergen snapshot into a citable source document.""" observed_at = snapshot.observed_at.to_iso8601_string() if snapshot.observed_at is not None else "不可用" levels = ( "\n".join(f"- {level.name}:{level.concentration:g} 粒/m³({level.category})" for level in snapshot.levels) diff --git a/weather_briefing/api_client.py b/weather_briefing/api_client.py index 5d079150..9209c5ed 100644 --- a/weather_briefing/api_client.py +++ b/weather_briefing/api_client.py @@ -1,3 +1,5 @@ +"""Privacy-preserving HTTP client instrumentation.""" + from __future__ import annotations import logging @@ -25,6 +27,7 @@ class LoggedAsyncClient(httpx.AsyncClient): """Record outbound HTTP calls without logging request data.""" async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: + """Send a request while logging only its non-sensitive identity.""" provider, operation = _api_call_identity(request) method = _safe_http_method(request.method) started_at = time.monotonic() diff --git a/weather_briefing/cli.py b/weather_briefing/cli.py index b4108f06..ba142c29 100644 --- a/weather_briefing/cli.py +++ b/weather_briefing/cli.py @@ -1,3 +1,5 @@ +"""Command-line composition, scheduling, and one-shot execution.""" + from __future__ import annotations import argparse @@ -55,6 +57,7 @@ def build_parser() -> argparse.ArgumentParser: + """Build the command-line parser for runs, daemon, and diagnostics.""" parser = argparse.ArgumentParser(description="Generate a stateful weather briefing") parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", required=True) @@ -201,6 +204,7 @@ async def run( forecast_date: str | None = None, run_now: bool = False, ) -> None: + """Compose dependencies and execute one task across configured locations.""" settings = Settings.from_env() _configure_logging(debug=settings.debug) if forecast_date is not None and kind != "forecast": @@ -466,6 +470,7 @@ def _parse_forecast_date(value: str) -> pendulum.Date: async def daemon() -> None: + """Run the in-process forecast and briefing scheduler indefinitely.""" settings = Settings.from_env() _configure_logging(debug=settings.debug) _LOGGER.info("Starting weather-briefing daemon (timezone: %s)", settings.timezone.name) @@ -524,6 +529,7 @@ def _manage_rendered_text_diagnostics(action: str, duration_seconds: int | None def main() -> None: + """Parse command-line arguments and dispatch the selected command.""" load_dotenv(override=False) args = build_parser().parse_args() _configure_logging(debug=False) diff --git a/weather_briefing/config.py b/weather_briefing/config.py index 3c0bd95d..81f80f0f 100644 --- a/weather_briefing/config.py +++ b/weather_briefing/config.py @@ -1,3 +1,5 @@ +"""Runtime configuration parsing and validation.""" + from __future__ import annotations import json @@ -117,10 +119,12 @@ def _configured_weather_providers() -> tuple[str, ...] | None: def state_path_from_env() -> Path: + """Return the configured SQLite state path without loading all settings.""" return Path(_clean_env(os.getenv("BRIEFING_STATE_PATH", "state/weather.sqlite3"))) def weather_providers_for(location: ResolvedLocation, configured: tuple[str, ...] | None) -> tuple[str, ...]: + """Resolve the configured or region-default weather provider order.""" if configured is not None: return configured region = "mainland_china" if location.is_mainland_china else "other" @@ -193,6 +197,8 @@ def _feeds(path: Path) -> tuple[FeedConfig, ...]: @dataclass(frozen=True, slots=True) class Settings: + """Collect validated runtime settings used to compose the application.""" + api_key: str llm_provider: str llm_model: str @@ -242,6 +248,7 @@ class Settings: @classmethod def from_env(cls) -> Settings: + """Load and validate application settings from the environment.""" locations_path = Path(_clean_env(os.getenv("BRIEFING_LOCATIONS_FILE", "locations.json"))) rss_sources_path = Path(_clean_env(os.getenv("RSS_SOURCES_FILE", "rss-sources.json"))) feeds = _feeds(rss_sources_path) diff --git a/weather_briefing/content_cleaners.py b/weather_briefing/content_cleaners.py index e989e549..c9a870e4 100644 --- a/weather_briefing/content_cleaners.py +++ b/weather_briefing/content_cleaners.py @@ -1,3 +1,5 @@ +"""Composable HTML-to-text content cleaning rules.""" + from __future__ import annotations import re @@ -16,17 +18,26 @@ class ContentCleaningError(ValueError): class ContentCleaner(Protocol): - def clean(self, content: str, rules: ContentCleaningRules) -> str: ... + """Normalize source content according to injected cleaning rules.""" + + def clean(self, content: str, rules: ContentCleaningRules) -> str: + """Clean source content according to injected rules.""" + ... @dataclass(frozen=True, slots=True) class ContentCleaningRules: + """Hold source-specific selectors and text removal patterns.""" + remove_selectors: tuple[str, ...] = () remove_patterns: tuple[str, ...] = () class HTMLContentCleaner: + """Convert untrusted HTML into filtered plain text.""" + def clean(self, content: str, rules: ContentCleaningRules) -> str: + """Remove configured noise and return normalized text lines.""" default_selectors, default_patterns = _default_cleaning_rules() soup = BeautifulSoup(content, "html.parser") for comment in soup.find_all(string=lambda value: isinstance(value, Comment)): diff --git a/weather_briefing/geocoding.py b/weather_briefing/geocoding.py index 8c937438..41288d35 100644 --- a/weather_briefing/geocoding.py +++ b/weather_briefing/geocoding.py @@ -1,3 +1,5 @@ +"""Location geocoding, fallback selection, and local caching.""" + from __future__ import annotations import asyncio @@ -21,11 +23,19 @@ class GeocodingError(RuntimeError): class GeocodingProvider(Protocol): - async def geocode(self, location: LocationSpec) -> ResolvedLocation: ... + """Resolve a named location to coordinates and geographic metadata.""" + + async def geocode(self, location: LocationSpec) -> ResolvedLocation: + """Resolve one named location.""" + ... class ReverseGeocodingProvider(Protocol): - async def reverse_geocode(self, location: LocationSpec) -> ResolvedLocation: ... + """Resolve coordinates to a canonical location name and metadata.""" + + async def reverse_geocode(self, location: LocationSpec) -> ResolvedLocation: + """Reverse-geocode one coordinate-bearing location.""" + ... @cache @@ -52,11 +62,14 @@ def _mainland_china_rules() -> tuple[float, float, float, float, frozenset[str]] def possibly_mainland_china(latitude: float, longitude: float) -> bool: + """Return whether coordinates fall inside the broad service bounds.""" latitude_min, latitude_max, longitude_min, longitude_max, _ = _mainland_china_rules() return latitude_min <= latitude <= latitude_max and longitude_min <= longitude <= longitude_max class OpenMeteoGeocodingProvider: + """Resolve named locations through the Open-Meteo geocoding API.""" + def __init__( self, client: httpx.AsyncClient, @@ -64,11 +77,13 @@ def __init__( base_url: str = "https://geocoding-api.open-meteo.com", api_key: str | None = None, ) -> None: + """Configure Open-Meteo geocoding access and its optional API key.""" self._client = client self._base_url = base_url.rstrip("/") self._api_key = api_key async def geocode(self, location: LocationSpec) -> ResolvedLocation: + """Resolve a location using a matching Open-Meteo result.""" location_name = _required_location_name(location) params: dict[str, str | int] = { "name": location_name, @@ -122,6 +137,8 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: class NominatimGeocodingProvider: + """Resolve and reverse-resolve locations through Nominatim.""" + def __init__( self, client: httpx.AsyncClient, @@ -129,6 +146,7 @@ def __init__( user_agent: str, base_url: str = "https://nominatim.openstreetmap.org", ) -> None: + """Configure rate-limited Nominatim access with an identifying user agent.""" if not user_agent.strip(): raise ValueError("Nominatim requires an identifying User-Agent") self._client = client @@ -138,6 +156,7 @@ def __init__( self._last_request_at = 0.0 async def geocode(self, location: LocationSpec) -> ResolvedLocation: + """Resolve a named location while respecting Nominatim rate limits.""" location_name = _required_location_name(location) async with self._lock: result: dict[str, object] | None = None @@ -197,6 +216,7 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: ) async def reverse_geocode(self, location: LocationSpec) -> ResolvedLocation: + """Resolve coordinates to the nearest canonical OSM address.""" if location.latitude is None or location.longitude is None: raise GeocodingError(f"Reverse geocoding requires coordinates for location: {location.id}") async with self._lock: @@ -246,12 +266,16 @@ async def reverse_geocode(self, location: LocationSpec) -> ResolvedLocation: class FallbackGeocodingProvider: + """Try geocoding providers in order until one resolves a location.""" + def __init__(self, *providers: GeocodingProvider) -> None: + """Require and retain providers in fallback priority order.""" if not providers: raise ValueError("At least one geocoding provider is required") self._providers = providers async def geocode(self, location: LocationSpec) -> ResolvedLocation: + """Resolve a location with the first successful provider.""" location_name = _required_location_name(location) errors: list[GeocodingError] = [] for provider in self._providers: @@ -263,10 +287,14 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: class PrecisionReducingGeocodingProvider: + """Retry failed Chinese place names at progressively lower precision.""" + def __init__(self, provider: GeocodingProvider) -> None: + """Wrap a geocoder with progressively broader Chinese-name retries.""" self._provider = provider async def geocode(self, location: LocationSpec) -> ResolvedLocation: + """Resolve a location directly before trying safe broader names.""" location_name = _required_location_name(location) try: return await self._provider.geocode(location) @@ -288,6 +316,8 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation: class CachedLocationResolver: + """Resolve complete locations while caching provider-derived metadata.""" + def __init__( self, provider: GeocodingProvider, @@ -295,14 +325,17 @@ def __init__( *, reverse_provider: ReverseGeocodingProvider | None = None, ) -> None: + """Configure forward and optional reverse geocoding with a local cache.""" self._provider = provider self._cache_path = cache_path self._reverse_provider = reverse_provider async def resolve(self, location: LocationSpec) -> ResolvedLocation: + """Resolve a location and return its normalized value.""" return (await self.resolve_with_metadata(location)).location async def resolve_with_metadata(self, location: LocationSpec) -> LocationResolution: + """Resolve a location and report whether its result came from cache.""" location_name = (location.name or "").strip() or None if location.latitude is not None and location.longitude is not None: if location_name is None: diff --git a/weather_briefing/llm.py b/weather_briefing/llm.py index f64d2aaa..5af2ba3d 100644 --- a/weather_briefing/llm.py +++ b/weather_briefing/llm.py @@ -1,3 +1,5 @@ +"""LLM provider adapters and structured result validation.""" + from __future__ import annotations import json @@ -17,10 +19,16 @@ class LLMError(RuntimeError): class LLMProvider(Protocol): - async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]: ... + """Produce a structured briefing payload from validated source context.""" + + async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]: + """Return one structured model response.""" + ... class OpenAICompatibleChatCompletionsProvider: + """Call an OpenAI-compatible chat-completions endpoint.""" + API_PROVIDER = "openai-compatible" def __init__( @@ -32,6 +40,7 @@ def __init__( model: str, max_output_tokens: int, ) -> None: + """Configure one OpenAI-compatible model endpoint and output limit.""" self._client = client self._api_key = api_key self._base_url = base_url @@ -40,9 +49,11 @@ def __init__( @property def base_url(self) -> str: + """Return the configured API base URL.""" return self._base_url async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]: + """Request and decode one structured JSON response.""" try: response = await self._client.post( f"{self._base_url}/chat/completions", @@ -72,6 +83,8 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic class DeepSeekProvider(OpenAICompatibleChatCompletionsProvider): + """Use DeepSeek through the shared OpenAI-compatible adapter.""" + DEFAULT_BASE_URL = "https://api.deepseek.com" API_PROVIDER = "deepseek" @@ -84,6 +97,7 @@ def __init__( max_output_tokens: int, base_url: str = DEFAULT_BASE_URL, ) -> None: + """Configure DeepSeek through the shared chat-completions adapter.""" super().__init__( client, api_key=api_key, @@ -98,6 +112,7 @@ def parse_result( now: pendulum.DateTime, valid_source_ids: set[str], ) -> BriefingResult: + """Validate an LLM payload and convert it to a briefing result.""" require_aware_datetime(now, context="Briefing result time") def string_ids(value: Mapping[str, Any], field: str) -> tuple[str, ...]: diff --git a/weather_briefing/models.py b/weather_briefing/models.py index 1ca69403..f0c515b7 100644 --- a/weather_briefing/models.py +++ b/weather_briefing/models.py @@ -1,3 +1,5 @@ +"""Platform-neutral domain models and runtime configuration records.""" + from __future__ import annotations from dataclasses import dataclass, field @@ -8,6 +10,8 @@ @dataclass(frozen=True, slots=True) class FeedConfig: + """Describe an RSS feed and its location-specific content rules.""" + id: str name: str url: str @@ -20,6 +24,8 @@ class FeedConfig: @dataclass(frozen=True, slots=True) class ContextSourceConfig: + """Describe an auxiliary HTTP context source.""" + id: str name: str url: str @@ -27,6 +33,8 @@ class ContextSourceConfig: @dataclass(frozen=True, slots=True) class LocationSpec: + """Represent the user-provided identity or coordinates of a location.""" + id: str name: str | None = None latitude: float | None = None @@ -35,6 +43,8 @@ class LocationSpec: @dataclass(frozen=True, slots=True) class ResolvedLocation: + """Represent a location resolved to stable coordinates and metadata.""" + id: str name: str latitude: float @@ -49,12 +59,16 @@ class ResolvedLocation: @dataclass(frozen=True, slots=True) class LocationResolution: + """Pair a resolved location with its cache provenance.""" + location: ResolvedLocation from_cache: bool @dataclass(frozen=True, slots=True) class Article: + """Represent cleaned source content ready for briefing orchestration.""" + id: str source_id: str source_name: str @@ -67,6 +81,8 @@ class Article: @dataclass(frozen=True, slots=True) class SourceDocument: + """Represent citable non-article context supplied to the LLM.""" + id: str name: str url: str @@ -76,6 +92,8 @@ class SourceDocument: @dataclass(frozen=True, slots=True) class BriefingRecord: + """Represent a previously published briefing.""" + kind: str body: str published_at: pendulum.DateTime @@ -83,6 +101,8 @@ class BriefingRecord: @dataclass(frozen=True, slots=True) class AirQualitySnapshot: + """Represent provider-neutral air-quality observations and guidance.""" + source_id: str source_name: str source_url: str @@ -99,6 +119,8 @@ class AirQualitySnapshot: @dataclass(frozen=True, slots=True) class AllergenLevel: + """Represent the concentration and category of one allergen.""" + name: str category: str concentration: float @@ -106,6 +128,8 @@ class AllergenLevel: @dataclass(frozen=True, slots=True) class AllergenSnapshot: + """Represent provider-neutral allergen observations and guidance.""" + source_id: str source_name: str source_url: str @@ -117,6 +141,8 @@ class AllergenSnapshot: @dataclass(frozen=True, slots=True) class WeatherContextSnapshot: + """Collect weather, air-quality, allergen, and lifestyle context.""" + source_id: str source_name: str source_url: str @@ -130,6 +156,8 @@ class WeatherContextSnapshot: @dataclass(frozen=True, slots=True) class Warning: + """Represent an active warning and the evidence that confirms it.""" + id: str title: str status: str @@ -140,11 +168,15 @@ class Warning: @dataclass(frozen=True, slots=True) class Conclusion: + """Represent a sourced conclusion emitted by the LLM.""" + text: str source_ids: tuple[str, ...] class AdviceTopic(StrEnum): + """Enumerate supported structured lifestyle advice topics.""" + CLOTHING = "clothing" DEHUMIDIFICATION = "dehumidification" EXERCISE = "exercise" @@ -154,6 +186,8 @@ class AdviceTopic(StrEnum): @dataclass(frozen=True, slots=True) class Advice: + """Represent sourced lifestyle advice for one topic.""" + topic: AdviceTopic text: str source_ids: tuple[str, ...] @@ -161,6 +195,8 @@ class Advice: @dataclass(frozen=True, slots=True) class BriefingResult: + """Represent a validated, platform-neutral LLM briefing result.""" + headline: str headline_source_ids: tuple[str, ...] conclusions: tuple[Conclusion, ...] @@ -174,5 +210,7 @@ class BriefingResult: @dataclass(frozen=True, slots=True) class RenderedMessage: + """Pair rendered message content with its platform-visible length.""" + body: str visible_length: int diff --git a/weather_briefing/prompts.py b/weather_briefing/prompts.py index dfe4a8e0..24928ab3 100644 --- a/weather_briefing/prompts.py +++ b/weather_briefing/prompts.py @@ -1,3 +1,5 @@ +"""System prompts defining the structured briefing output contract.""" + SYSTEM_PROMPT = """你是谨慎的天气信息编辑。只能根据输入资料形成结论,不得编造事实或链接。 输出单个 JSON 对象,字段为: - headline: string diff --git a/weather_briefing/publishers.py b/weather_briefing/publishers.py index a02831dd..e8ba8599 100644 --- a/weather_briefing/publishers.py +++ b/weather_briefing/publishers.py @@ -1,3 +1,5 @@ +"""Delivery composition and message publisher adapters.""" + from __future__ import annotations import logging @@ -14,17 +16,25 @@ class Publisher(Protocol): + """Transport a rendered message to its destination.""" + async def publish( self, message: RenderedMessage, *, single_message: bool = False, silent: bool = False, - ) -> None: ... + ) -> None: + """Publish one rendered message with delivery hints.""" + ... class RenderedTextDiagnostics(Protocol): - def rendered_text_logging_enabled(self) -> bool: ... + """Expose the runtime switch for sensitive rendered-text logging.""" + + def rendered_text_logging_enabled(self) -> bool: + """Return whether sensitive rendered-text logging is enabled.""" + ... @dataclass(frozen=True, slots=True) @@ -37,6 +47,7 @@ class DeliveryProvider: diagnostics: RenderedTextDiagnostics | None = None def briefing_limit(self, configured_limit: int) -> int: + """Clamp a configured briefing limit to the platform limit.""" if self.single_message_limit is None: return configured_limit return min(configured_limit, self.single_message_limit) @@ -47,6 +58,7 @@ def render_briefing( reference_articles: tuple[Article, ...], context: tuple[SourceDocument, ...], ) -> RenderedMessage: + """Render a briefing with the configured platform renderer.""" return self.renderer.render_briefing(result, reference_articles, context) async def publish_rendered( @@ -56,10 +68,12 @@ async def publish_rendered( single_message: bool = False, silent: bool = False, ) -> None: + """Publish an already rendered message with delivery hints.""" _log_rendered_text(self.diagnostics, "briefing", message.body) await self.publisher.publish(message, single_message=single_message, silent=silent) async def publish_verbatim(self, article: Article, *, silent: bool = False) -> None: + """Render and publish one cleaned article without summarization.""" message = self.renderer.render_verbatim(article) _LOGGER.debug( "Rendered verbatim message: visible_characters=%d payload_characters=%d", @@ -70,12 +84,15 @@ async def publish_verbatim(self, article: Article, *, silent: bool = False) -> N await self.publisher.publish(message, silent=silent) async def publish_alert(self, title: str, body: str) -> None: + """Render and publish an operational alert.""" message = self.renderer.render_alert(title, body) _log_rendered_text(self.diagnostics, "alert", message.body) await self.publisher.publish(message) class StdoutPublisher: + """Write rendered messages to standard output.""" + async def publish( self, message: RenderedMessage, @@ -83,6 +100,7 @@ async def publish( single_message: bool = False, silent: bool = False, ) -> None: + """Print the rendered body and ignore platform delivery hints.""" print(message.body) @@ -91,6 +109,8 @@ class DeliveryError(RuntimeError): class TelegramPublisher: + """Publish rendered HTML messages through the Telegram Bot API.""" + MAX_MESSAGE_LENGTH = 4096 def __init__( @@ -100,6 +120,7 @@ def __init__( chat_id: str, diagnostics: RenderedTextDiagnostics | None = None, ) -> None: + """Configure Telegram delivery and optional sensitive-text diagnostics.""" self._client = client self._url = f"https://api.telegram.org/bot{token}/sendMessage" self._chat_id = chat_id @@ -112,6 +133,7 @@ async def publish( single_message: bool = False, silent: bool = False, ) -> None: + """Publish one message, splitting it only when allowed.""" 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) diff --git a/weather_briefing/reference_data.py b/weather_briefing/reference_data.py index 3726629b..fe6cbc38 100644 --- a/weather_briefing/reference_data.py +++ b/weather_briefing/reference_data.py @@ -1,3 +1,5 @@ +"""Validated access to packaged domain reference data.""" + from __future__ import annotations import json @@ -15,6 +17,7 @@ class ReferenceDataError(RuntimeError): @cache def load_reference_data(filename: str) -> dict[str, object]: + """Load and validate one packaged JSON reference-data object.""" if PurePath(filename).name != filename or not filename.endswith(".json"): raise ReferenceDataError("Reference data filename must identify one JSON file") try: @@ -28,6 +31,7 @@ def load_reference_data(filename: str) -> dict[str, object]: def reference_value(filename: str, *path: str) -> Any: + """Read a nested value from a packaged reference-data file.""" value: Any = load_reference_data(filename) try: for key in path: @@ -39,6 +43,7 @@ def reference_value(filename: str, *path: str) -> Any: def reference_string(filename: str, *path: str) -> str: + """Read a non-empty string from packaged reference data.""" value = reference_value(filename, *path) if not isinstance(value, str) or not value.strip(): joined_path = ".".join(path) @@ -47,6 +52,7 @@ def reference_string(filename: str, *path: str) -> str: def reference_string_tuple(filename: str, *path: str) -> tuple[str, ...]: + """Read a non-empty string sequence from packaged reference data.""" 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): joined_path = ".".join(path) diff --git a/weather_briefing/render.py b/weather_briefing/render.py index 3a6e48d1..7fbdebea 100644 --- a/weather_briefing/render.py +++ b/weather_briefing/render.py @@ -1,3 +1,5 @@ +"""Platform-specific rendering of validated briefing results.""" + from __future__ import annotations from html import escape, unescape @@ -16,25 +18,36 @@ class MessageRenderer(Protocol): + """Render platform-neutral briefing data for one delivery platform.""" + def render_briefing( self, result: BriefingResult, reference_articles: tuple[Article, ...], context: tuple[SourceDocument, ...], - ) -> RenderedMessage: ... + ) -> RenderedMessage: + """Render a validated briefing and its citable references.""" + ... - def render_verbatim(self, article: Article) -> RenderedMessage: ... + def render_verbatim(self, article: Article) -> RenderedMessage: + """Render an article without summarizing its cleaned content.""" + ... - def render_alert(self, title: str, body: str) -> RenderedMessage: ... + def render_alert(self, title: str, body: str) -> RenderedMessage: + """Render an operational alert.""" + ... class TelegramHTMLRenderer: + """Render briefings as Telegram-compatible HTML.""" + def render_briefing( self, result: BriefingResult, reference_articles: tuple[Article, ...], context: tuple[SourceDocument, ...], ) -> RenderedMessage: + """Render a sourced briefing as Telegram HTML.""" source_links = { article.id: _html_link(article.url, _article_source_name(article)) for article in reference_articles } @@ -57,6 +70,7 @@ def render_briefing( return _html_message("\n".join(lines).strip()) def render_verbatim(self, article: Article) -> RenderedMessage: + """Render cleaned article content as Telegram HTML.""" return _html_message( "\n".join( ( @@ -68,16 +82,20 @@ def render_verbatim(self, article: Article) -> RenderedMessage: ) def render_alert(self, title: str, body: str) -> RenderedMessage: + """Render an escaped Telegram HTML alert.""" return _html_message(f"{_html_text(title)}\n\n{_html_text(body)}") class PlainTextRenderer: + """Render briefings for stdout and other plain-text transports.""" + def render_briefing( self, result: BriefingResult, reference_articles: tuple[Article, ...], context: tuple[SourceDocument, ...], ) -> RenderedMessage: + """Render a sourced briefing as plain text.""" source_references = { article.id: f"{_article_source_name(article)}: {article.url}" for article in reference_articles } @@ -98,9 +116,11 @@ def render_briefing( return _plain_message("\n".join(lines).strip()) def render_verbatim(self, article: Article) -> RenderedMessage: + """Render cleaned article content as plain text.""" return _plain_message(f"{article.title}\n\n{article.content}") def render_alert(self, title: str, body: str) -> RenderedMessage: + """Render an operational alert as plain text.""" return _plain_message(f"{title}\n\n{body}") diff --git a/weather_briefing/service.py b/weather_briefing/service.py index f0920d79..570bf870 100644 --- a/weather_briefing/service.py +++ b/weather_briefing/service.py @@ -1,3 +1,5 @@ +"""Core briefing orchestration and output contract enforcement.""" + from __future__ import annotations import asyncio @@ -29,35 +31,57 @@ class BriefingSettings(Protocol): + """Expose the settings required by briefing orchestration.""" + @property - def timezone(self) -> pendulum.Timezone: ... + def timezone(self) -> pendulum.Timezone: + """Return the briefing timezone.""" + ... @property - def feeds(self) -> tuple[FeedConfig, ...]: ... + def feeds(self) -> tuple[FeedConfig, ...]: + """Return configured RSS feeds.""" + ... @property - def context_sources(self) -> tuple[ContextSourceConfig, ...]: ... + def context_sources(self) -> tuple[ContextSourceConfig, ...]: + """Return configured auxiliary context sources.""" + ... @property - def rss_stale_hours(self) -> int: ... + def rss_stale_hours(self) -> int: + """Return the RSS staleness threshold in hours.""" + ... @property - def rss_failure_threshold(self) -> int: ... + def rss_failure_threshold(self) -> int: + """Return the consecutive RSS failure alert threshold.""" + ... @property - def warning_retention_hours(self) -> int: ... + def warning_retention_hours(self) -> int: + """Return the active-warning retention window in hours.""" + ... @property - def history_hours(self) -> int: ... + def history_hours(self) -> int: + """Return the retained briefing context window in hours.""" + ... @property - def briefing_max_characters(self) -> int: ... + def briefing_max_characters(self) -> int: + """Return the configured briefing character budget.""" + ... @property - def llm_max_attempts(self) -> int: ... + def llm_max_attempts(self) -> int: + """Return the maximum LLM validation attempts.""" + ... class BriefingService: + """Orchestrate source collection, validation, state, and delivery.""" + def __init__( self, settings: BriefingSettings, @@ -70,6 +94,7 @@ def __init__( ops_delivery: DeliveryProvider, weather_context_provider: WeatherContextProvider | None = None, ) -> None: + """Compose briefing orchestration from its location-scoped dependencies.""" self._settings = settings self._location = location self._state = state @@ -89,6 +114,7 @@ async def run( force_publish: bool = False, silent: bool = False, ) -> str | None: + """Run one forecast or briefing task and persist its outcome.""" current_time = require_aware_datetime(now or pendulum.now(self._settings.timezone), context="Briefing run time") if forecast_date is not None and kind != "forecast": raise ValueError("Forecast date is only supported in forecast mode") diff --git a/weather_briefing/sources.py b/weather_briefing/sources.py index 85e1e419..a82ffb5b 100644 --- a/weather_briefing/sources.py +++ b/weather_briefing/sources.py @@ -1,3 +1,5 @@ +"""RSS and auxiliary context source adapters.""" + from __future__ import annotations import asyncio @@ -23,11 +25,19 @@ class SourceFetchError(RuntimeError): class RSSFeedSource(Protocol): - async def fetch(self, config: FeedConfig) -> tuple[Article, ...]: ... + """Fetch and normalize articles from an RSS feed.""" + + async def fetch(self, config: FeedConfig) -> tuple[Article, ...]: + """Fetch articles for one feed configuration.""" + ... class ContextDocumentSource(Protocol): - async def fetch(self, config: ContextSourceConfig) -> SourceDocument: ... + """Fetch auxiliary context as a citable document.""" + + async def fetch(self, config: ContextSourceConfig) -> SourceDocument: + """Fetch one configured context document.""" + ... def _entry_time(entry: feedparser.FeedParserDict) -> pendulum.DateTime | None: @@ -45,6 +55,8 @@ def _entry_content(entry: feedparser.FeedParserDict) -> str: class RSSSource: + """Fetch, retry, clean, and normalize RSS feed entries.""" + def __init__( self, client: httpx.AsyncClient, @@ -54,6 +66,7 @@ def __init__( retry_max_seconds: float = 5, cleaner: ContentCleaner | None = None, ) -> None: + """Configure RSS retries and optional source-content cleaning.""" self._client = client self._max_attempts = max_attempts self._retry_min_seconds = retry_min_seconds @@ -61,6 +74,7 @@ def __init__( self._cleaner = cleaner or HTMLContentCleaner() async def fetch(self, config: FeedConfig) -> tuple[Article, ...]: + """Fetch and normalize all usable articles from one RSS source.""" response_text = await self._fetch_with_retry(config) parsed = feedparser.parse(response_text) if parsed.bozo and not parsed.entries: @@ -121,10 +135,14 @@ async def _fetch_with_retry(self, config: FeedConfig) -> str: class HTTPContextSource: + """Fetch auxiliary context documents over HTTP.""" + def __init__(self, client: httpx.AsyncClient) -> None: + """Use an injected HTTP client for auxiliary context requests.""" self._client = client async def fetch(self, config: ContextSourceConfig) -> SourceDocument: + """Fetch one context URL without exposing transport details.""" try: response = await self._client.get( config.url, diff --git a/weather_briefing/state.py b/weather_briefing/state.py index 3bee3469..33fcad38 100644 --- a/weather_briefing/state.py +++ b/weather_briefing/state.py @@ -1,3 +1,5 @@ +"""SQLite-backed briefing state and runtime diagnostics.""" + from __future__ import annotations import json @@ -18,7 +20,10 @@ class SQLiteRuntimeDiagnostics: + """Persist the expiring switch for sensitive rendered-text diagnostics.""" + def __init__(self, path: Path) -> None: + """Open the diagnostics database and ensure its runtime-switch schema.""" path.parent.mkdir(parents=True, exist_ok=True) self._connection = sqlite3.connect(path, timeout=_RUNTIME_DIAGNOSTIC_BUSY_TIMEOUT_SECONDS) self._connection.row_factory = sqlite3.Row @@ -28,15 +33,19 @@ def __init__(self, path: Path) -> None: self._connection.commit() def close(self) -> None: + """Close the diagnostics database connection.""" self._connection.close() def __enter__(self) -> SQLiteRuntimeDiagnostics: + """Return this diagnostics store for context-managed use.""" return self def __exit__(self, *_: object) -> None: + """Close the connection without suppressing context exceptions.""" self.close() def enable_rendered_text_logging(self, expires_at: pendulum.DateTime) -> None: + """Enable sensitive rendered-text logging until an aware timestamp.""" expires_at = require_aware_datetime(expires_at, context="Rendered text diagnostic expiration") self._connection.execute( """INSERT INTO runtime_diagnostics(name, expires_at) VALUES (?, ?) @@ -50,6 +59,7 @@ def enable_rendered_text_logging(self, expires_at: pendulum.DateTime) -> None: ) def disable_rendered_text_logging(self) -> None: + """Disable sensitive rendered-text logging immediately.""" self._connection.execute( "DELETE FROM runtime_diagnostics WHERE name = ?", (_RENDERED_TEXT_DIAGNOSTIC,), @@ -61,6 +71,7 @@ def rendered_text_logging_until( self, now: pendulum.DateTime | None = None, ) -> pendulum.DateTime | None: + """Return the active expiration time and remove expired state.""" current_time = require_aware_datetime( now or pendulum.now("UTC"), context="Rendered text diagnostic check time", @@ -86,23 +97,30 @@ def rendered_text_logging_until( return None def rendered_text_logging_enabled(self) -> bool: + """Return whether rendered-text diagnostics are currently enabled.""" return self.rendered_text_logging_until() is not None class SQLiteStateStore: + """Persist briefing history, warnings, articles, and health state.""" + def __init__(self, path: Path) -> None: + """Open the state database and initialize its application schema.""" path.parent.mkdir(parents=True, exist_ok=True) self._connection = sqlite3.connect(path) self._connection.row_factory = sqlite3.Row self._initialize() def close(self) -> None: + """Close the state database connection.""" self._connection.close() def __enter__(self) -> SQLiteStateStore: + """Return this state store for context-managed use.""" return self def __exit__(self, *_: object) -> None: + """Close the connection without suppressing context exceptions.""" self.close() def _initialize(self) -> None: @@ -151,6 +169,7 @@ def _initialize(self) -> None: self._connection.commit() def known_article_ids(self, ids: tuple[str, ...]) -> set[str]: + """Return the subset of article IDs already processed.""" if not ids: return set() placeholders = ",".join("?" for _ in ids) @@ -161,6 +180,7 @@ def known_article_ids(self, ids: tuple[str, ...]) -> set[str]: return {str(row["id"]) for row in rows} def save_articles(self, articles: tuple[Article, ...], processed_at: pendulum.DateTime) -> None: + """Persist processed articles at an aware timestamp.""" self._insert_articles(articles, processed_at) self._connection.commit() @@ -186,6 +206,7 @@ def _insert_articles(self, articles: tuple[Article, ...], processed_at: pendulum ) def save_pending_articles(self, articles: tuple[Article, ...], first_seen_at: pendulum.DateTime) -> None: + """Persist articles awaiting successful briefing delivery.""" self._connection.executemany( """INSERT OR IGNORE INTO pending_articles (id, source_id, source_name, title, url, published_at, content, is_verbatim, first_seen_at) @@ -208,6 +229,7 @@ def save_pending_articles(self, articles: tuple[Article, ...], first_seen_at: pe self._connection.commit() def pending_articles(self) -> tuple[Article, ...]: + """Return pending articles in stable processing order.""" rows = self._connection.execute("SELECT * FROM pending_articles ORDER BY first_seen_at, published_at") return tuple(_article_from_row(row) for row in rows) @@ -216,6 +238,7 @@ def mark_articles_processed( articles: tuple[Article, ...], processed_at: pendulum.DateTime, ) -> None: + """Move delivered articles from pending to processed state.""" self._insert_articles(articles, processed_at) if not articles: self._connection.commit() @@ -233,6 +256,7 @@ def record_source_check( checked_at: pendulum.DateTime, latest_at: pendulum.DateTime | None, ) -> None: + """Record an RSS source check and its newest observed article time.""" self._connection.execute( """INSERT INTO source_health( source_id, first_checked_at, last_article_at, stale_alerted_at @@ -265,6 +289,7 @@ def stale_sources( now: pendulum.DateTime, stale_hours: int, ) -> list[str]: + """Return sources without recent articles inside the threshold.""" threshold = now.subtract(hours=stale_hours) stale: list[str] = [] for source_id in source_ids: @@ -285,6 +310,7 @@ def stale_sources_requiring_alert( now: pendulum.DateTime, stale_hours: int, ) -> list[str]: + """Return stale sources not yet alerted for the current stale period.""" stale = set(self.stale_sources(source_ids, now, stale_hours)) return [ source_id @@ -302,6 +328,7 @@ def mark_stale_sources_alerted( source_ids: tuple[str, ...], alerted_at: pendulum.DateTime, ) -> None: + """Record successful stale-source alert delivery.""" if not source_ids: return placeholders = ",".join("?" for _ in source_ids) @@ -313,6 +340,7 @@ def mark_stale_sources_alerted( self._connection.commit() def recent_briefings(self, now: pendulum.DateTime, history_hours: int) -> tuple[BriefingRecord, ...]: + """Return briefings inside the configured history window.""" threshold = _storage_time(now.subtract(hours=history_hours)) rows = self._connection.execute( "SELECT kind, body, published_at FROM briefings WHERE published_at >= ? ORDER BY published_at", @@ -333,6 +361,7 @@ def has_briefing_between( start: pendulum.DateTime, end: pendulum.DateTime, ) -> bool: + """Return whether a briefing kind was published in a time interval.""" row = self._connection.execute( "SELECT 1 FROM briefings WHERE kind = ? AND published_at >= ? AND published_at <= ? LIMIT 1", (kind, _storage_time(start), _storage_time(end)), @@ -340,6 +369,7 @@ def has_briefing_between( return row is not None def recent_articles(self, now: pendulum.DateTime, history_hours: int) -> tuple[Article, ...]: + """Return processed articles inside the configured history window.""" threshold = _storage_time(now.subtract(hours=history_hours)) rows = self._connection.execute( "SELECT * FROM articles WHERE published_at >= ? ORDER BY published_at", @@ -348,6 +378,7 @@ def recent_articles(self, now: pendulum.DateTime, history_hours: int) -> tuple[A return tuple(_article_from_row(row) for row in rows) def save_briefing(self, kind: str, body: str, published_at: pendulum.DateTime) -> None: + """Persist a successfully published briefing.""" self._connection.execute( "INSERT INTO briefings(kind, body, published_at) VALUES (?, ?, ?)", (kind, body, _storage_time(published_at)), @@ -355,6 +386,7 @@ def save_briefing(self, kind: str, body: str, published_at: pendulum.DateTime) - self._connection.commit() def save_context_documents(self, documents: tuple[SourceDocument, ...], observed_at: pendulum.DateTime) -> None: + """Persist context documents observed during a successful run.""" self._connection.executemany( """INSERT INTO context_snapshots(source_id, name, url, content, observed_at) VALUES (?, ?, ?, ?, ?)""", @@ -372,6 +404,7 @@ def save_context_documents(self, documents: tuple[SourceDocument, ...], observed self._connection.commit() def recent_context_documents(self, now: pendulum.DateTime, history_hours: int) -> tuple[SourceDocument, ...]: + """Return context documents inside the configured history window.""" threshold = _storage_time(now.subtract(hours=history_hours)) rows = self._connection.execute( """SELECT source_id, name, url, content FROM context_snapshots @@ -389,6 +422,7 @@ def recent_context_documents(self, now: pendulum.DateTime, history_hours: int) - ) def active_warnings(self, now: pendulum.DateTime, retention_hours: int) -> tuple[Warning, ...]: + """Return warnings confirmed inside the retention window.""" threshold = _storage_time(now.subtract(hours=retention_hours)) rows = self._connection.execute( "SELECT payload, last_confirmed_at FROM warnings WHERE last_confirmed_at >= ?", @@ -416,6 +450,7 @@ def update_warnings( now: pendulum.DateTime, confirmed_source_ids: set[str] | None = None, ) -> None: + """Apply active and resolved warning updates atomically.""" confirmed_source_ids = confirmed_source_ids or set() if resolved_warning_ids: placeholders = ",".join("?" for _ in resolved_warning_ids) @@ -454,6 +489,7 @@ def record_success( history_hours: int, warning_retention_hours: int, ) -> None: + """Record task success and prune expired history in one transaction.""" history_threshold = _storage_time(now.subtract(hours=history_hours)) warning_threshold = _storage_time(now.subtract(hours=warning_retention_hours)) self._connection.execute("DELETE FROM articles WHERE processed_at < ?", (history_threshold,)) @@ -465,6 +501,7 @@ def record_success( self._connection.commit() def record_failure(self) -> int: + """Increment and return the consecutive task failure count.""" self._connection.execute( "UPDATE task_health SET consecutive_failures = consecutive_failures + 1 WHERE singleton = 1" ) @@ -473,10 +510,12 @@ def record_failure(self) -> int: return int(row["consecutive_failures"]) def task_failure_requires_alert(self) -> bool: + """Return whether the current task failure period lacks an alert.""" row = self._connection.execute("SELECT 1 FROM task_failure_alert WHERE singleton = 1").fetchone() return row is None def mark_task_failure_alerted(self, alerted_at: pendulum.DateTime) -> None: + """Record successful task-failure alert delivery.""" self._connection.execute( "INSERT OR REPLACE INTO task_failure_alert(singleton, alerted_at) VALUES (1, ?)", (_storage_time(alerted_at),), @@ -484,6 +523,7 @@ def mark_task_failure_alerted(self, alerted_at: pendulum.DateTime) -> None: self._connection.commit() def record_rss_fetch_failure(self, source_id: str) -> int: + """Increment and return one RSS source's consecutive failure count.""" self._connection.execute( """INSERT INTO rss_failure_tracker(source_id, consecutive_failures, failure_alerted_at) VALUES (?, 1, NULL) @@ -499,6 +539,7 @@ def record_rss_fetch_failure(self, source_id: str) -> int: return int(row["consecutive_failures"]) def record_rss_fetch_success(self, source_id: str) -> None: + """Reset one RSS source's failure period.""" self._connection.execute( "DELETE FROM rss_failure_tracker WHERE source_id = ?", (source_id,), @@ -510,6 +551,7 @@ def rss_sources_requiring_failure_alert( source_ids: tuple[str, ...], threshold: int, ) -> list[str]: + """Return RSS sources whose unalerted failures reached the threshold.""" if not source_ids: return [] placeholders = ",".join("?" for _ in source_ids) @@ -529,6 +571,7 @@ def mark_rss_failure_alerted( source_ids: tuple[str, ...], alerted_at: pendulum.DateTime, ) -> None: + """Record successful RSS failure alert delivery for sources.""" if not source_ids: return placeholders = ",".join("?" for _ in source_ids) diff --git a/weather_briefing/time_utils.py b/weather_briefing/time_utils.py index c3c9a180..1d0a6af2 100644 --- a/weather_briefing/time_utils.py +++ b/weather_briefing/time_utils.py @@ -1,3 +1,5 @@ +"""Timezone-aware parsing and SQLite timestamp conversion.""" + from __future__ import annotations import re @@ -8,12 +10,14 @@ def require_aware_datetime(value: pendulum.DateTime, *, context: str) -> pendulum.DateTime: + """Return a datetime after rejecting missing timezone information.""" if value.tzinfo is None: raise ValueError(f"{context} must include explicit timezone information") return value def parse_aware_datetime(value: str, *, context: str) -> pendulum.DateTime: + """Parse a timestamp that carries an explicit UTC offset or Z suffix.""" if not _OFFSET_SUFFIX.search(value): raise ValueError(f"{context} must include an explicit UTC offset or Z suffix") parsed = pendulum.parse(value, strict=True) @@ -29,7 +33,6 @@ def parse_datetime_with_default_timezone( context: str, ) -> pendulum.DateTime: """Parse provider time, applying an explicit provider fallback when needed.""" - if _OFFSET_SUFFIX.search(value): return parse_aware_datetime(value, context=context) if not default_timezone: @@ -52,7 +55,6 @@ def parse_datetime_with_utc_offset( context: str, ) -> pendulum.DateTime: """Parse a provider-local timestamp with its explicit response offset.""" - if abs(offset_seconds) > 14 * 60 * 60 or offset_seconds % 60: raise ValueError(f"{context} has an invalid UTC offset") sign = "+" if offset_seconds >= 0 else "-" @@ -65,6 +67,5 @@ def parse_datetime_with_utc_offset( def datetime_timezone_specifier(value: pendulum.DateTime, *, context: str) -> str: """Return an IANA timezone name or an explicit offset for an aware time.""" - aware = require_aware_datetime(value, context=context) return aware.timezone_name or aware.format("Z") diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index 936edd69..760c6229 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -1,3 +1,5 @@ +"""Weather provider adapters and provider-neutral context conversion.""" + from __future__ import annotations import base64 @@ -39,23 +41,32 @@ class _OpenMeteoResponseError(ValueError): class WeatherContextProvider(Protocol): - async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: ... + """Fetch provider-neutral weather context for coordinates.""" + + async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: + """Fetch the current weather context for a location.""" + ... @runtime_checkable class DatedWeatherContextProvider(Protocol): + """Fetch weather context for an explicit forecast date.""" + async def fetch_for_date( self, latitude: float, longitude: float, forecast_date: pendulum.Date, - ) -> WeatherContextSnapshot: ... + ) -> WeatherContextSnapshot: + """Fetch weather context for a location and forecast date.""" + ... class LoggedWeatherContextProvider: """Record a non-sensitive history of logical weather provider calls.""" def __init__(self, name: str, provider: WeatherContextProvider) -> None: + """Wrap a named provider with non-sensitive timing and outcome logs.""" self._name = name self._provider = provider @@ -66,6 +77,7 @@ async def fetch( *, forecast_date: pendulum.Date | None = None, ) -> WeatherContextSnapshot: + """Fetch weather context while recording non-sensitive call metadata.""" started_at = time.monotonic() _LOGGER.info("Weather API call started provider=%s", self._name) try: @@ -101,14 +113,21 @@ async def fetch_for_date( longitude: float, forecast_date: pendulum.Date, ) -> WeatherContextSnapshot: + """Fetch logged weather context for an explicit date.""" return await self.fetch(latitude, longitude, forecast_date=forecast_date) class QWeatherAuthenticator(Protocol): - def authorization_header(self) -> str: ... + """Generate an authorization header for QWeather requests.""" + + def authorization_header(self) -> str: + """Return a fresh QWeather authorization header.""" + ... class QWeatherJWTAuthenticator: + """Issue short-lived QWeather EdDSA JWT credentials.""" + def __init__( self, *, @@ -118,6 +137,7 @@ def __init__( lifetime_seconds: int = 900, clock: Callable[[], float] = time.time, ) -> None: + """Validate and retain credentials for short-lived QWeather JWTs.""" if not 1 <= lifetime_seconds <= 86_400: raise ValueError("QWeather JWT lifetime must be between 1 and 86400 seconds") self._project_id = project_id @@ -130,6 +150,7 @@ def __init__( self._clock = clock def authorization_header(self) -> str: + """Create a Bearer header containing a fresh short-lived JWT.""" issued_at = int(self._clock()) - 30 token = jwt.encode( { @@ -145,6 +166,8 @@ def authorization_header(self) -> str: class QWeatherProvider: + """Fetch weather, lifestyle, and air-quality context from QWeather.""" + def __init__( self, client: httpx.AsyncClient, @@ -153,6 +176,7 @@ def __init__( base_url: str, index_types: tuple[str, ...] | None = None, ) -> None: + """Configure authenticated QWeather access and lifestyle index selection.""" self._client = client self._authenticator = authenticator self._base_url = base_url @@ -168,6 +192,7 @@ async def fetch( *, forecast_date: pendulum.Date | None = None, ) -> WeatherContextSnapshot: + """Fetch and normalize QWeather context for a location.""" operation = "authentication" try: headers = {"Authorization": self._authenticator.authorization_header()} @@ -276,6 +301,7 @@ async def fetch_for_date( longitude: float, forecast_date: pendulum.Date, ) -> WeatherContextSnapshot: + """Fetch QWeather context for an explicit forecast date.""" return await self.fetch(latitude, longitude, forecast_date=forecast_date) async def _fetch_air_quality( @@ -323,6 +349,8 @@ async def _fetch_air_quality( class OpenMeteoProvider: + """Fetch global weather, air-quality, and pollen context from Open-Meteo.""" + def __init__( self, client: httpx.AsyncClient, @@ -331,6 +359,7 @@ def __init__( air_quality_base_url: str = "https://air-quality-api.open-meteo.com", api_key: str | None = None, ) -> None: + """Configure Open-Meteo weather and air-quality endpoints.""" self._client = client self._weather_base_url = weather_base_url self._air_quality_base_url = air_quality_base_url @@ -343,6 +372,7 @@ async def fetch( *, forecast_date: pendulum.Date | None = None, ) -> WeatherContextSnapshot: + """Fetch and normalize Open-Meteo context for a location.""" params: dict[str, str | int | float] = { "latitude": latitude, "longitude": longitude, @@ -417,6 +447,7 @@ async def fetch_for_date( longitude: float, forecast_date: pendulum.Date, ) -> WeatherContextSnapshot: + """Fetch Open-Meteo context for an explicit forecast date.""" return await self.fetch(latitude, longitude, forecast_date=forecast_date) async def _fetch_air_quality_and_allergen( @@ -547,7 +578,10 @@ def _parse_allergen( class FallbackWeatherContextProvider: + """Try weather providers in configured priority order.""" + def __init__(self, *providers: WeatherContextProvider) -> None: + """Require and retain weather providers in fallback priority order.""" if not providers: raise ValueError("At least one weather context provider is required") self._providers = providers @@ -559,6 +593,7 @@ async def fetch( *, forecast_date: pendulum.Date | None = None, ) -> WeatherContextSnapshot: + """Return current context from the first successful provider.""" for provider in self._providers[:-1]: try: return await fetch_weather_context(provider, latitude, longitude, forecast_date) @@ -572,6 +607,7 @@ async def fetch_for_date( longitude: float, forecast_date: pendulum.Date, ) -> WeatherContextSnapshot: + """Return dated context from the first compatible provider.""" return await self.fetch(latitude, longitude, forecast_date=forecast_date) @@ -581,6 +617,7 @@ async def fetch_weather_context( longitude: float, forecast_date: pendulum.Date | None, ) -> WeatherContextSnapshot: + """Fetch current or dated context through a provider capability boundary.""" if forecast_date is None: return await provider.fetch(latitude, longitude) if not isinstance(provider, DatedWeatherContextProvider): @@ -605,11 +642,14 @@ def _safe_api_status(value: object) -> str: class AirQualitySupplementingWeatherProvider: + """Fill missing weather-provider air quality from a dedicated provider.""" + def __init__( self, weather_provider: WeatherContextProvider, air_quality_provider: AirQualityProvider | None, ) -> None: + """Compose weather context with an optional air-quality fallback.""" self._weather_provider = weather_provider self._air_quality_provider = air_quality_provider @@ -620,6 +660,7 @@ async def fetch( *, forecast_date: pendulum.Date | None = None, ) -> WeatherContextSnapshot: + """Fetch current weather context and supplement missing air quality.""" snapshot = await fetch_weather_context(self._weather_provider, latitude, longitude, forecast_date) if snapshot.air_quality is not None: return snapshot @@ -644,10 +685,12 @@ async def fetch_for_date( longitude: float, forecast_date: pendulum.Date, ) -> WeatherContextSnapshot: + """Fetch dated weather context and supplement missing air quality.""" return await self.fetch(latitude, longitude, forecast_date=forecast_date) def snapshot_to_documents(snapshot: WeatherContextSnapshot) -> tuple[SourceDocument, ...]: + """Convert a weather snapshot into citable LLM source documents.""" weather = "\n".join(f"- {item}" for item in snapshot.weather_forecast) lifestyle = "\n".join(f"- {item}" for item in snapshot.lifestyle_advice) or "不可用" documents = [