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: 3 additions & 6 deletions src/sample_python_app/app/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,17 @@

import socket

from loguru import logger
from prometheus_client import start_http_server

from sample_python_app.core.logging import setup_logger

logger = setup_logger("normal")


def start_metrics_server(port: int) -> None:
"""Start the Prometheus metrics server on the specified port."""
if _port_in_use(port):
logger.error(f"Port {port} already in use; metrics disabled")
logger.error("Port {} already in use; metrics disabled", port)
return

logger.info(f"Starting Prometheus metrics on 0.0.0.0:{port}")
logger.info("Starting Prometheus metrics on 0.0.0.0:{}", port)
start_http_server(port, addr="0.0.0.0")


Expand Down
13 changes: 7 additions & 6 deletions src/sample_python_app/app/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@
import time
from datetime import date

from loguru import logger

from sample_python_app.core import (
FETCH_COUNTER,
FETCH_DURATION,
setup_logger,
weather_settings,
)
from sample_python_app.exceptions import AppError
from sample_python_app.services import (
CustomHTTPClient,
fetch_astronomical_data_from_api,
fetch_hourly_forecast_from_api,
set_next_hour_forecast_temperature,
)
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.
Expand All @@ -40,16 +40,17 @@ def fetch(self, *, exit_on_error: bool = True) -> None:
lat = weather_settings.LOCATION.latitude
lon = weather_settings.LOCATION.longitude

logger.info("Using latitude=%s longitude=%s", lat, lon)
logger.info("Using latitude={} longitude={}", lat, lon)

start = time.time()

try:
astro = fetch_astronomical_data_from_api(lat, lon, client=self.client)
forecast = fetch_hourly_forecast_from_api(lat, lon, client=self.client)
set_next_hour_forecast_temperature(forecast, location=f"{lat},{lon}")
FETCH_COUNTER.inc()
except AppError as exc:
logger.error("Weather fetch failed: %s", exc)
logger.error("Weather fetch failed: {}", exc)
if exit_on_error:
raise SystemExit(1) from None
return
Expand Down
2 changes: 0 additions & 2 deletions src/sample_python_app/app/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@
from loguru import logger

from sample_python_app.app.runner import fetcher
from sample_python_app.core.logging import setup_logger


def start_scheduler(test_mode: bool = False) -> None:
"""Start the scheduler to fetch astronomical data every 24 hours."""
setup_logger("normal")
scheduler_logger = logger.bind(component="scheduler")

if test_mode:
Expand Down
2 changes: 2 additions & 0 deletions src/sample_python_app/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
FETCH_COUNTER,
FETCH_DURATION,
FETCH_ERRORS,
FORECAST_NEXT_HOUR_TEMPERATURE,
HTTP_REQUEST_DURATION,
HTTP_REQUEST_EXCEPTIONS,
HTTP_REQUESTS,
Expand All @@ -22,4 +23,5 @@
"HTTP_REQUESTS",
"HTTP_REQUEST_EXCEPTIONS",
"HTTP_REQUEST_DURATION",
"FORECAST_NEXT_HOUR_TEMPERATURE",
]
1 change: 1 addition & 0 deletions src/sample_python_app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class Settings(BaseSettings):
TIMEZONE: str = "America/Chicago"
PROMETHEUS_METRICS_PORT: int = 8000
GF_SECURITY_ADMIN_PASSWORD: SecretStr = SecretStr("admin")
LOG_LEVEL: str = "INFO"

model_config = SettingsConfigDict(
env_file=".env",
Expand Down
30 changes: 13 additions & 17 deletions src/sample_python_app/core/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,45 +12,41 @@
)


