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
75 changes: 0 additions & 75 deletions .github/workflows/auto-merge-on-release.yml

This file was deleted.

10 changes: 0 additions & 10 deletions .github/workflows/ci-cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,3 @@ jobs:
permissions:
contents: write
secrets: inherit

trigger-auto-merge:
needs: [release, docker-build-and-image-scan]
uses: milsman2/python-app-template/.github/workflows/auto-merge-on-release.yml@main
permissions:
contents: write
pull-requests: write
with:
head_branch: ${{ github.ref_name }}
secrets: inherit
46 changes: 40 additions & 6 deletions src/sample_python_app/app/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,23 @@
fetch_astronomical_data_from_api,
fetch_hourly_forecast_from_api,
)
from sample_python_app.services.http_client import CustomHTTPClient
from sample_python_app.ui import display_astronomical_data

logger = setup_logger("normal")


class AstroFetcher:
"""Fetches astronomical data and displays only once per day."""
"""Fetches astronomical data and displays only once per day.

def __init__(self) -> None:
"""Initialize the AstroFetcher with no last displayed day."""
Accepts an `HTTPClient` to use for all outbound requests so the
runner can own the client's lifecycle and tests can inject mocks.
"""

def __init__(self, client: CustomHTTPClient) -> None:
"""Initialize the AstroFetcher with an optional HTTP client."""
self._last_displayed_day: str | None = None
self.client = client

