Skip to content

Commit b1f7a00

Browse files
authored
feat: Refactor HTTP client usage and remove auto-merge workflow
feat: Refactor HTTP client usage and remove auto-merge workflow
2 parents f8867ce + 8480bb9 commit b1f7a00

8 files changed

Lines changed: 200 additions & 132 deletions

File tree

.github/workflows/auto-merge-on-release.yml

Lines changed: 0 additions & 75 deletions
This file was deleted.

.github/workflows/ci-cd.yaml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,3 @@ jobs:
4242
permissions:
4343
contents: write
4444
secrets: inherit
45-
46-
trigger-auto-merge:
47-
needs: [release, docker-build-and-image-scan]
48-
uses: milsman2/python-app-template/.github/workflows/auto-merge-on-release.yml@main
49-
permissions:
50-
contents: write
51-
pull-requests: write
52-
with:
53-
head_branch: ${{ github.ref_name }}
54-
secrets: inherit

src/sample_python_app/app/runner.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,23 @@
2222
fetch_astronomical_data_from_api,
2323
fetch_hourly_forecast_from_api,
2424
)
25+
from sample_python_app.services.http_client import CustomHTTPClient
2526
from sample_python_app.ui import display_astronomical_data
2627

2728
logger = setup_logger("normal")
2829

2930

3031
class AstroFetcher:
31-
"""Fetches astronomical data and displays only once per day."""
32+
"""Fetches astronomical data and displays only once per day.
3233
33-
def __init__(self) -> None:
34-
"""Initialize the AstroFetcher with no last displayed day."""
34+
Accepts an `HTTPClient` to use for all outbound requests so the
35+
runner can own the client's lifecycle and tests can inject mocks.
36+
"""
37+
38+
def __init__(self, client: CustomHTTPClient) -> None:
39+
"""Initialize the AstroFetcher with an optional HTTP client."""
3540
self._last_displayed_day: str | None = None
41+
self.client = client
3642

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

73+
def close(self) -> None:
74+
"""Close the associated HTTP client if present.
75+
76+
This allows the runner to delegate shutdown responsibility to the
77+
fetcher when it owns the client's lifecycle.
78+
"""
79+
if hasattr(self, "client") and self.client is not None:
80+
try:
81+
self.client.close()
82+
except Exception:
83+
logger.exception("Error closing HTTP client in AstroFetcher")
84+
6785
def _handle_fetch_error(self, exc: Exception, exit_on_error: bool) -> None:
6886
FETCH_ERRORS.inc()
6987
if isinstance(exc, httpx.HTTPStatusError):
@@ -81,4 +99,20 @@ def _handle_fetch_error(self, exc: Exception, exit_on_error: bool) -> None:
8199
raise AppError(str(exc)) from exc
82100

83101

84-
fetcher = AstroFetcher()
102+
runner_client = CustomHTTPClient(
103+
headers=weather_settings.WEATHER_HEADERS,
104+
base_url=weather_settings.WEATHER_API_BASE,
105+
)
106+
fetcher = AstroFetcher(client=runner_client)
107+
108+
109+
def shutdown_runner() -> None:
110+
"""Shutdown helper to close long-lived resources owned by the runner.
111+
112+
Call this from application shutdown hooks to ensure the HTTP client is
113+
properly closed and connections are released.
114+
"""
115+
try:
116+
fetcher.close()
117+
except Exception:
118+
logger.exception("Error during runner shutdown")

src/sample_python_app/core/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ class WeatherSettings(BaseSettings):
1111
"""Weather-related settings."""
1212

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