def setup_logger(mode="normal"):
def setup_logger(level: str = "INFO"):
"""Configure and return a Loguru logger instance.

Args:
mode (str, optional): Logging mode. "normal" for console and file logging,
"silent" for file logging only. Defaults to "normal".
level (str, optional): Logging level for file outputs (e.g., "INFO", "DEBUG",
"ERROR"). Defaults to "INFO".

Returns:
logger: Configured Loguru logger instance.

"""
logger.remove()
if mode == "silent":
# Log errors to the console even in silent mode
logger.add(sys.stdout, format=log_format, level="ERROR")
if level.upper() == "SILENT":
# Only add file handler at ERROR level (or could be level, but no stdout)
logger.add(
"app.log",
format=log_format,
level="DEBUG",
level="ERROR",
rotation="1 MB",
retention="10 days",
compression="zip",
)
elif mode == "debug":
logger.add(sys.stdout, format=log_format, level="DEBUG")
else:
logger.add(
"app.log",
sys.stdout,
format=log_format,
level="DEBUG",
rotation="1 MB",
retention="10 days",
compression="zip",
level=level,
backtrace=True,
diagnose=True,
enqueue=True,
)
else:
logger.add(sys.stdout, format=log_format, level="INFO")
logger.add(
"app.log",
format=log_format,
level="DEBUG",
level=level,
rotation="1 MB",
retention="10 days",
compression="zip",
Expand Down
9 changes: 8 additions & 1 deletion src/sample_python_app/core/metrics.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Prometheus metrics for astronomical data fetches."""

from prometheus_client import Counter, Histogram, start_http_server
from prometheus_client import Counter, Gauge, Histogram, start_http_server

FETCH_COUNTER = Counter(
"fetch_all_total",
Expand Down Expand Up @@ -29,12 +29,19 @@
"HTTP request latency in seconds",
["method", "host", "path"],
)
FORECAST_NEXT_HOUR_TEMPERATURE = Gauge(
"forecast_next_hour_temperature",
"Forecast temperature for 1 hour from now (°F)",
["location"],
)

__all__ = [
"FETCH_COUNTER",
"FETCH_ERRORS",
"FETCH_DURATION",
"HTTP_REQUESTS",
"HTTP_REQUEST_EXCEPTIONS",
"HTTP_REQUEST_DURATION",
"FORECAST_NEXT_HOUR_TEMPERATURE",
"start_http_server",
]
4 changes: 3 additions & 1 deletion src/sample_python_app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"""

from sample_python_app.app import start_metrics_server, start_scheduler
from sample_python_app.core import settings
from sample_python_app.core import settings, setup_logger

setup_logger(settings.LOG_LEVEL)


def run_app() -> None:
Expand Down
6 changes: 4 additions & 2 deletions src/sample_python_app/scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
import sys
from pathlib import Path

from loguru import logger

from sample_python_app.core.logging import setup_logger

ROOT = Path(__file__).resolve().parents[2]

setup_logger("DEBUG")


def _run(cmd: list[str]) -> None:
logger = setup_logger("normal")
logger.info(f"Running: {' '.join(cmd)}")
subprocess.check_call(cmd, cwd=str(ROOT))

Expand All @@ -33,5 +36,4 @@ def run_checks() -> None:
_run([py, "-m", "ruff", "check", ".", "--exit-zero"])
_run([py, "-m", "coverage", "run", "-m", "pytest"])

logger = setup_logger("normal")
logger.info("All checks completed.")
4 changes: 4 additions & 0 deletions src/sample_python_app/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
fetch_astronomical_data_from_api,
fetch_hourly_forecast_by_grid,
fetch_hourly_forecast_from_api,
set_next_hour_forecast_temperature,
)
from sample_python_app.services.http_client import CustomHTTPClient

__all__ = [
"fetch_astronomical_data_from_api",
"fetch_hourly_forecast_from_api",
"fetch_hourly_forecast_by_grid",
"set_next_hour_forecast_temperature",
"CustomHTTPClient",
]
36 changes: 26 additions & 10 deletions src/sample_python_app/services/data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@

from __future__ import annotations

from datetime import UTC, datetime, timedelta

from pydantic import ValidationError

from sample_python_app.core import setup_logger, weather_settings
from sample_python_app.exceptions import NetworkError, ServiceError
from sample_python_app.exceptions.custom import AppError
from sample_python_app.core import (
FORECAST_NEXT_HOUR_TEMPERATURE,
weather_settings,
)
from sample_python_app.exceptions import AppError, NetworkError, ServiceError
from sample_python_app.models import (
AstronomicalData,
ForecastFeature,
WeatherGovFeature,
)
from sample_python_app.services.http_client import CustomHTTPClient

