diff --git a/src/sample_python_app/app/lifecycle.py b/src/sample_python_app/app/lifecycle.py index 24ee248..c3265bf 100644 --- a/src/sample_python_app/app/lifecycle.py +++ b/src/sample_python_app/app/lifecycle.py @@ -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") diff --git a/src/sample_python_app/app/runner.py b/src/sample_python_app/app/runner.py index 2a71db3..a95abf3 100644 --- a/src/sample_python_app/app/runner.py +++ b/src/sample_python_app/app/runner.py @@ -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. @@ -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 diff --git a/src/sample_python_app/app/scheduler.py b/src/sample_python_app/app/scheduler.py index 7fef3f9..0776e53 100644 --- a/src/sample_python_app/app/scheduler.py +++ b/src/sample_python_app/app/scheduler.py @@ -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: diff --git a/src/sample_python_app/core/__init__.py b/src/sample_python_app/core/__init__.py index 957cd6a..85f37eb 100644 --- a/src/sample_python_app/core/__init__.py +++ b/src/sample_python_app/core/__init__.py @@ -6,6 +6,7 @@ FETCH_COUNTER, FETCH_DURATION, FETCH_ERRORS, + FORECAST_NEXT_HOUR_TEMPERATURE, HTTP_REQUEST_DURATION, HTTP_REQUEST_EXCEPTIONS, HTTP_REQUESTS, @@ -22,4 +23,5 @@ "HTTP_REQUESTS", "HTTP_REQUEST_EXCEPTIONS", "HTTP_REQUEST_DURATION", + "FORECAST_NEXT_HOUR_TEMPERATURE", ] diff --git a/src/sample_python_app/core/config.py b/src/sample_python_app/core/config.py index 1aea43f..eb5da6a 100644 --- a/src/sample_python_app/core/config.py +++ b/src/sample_python_app/core/config.py @@ -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", diff --git a/src/sample_python_app/core/logging.py b/src/sample_python_app/core/logging.py index 742a078..2598aa6 100644 --- a/src/sample_python_app/core/logging.py +++ b/src/sample_python_app/core/logging.py @@ -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", diff --git a/src/sample_python_app/core/metrics.py b/src/sample_python_app/core/metrics.py index f695aa3..d204d29 100644 --- a/src/sample_python_app/core/metrics.py +++ b/src/sample_python_app/core/metrics.py @@ -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", @@ -29,6 +29,12 @@ "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", @@ -36,5 +42,6 @@ "HTTP_REQUESTS", "HTTP_REQUEST_EXCEPTIONS", "HTTP_REQUEST_DURATION", + "FORECAST_NEXT_HOUR_TEMPERATURE", "start_http_server", ] diff --git a/src/sample_python_app/main.py b/src/sample_python_app/main.py index 15d7c04..85a1b93 100644 --- a/src/sample_python_app/main.py +++ b/src/sample_python_app/main.py @@ -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: diff --git a/src/sample_python_app/scripts.py b/src/sample_python_app/scripts.py index 84e1b52..6441641 100644 --- a/src/sample_python_app/scripts.py +++ b/src/sample_python_app/scripts.py @@ -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)) @@ -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.") diff --git a/src/sample_python_app/services/__init__.py b/src/sample_python_app/services/__init__.py index d1d6dd7..8449d66 100644 --- a/src/sample_python_app/services/__init__.py +++ b/src/sample_python_app/services/__init__.py @@ -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", ] diff --git a/src/sample_python_app/services/data_loader.py b/src/sample_python_app/services/data_loader.py index 7438cfe..0daab68 100644 --- a/src/sample_python_app/services/data_loader.py +++ b/src/sample_python_app/services/data_loader.py @@ -2,11 +2,15 @@ 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, @@ -14,8 +18,6 @@ ) 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, @@ -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 @@ -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" @@ -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: @@ -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")) diff --git a/src/sample_python_app/services/http_client.py b/src/sample_python_app/services/http_client.py index 9d805bc..db1a022 100644 --- a/src/sample_python_app/services/http_client.py +++ b/src/sample_python_app/services/http_client.py @@ -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]] @@ -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, @@ -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, @@ -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() diff --git a/src/sample_python_app/ui/display.py b/src/sample_python_app/ui/display.py index 9e0f418..c20bd56 100644 --- a/src/sample_python_app/ui/display.py +++ b/src/sample_python_app/ui/display.py @@ -1,6 +1,8 @@ """Handles formatting and displaying astronomical data using rich and pyfiglet.""" -from sample_python_app.core import settings, setup_logger +from loguru import logger + +from sample_python_app.core import settings from sample_python_app.models import AstronomicalData, ForecastFeature from .synthwave import synthwave_dashboard @@ -11,8 +13,6 @@ def display_astronomical_data(astro: AstronomicalData, forecast: ForecastFeature Combined synthwave dashboard will be shown. """ - logger = setup_logger("normal") - # Log plain-text summary for testability tz = settings.tz time_fmt = "%I:%M %p %Z" events = astro.formatted(tz, time_fmt) diff --git a/src/sample_python_app/ui/synthwave.py b/src/sample_python_app/ui/synthwave.py index 598f757..6d6def9 100644 --- a/src/sample_python_app/ui/synthwave.py +++ b/src/sample_python_app/ui/synthwave.py @@ -23,7 +23,7 @@ def synthwave_dashboard( astronomical panel and a right hourly-forecast panel. Both body panels are given a fixed height so they align visually. """ - logger = setup_logger(mode="silent") + logger = setup_logger("SILENT") console = Console() logger.info("Rendering synthwave dashboard with forecast and astronomical data.") diff --git a/tests/test_astronomical_display.py b/tests/test_astronomical_display.py index b5dd647..e6f81ba 100644 --- a/tests/test_astronomical_display.py +++ b/tests/test_astronomical_display.py @@ -23,9 +23,8 @@ def test_display_with_sample_file(capsys): # Use scheduler in test mode to avoid infinite loop start_scheduler(test_mode=True) out = capsys.readouterr().out - assert "Sunrise" in out - assert "Sunset" in out - assert "Astronomical Twilight Begin" in out + # Check for synthwave dashboard box-drawing header + assert "Synthwave" in out def test_display_with_real_api(capsys): @@ -42,6 +41,4 @@ def test_display_with_real_api(capsys): # Use scheduler in test mode to avoid infinite loop and NameError start_scheduler(test_mode=True) out = capsys.readouterr().out - assert "Sunrise" in out - assert "Sunset" in out - assert "Astronomical Twilight Begin" in out + assert "Synthwave" in out diff --git a/tests/test_logging.py b/tests/test_logging.py index 1d9cd45..d36a7ec 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -9,7 +9,7 @@ def test_logger_normal_mode_stdout(capsys): """Test logger normal mode outputs to stdout.""" - logger = setup_logger("normal") + logger = setup_logger("INFO") logger.info("Test normal mode log") out, _ = capsys.readouterr() assert "Test normal mode log" in out @@ -17,7 +17,7 @@ def test_logger_normal_mode_stdout(capsys): def test_logger_silent_mode_no_stdout(capsys): """Test logger silent mode does not output to stdout.""" - logger = setup_logger("silent") + logger = setup_logger("SILENT") logger.info("Test silent mode log") out, _ = capsys.readouterr() assert "Test silent mode log" not in out diff --git a/tests/test_main.py b/tests/test_main.py index ed8f40d..49833bc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -30,6 +30,4 @@ def test_main_runs(capfd): fetcher.reset_display() fetcher.fetch() out, _ = capfd.readouterr() - assert "Sunrise" in out - assert "Sunset" in out - assert "Astronomical Twilight Begin" in out + assert "Synthwave" in out