1517
model_config = SettingsConfigDict(
1618
env_file=".env",

src/sample_python_app/services/data_loader.py

Lines changed: 62 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,58 @@
11
"""Handles loading and validating weather.gov astronomical data from file and API."""
22

3-
import httpx
3+
from __future__ import annotations
4+
45
from pydantic import ValidationError
56

6-
from sample_python_app.core import setup_logger
7+
from sample_python_app.core import setup_logger, weather_settings
78
from sample_python_app.models import (
89
AstronomicalData,
910
ForecastFeature,
1011
WeatherGovFeature,
1112
)
13+
from sample_python_app.services.http_client import CustomHTTPClient
14+
15+
weather_client = CustomHTTPClient(
16+
headers=weather_settings.WEATHER_HEADERS, base_url=weather_settings.WEATHER_API_BASE
17+
)
18+
1219

20+
def resolve_point_metadata(
21+
lat: float, lon: float, client: CustomHTTPClient
22+
) -> WeatherGovFeature:
23+
"""Resolve and return the WeatherGovFeature for the given lat/lon.
1324
14-
def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData:
25+
This calls the `/points/{lat},{lon}` endpoint and returns the
26+
validated `WeatherGovFeature` model. Other functions can call this
27+
to discover grid coordinates or forecast URLs.
28+
"""
29+
logger = setup_logger(mode="silent")
30+
api_client = client or weather_client
31+
points_path = f"/points/{lat},{lon}"
32+
logger.info(
33+
"Resolving point metadata for coordinates %s,%s: %s", lat, lon, points_path
34+
)
35+
points_data = api_client.get_json(points_path)
36+
point_model = WeatherGovFeature.model_validate(points_data)
37+
logger.info(
38+
"Resolved point metadata: grid=%s x=%s y=%s",
39+
point_model.properties.grid_id,
40+
point_model.properties.grid_x,
41+
point_model.properties.grid_y,
42+
)
43+
return point_model
44+
45+
46+
def fetch_astronomical_data_from_api(
47+
lat: float, lon: float, client: CustomHTTPClient
48+
) -> AstronomicalData:
1549
"""Fetch and validate astronomical data from weather.gov API for given coordinates.
1650
1751
Args:
1852
lat (float): Latitude of the location.
1953
lon (float): Longitude of the location.
54+
client (HTTPClient, optional): HTTP client to use for requests. If
55+
not provided the module-level `weather_client` will be used.
2056
2157
Returns:
2258
AstronomicalData: Validated astronomical data from API response.
@@ -27,24 +63,23 @@ def fetch_astronomical_data_from_api(lat: float, lon: float) -> AstronomicalData
2763
2864
"""
2965
logger = setup_logger(mode="silent")
30-
url = f"https://api.weather.gov/points/{lat},{lon}"
31-
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
32-
logger.info(f"Fetching astronomical data from URL: {url}")
33-
logger.info(f"Request headers: {headers}")
66+
points_path = f"/points/{lat},{lon}"
67+
client = client or weather_client
68+
logger.info("Fetching astronomical data from: %s", points_path)
3469
try:
35-
response = httpx.get(url, headers=headers)
36-
response.raise_for_status()
37-
data = response.json()
70+
data = client.get_json(points_path)
3871
model = WeatherGovFeature.model_validate(data)
3972
astro = model.properties.astronomical_data
4073
logger.info("AstronomicalData fetched and validated from API.")
4174
return astro
4275
except ValidationError as e:
43-
logger.error(f"Data validation error: {e}")
76+
logger.error("Data validation error: %s", e)
4477
raise
4578

4679

47-
def fetch_hourly_forecast_from_api(lat: float, lon: float) -> ForecastFeature:
80+
def fetch_hourly_forecast_from_api(
81+
lat: float, lon: float, client: CustomHTTPClient
82+
) -> ForecastFeature:
4883
"""Fetch hourly forecast Feature for the given coordinates.
4984
5085
This function first queries the `/points/{lat},{lon}` endpoint to resolve
@@ -54,6 +89,8 @@ def fetch_hourly_forecast_from_api(lat: float, lon: float) -> ForecastFeature:
5489
Args:
5590
lat (float): Latitude of the location.
5691
lon (float): Longitude of the location.
92+
client (HTTPClient, optional): HTTP client to use for requests. If
93+
not provided the module-level `weather_client` will be used.
5794
5895
Returns:
5996
ForecastFeature: Parsed and validated hourly forecast feature.
@@ -64,30 +101,24 @@ def fetch_hourly_forecast_from_api(lat: float, lon: float) -> ForecastFeature:
64101
65102
"""
66103
logger = setup_logger(mode="silent")
67-
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
104+
client = client or weather_client
68105

69106
# Resolve point metadata to find the hourly forecast URL
70-
points_url = f"https://api.weather.gov/points/{lat},{lon}"
71-
logger.info(f"Resolving grid for coordinates {lat},{lon}: {points_url}")
72-
resp = httpx.get(points_url, headers=headers)
73-
resp.raise_for_status()
74-
points_data = resp.json()
75-
76-
point_model = WeatherGovFeature.model_validate(points_data)
107+
point_model = resolve_point_metadata(lat, lon, client=client)
77108
forecast_url = point_model.properties.forecast_hourly
78-
logger.info(f"Fetching hourly forecast from: {forecast_url}")
109+
logger.info("Fetching hourly forecast from: %s", forecast_url)
79110

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

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

88119

89120
def fetch_hourly_forecast_by_grid(
90-
grid_id: str, grid_x: int, grid_y: int
121+
grid_id: str, grid_x: int, grid_y: int, client: CustomHTTPClient
91122
) -> ForecastFeature:
92123
"""Fetch hourly forecast Feature directly from grid coordinates.
93124
@@ -99,22 +130,19 @@ def fetch_hourly_forecast_by_grid(
99130
grid_id: Grid identifier (e.g. "HGX").
100131
grid_x: Grid X coordinate (e.g. 59).
101132
grid_y: Grid Y coordinate (e.g. 98).
133+
client (HTTPClient, optional): HTTP client to use for requests. If
134+
not provided the module-level `weather_client` will be used.
102135
103136
Returns:
104137
ForecastFeature: Parsed and validated hourly forecast feature.
105138
106139
"""
107140
logger = setup_logger(mode="silent")
108-
headers = {"User-Agent": "(milsman2, milsman2@gmail.com)"}
141+
client = client or weather_client
109142

110-
url = (
111-
"https://api.weather.gov/gridpoints/ "
112-
f"{grid_id}/{grid_x},{grid_y}/forecast/hourly"
113-
)
114-
logger.info(f"Fetching hourly forecast by grid from: {url}")
115-
resp = httpx.get(url, headers=headers)
116-
resp.raise_for_status()
117-
data = resp.json()
143+
path = f"/gridpoints/{grid_id}/{grid_x},{grid_y}/forecast/hourly"
144+
logger.info("Fetching hourly forecast by grid from: %s", path)
145+
data = client.get_json(path)
118146

119147
model = ForecastFeature.model_validate(data)
120148
logger.info("Hourly forecast (grid) fetched and validated.")

0 commit comments

Comments
 (0)