def fetch(self, *, exit_on_error: bool = True) -> None:
"""Fetch astronomical data and display if not already displayed today."""
Expand All @@ -41,8 +47,8 @@ def fetch(self, *, exit_on_error: bool = True) -> None:
logger.info(f"Using latitude={lat} longitude={lon}")
start = time.time()
try:
astro = fetch_astronomical_data_from_api(lat, lon)
forecast = fetch_hourly_forecast_from_api(lat, lon)
astro = fetch_astronomical_data_from_api(lat, lon, client=self.client)
forecast = fetch_hourly_forecast_from_api(lat, lon, client=self.client)
FETCH_COUNTER.inc()
except (
httpx.HTTPStatusError,
Expand All @@ -64,6 +70,18 @@ def reset_display(self):
"""Reset the last displayed day so display will occur again."""
self._last_displayed_day = None

def close(self) -> None:
"""Close the associated HTTP client if present.

This allows the runner to delegate shutdown responsibility to the
fetcher when it owns the client's lifecycle.
"""
if hasattr(self, "client") and self.client is not None:
try:
self.client.close()
except Exception:
logger.exception("Error closing HTTP client in AstroFetcher")

def _handle_fetch_error(self, exc: Exception, exit_on_error: bool) -> None:
FETCH_ERRORS.inc()
if isinstance(exc, httpx.HTTPStatusError):
Expand All @@ -81,4 +99,20 @@ def _handle_fetch_error(self, exc: Exception, exit_on_error: bool) -> None:
raise AppError(str(exc)) from exc


fetcher = AstroFetcher()
runner_client = CustomHTTPClient(
headers=weather_settings.WEATHER_HEADERS,
base_url=weather_settings.WEATHER_API_BASE,
)
fetcher = AstroFetcher(client=runner_client)


def shutdown_runner() -> None:
"""Shutdown helper to close long-lived resources owned by the runner.

Call this from application shutdown hooks to ensure the HTTP client is
properly closed and connections are released.
"""
try:
fetcher.close()
except Exception:
logger.exception("Error during runner shutdown")
2 changes: 2 additions & 0 deletions src/sample_python_app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class WeatherSettings(BaseSettings):
"""Weather-related settings."""

LOCATION: Coordinate = Coordinate(Latitude(29.8469), Longitude(-95.4689))
WEATHER_API_BASE: str = "https://api.weather.gov"
WEATHER_HEADERS: dict = {"User-Agent": "(milsman2, milsman2@gmail.com)"}

model_config = SettingsConfigDict(
env_file=".env",
Expand Down
96 changes: 62 additions & 34 deletions src/sample_python_app/services/data_loader.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,58 @@
"""Handles loading and validating weather.gov astronomical data from file and API."""

import httpx
from __future__ import annotations

from pydantic import ValidationError

from sample_python_app.core import setup_logger
from sample_python_app.core import setup_logger, weather_settings
from sample_python_app.models import (
AstronomicalData,
ForecastFeature,
WeatherGovFeature,
)
from sample_python_app.services.http_client import CustomHTTPClient

weather_client = CustomHTTPClient(
headers=weather_settings.WEATHER_HEADERS, base_url=weather_settings.WEATHER_API_BASE
)


def resolve_point_metadata(
lat: float, lon: float, client: CustomHTTPClient
) -> WeatherGovFeature:
"""Resolve and return the WeatherGovFeature for the given lat/lon.

def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData:
This calls the `/points/{lat},{lon}` endpoint and returns the
validated `WeatherGovFeature` model. Other functions can call this
to discover grid coordinates or forecast URLs.
"""
logger = setup_logger(mode="silent")
api_client = client or weather_client
points_path = f"/points/{lat},{lon}"
logger.info(
"Resolving point metadata for coordinates %s,%s: %s", lat, lon, points_path
)
points_data = api_client.get_json(points_path)
point_model = WeatherGovFeature.model_validate(points_data)
logger.info(
"Resolved point metadata: grid=%s x=%s y=%s",
point_model.properties.grid_id,
point_model.properties.grid_x,
point_model.properties.grid_y,
)
return point_model


def fetch_astronomical_data_from_api(
lat: float, lon: float, client: CustomHTTPClient
) -> AstronomicalData:
"""Fetch and validate astronomical data from weather.gov API for given coordinates.

Args:
lat (float): Latitude of the location.
lon (float): Longitude of the location.
client (HTTPClient, optional): HTTP client to use for requests. If
not provided the module-level `weather_client` will be used.

Returns:
AstronomicalData: Validated astronomical data from API response.
Expand All @@ -27,24 +63,23 @@ def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData

"""
logger = setup_logger(mode="silent")
url = f"https://api.weather.gov/points/{lat},{lon}"
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
logger.info(f"Fetching astronomical data from URL: {url}")
logger.info(f"Request headers: {headers}")
points_path = f"/points/{lat},{lon}"
client = client or weather_client
logger.info("Fetching astronomical data from: %s", points_path)
try:
response = httpx.get(url, headers=headers)
response.raise_for_status()
data = response.json()
data = client.get_json(points_path)
model = WeatherGovFeature.model_validate(data)
astro = model.properties.astronomical_data
logger.info("AstronomicalData fetched and validated from API.")
return astro
except ValidationError as e:
logger.error(f"Data validation error: {e}")
logger.error("Data validation error: %s", e)
raise


def fetch_hourly_forecast_from_api(lat: float, lon: float) -> ForecastFeature:
def fetch_hourly_forecast_from_api(
lat: float, lon: float, client: CustomHTTPClient
) -> ForecastFeature:
"""Fetch hourly forecast Feature for the given coordinates.

This function first queries the `/points/{lat},{lon}` endpoint to resolve
Expand All @@ -54,6 +89,8 @@ def fetch_hourly_forecast_from_api(lat: float, lon: float) -> ForecastFeature:
Args:
lat (float): Latitude of the location.
lon (float): Longitude of the location.
client (HTTPClient, optional): HTTP client to use for requests. If
not provided the module-level `weather_client` will be used.

Returns:
ForecastFeature: Parsed and validated hourly forecast feature.
Expand All @@ -64,30 +101,24 @@ def fetch_hourly_forecast_from_api(lat: float, lon: float) -> ForecastFeature:

"""
logger = setup_logger(mode="silent")
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
client = client or weather_client

# Resolve point metadata to find the hourly forecast URL
points_url = f"https://api.weather.gov/points/{lat},{lon}"
logger.info(f"Resolving grid for coordinates {lat},{lon}: {points_url}")
resp = httpx.get(points_url, headers=headers)
resp.raise_for_status()
points_data = resp.json()

point_model = WeatherGovFeature.model_validate(points_data)
point_model = resolve_point_metadata(lat, lon, client=client)
forecast_url = point_model.properties.forecast_hourly
logger.info(f"Fetching hourly forecast from: {forecast_url}")
logger.info("Fetching hourly forecast from: %s", forecast_url)

resp2 = httpx.get(forecast_url, headers=headers)
resp2.raise_for_status()
forecast_data = resp2.json()
# forecast_url is often an absolute URL returned by the API; httpx
# client with a base_url accepts absolute URLs as well, so pass through.
forecast_data = client.get_json(forecast_url)

forecast_model = ForecastFeature.model_validate(forecast_data)
logger.info("Hourly forecast fetched and validated.")
return forecast_model


def fetch_hourly_forecast_by_grid(
grid_id: str, grid_x: int, grid_y: int
grid_id: str, grid_x: int, grid_y: int, client: CustomHTTPClient
) -> ForecastFeature:
"""Fetch hourly forecast Feature directly from grid coordinates.

Expand All @@ -99,22 +130,19 @@ def fetch_hourly_forecast_by_grid(
grid_id: Grid identifier (e.g. "HGX").
grid_x: Grid X coordinate (e.g. 59).
grid_y: Grid Y coordinate (e.g. 98).
client (HTTPClient, optional): HTTP client to use for requests. If
not provided the module-level `weather_client` will be used.

Returns:
ForecastFeature: Parsed and validated hourly forecast feature.

"""
logger = setup_logger(mode="silent")
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
client = client or weather_client

url = (
"https://api.weather.gov/gridpoints/ "
f"{grid_id}/{grid_x},{grid_y}/forecast/hourly"
)
logger.info(f"Fetching hourly forecast by grid from: {url}")
resp = httpx.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
path = f"/gridpoints/{grid_id}/{grid_x},{grid_y}/forecast/hourly"
logger.info("Fetching hourly forecast by grid from: %s", path)
data = client.get_json(path)

model = ForecastFeature.model_validate(data)
logger.info("Hourly forecast (grid) fetched and validated.")
Expand Down
Loading
Loading