logger = setup_logger(mode="silent")

weather_client = CustomHTTPClient(
headers=weather_settings.WEATHER_HEADERS,
base_url=weather_settings.WEATHER_API_BASE,
Expand All @@ -32,10 +34,8 @@ def resolve_point_metadata(
try:
data = client.get_json(path)
return WeatherGovFeature.model_validate(data)

except (NetworkError, ServiceError) as exc:
raise AppError("Failed to resolve point metadata") from exc

except ValidationError as exc:
raise AppError("Invalid metadata returned from weather service") from exc

Expand All @@ -53,10 +53,8 @@ def fetch_astronomical_data_from_api(
data = client.get_json(path)
model = WeatherGovFeature.model_validate(data)
return model.properties.astronomical_data

except (NetworkError, ServiceError) as exc:
raise AppError("Weather API request failed") from exc

except ValidationError as exc:
raise AppError(
"Invalid astronomical data returned from weather service"
Expand Down Expand Up @@ -90,7 +88,6 @@ def fetch_hourly_forecast_by_grid(
) -> ForecastFeature:
"""Fetch hourly forecast using grid coordinates."""
client = client or weather_client

path = f"/gridpoints/{grid_id}/{grid_x},{grid_y}/forecast/hourly"

try:
Expand All @@ -100,3 +97,22 @@ def fetch_hourly_forecast_by_grid(
raise AppError("Weather API request failed") from exc
except ValidationError as exc:
raise AppError("Invalid forecast returned from weather service") from exc


def set_next_hour_forecast_temperature(
forecast: ForecastFeature, location: str = "default"
) -> None:
"""Set the Prometheus metric for the 1-hour-ahead forecast temperature."""
now = datetime.now(UTC)
one_hour_later = now + timedelta(hours=1)
next_period = None
for period in forecast.properties.periods:
if period.start_time >= one_hour_later:
next_period = period
break
if next_period and next_period.temperature is not None:
FORECAST_NEXT_HOUR_TEMPERATURE.labels(location=location).set(
next_period.temperature
)
else:
FORECAST_NEXT_HOUR_TEMPERATURE.labels(location=location).set(float("nan"))
27 changes: 20 additions & 7 deletions src/sample_python_app/services/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
from __future__ import annotations

import time
from types import TracebackType
from typing import Any

import httpx
from loguru import logger

from sample_python_app.core import (
HTTP_REQUEST_DURATION,
HTTP_REQUEST_EXCEPTIONS,
HTTP_REQUESTS,
)
from sample_python_app.core.logging import logger
from sample_python_app.exceptions import HTTPTimeoutError, NetworkError, ServiceError

JSONType = dict[str, object] | list[dict[str, object]]
Expand Down Expand Up @@ -79,7 +80,6 @@ def request_json(self, method: str, url: str, **kwargs: Any) -> JSONType:
host=self._host_label,
path=path,
).observe(duration)

HTTP_REQUESTS.labels(
method=method,
host=self._host_label,
Expand All @@ -89,13 +89,21 @@ def request_json(self, method: str, url: str, **kwargs: Any) -> JSONType:

full_url = str(response.request.url) if hasattr(response, "request") else url
logger.info(
f"HTTP {method} {full_url} responded with status code "
f"{response.status_code}"
"HTTP {method} {full_url} responded with status code "
"{status_code} in {duration:.2f}s",
method=method,
full_url=full_url,
status_code=response.status_code,
duration=duration,
)

if not response.is_success:
logger.error(f"HTTP error status code: {response.status_code}")
logger.debug(f"HTTP error response body: {response.text}")
logger.error(
"HTTP error status code: {}," "response body: {}",
response.status_code,
response.text,
)
logger.debug("HTTP error response body: {}", response.text)
raise ServiceError(
status_code=response.status_code,
body=response.text,
Expand All @@ -121,6 +129,11 @@ def __enter__(self) -> CustomHTTPClient:
"""Support context manager entry."""
return self

def __exit__(self, *args: Any) -> None:
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
"""Support context manager exit by closing the client."""
self.close()
Loading
Loading