Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
14 changes: 13 additions & 1 deletion weather_briefing/air_quality.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Air-quality providers, normalization, and health guidance."""

from __future__ import annotations

from functools import cache
Expand All @@ -17,22 +19,29 @@ 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,
*,
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
Expand All @@ -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}/",
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions weather_briefing/allergen.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Allergen reference data, guidance, and source conversion."""

from __future__ import annotations

from functools import cache
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions weather_briefing/api_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Privacy-preserving HTTP client instrumentation."""

from __future__ import annotations

import logging
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions weather_briefing/cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Command-line composition, scheduling, and one-shot execution."""

from __future__ import annotations

import argparse
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions weather_briefing/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Runtime configuration parsing and validation."""

from __future__ import annotations

import json
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion weather_briefing/content_cleaners.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Composable HTML-to-text content cleaning rules."""

from __future__ import annotations

import re
Expand All @@ -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)):
Expand Down
37 changes: 35 additions & 2 deletions weather_briefing/geocoding.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Location geocoding, fallback selection, and local caching."""

from __future__ import annotations

import asyncio
Expand All @@ -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
Expand All @@ -52,23 +62,28 @@ 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,
*,
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,
Expand Down Expand Up @@ -122,13 +137,16 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation:


class NominatimGeocodingProvider:
"""Resolve and reverse-resolve locations through Nominatim."""

def __init__(
self,
client: httpx.AsyncClient,
*,
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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -288,21 +316,26 @@ async def geocode(self, location: LocationSpec) -> ResolvedLocation:


class CachedLocationResolver:
"""Resolve complete locations while caching provider-derived metadata."""

def __init__(
self,
provider: GeocodingProvider,
cache_path: Path,
*,
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:
Expand Down
Loading
Loading