Skip to content

Commit 6c5e544

Browse files
authored
fix: improved scheduling
fix: improved scheduling
2 parents 1c9510e + d03dbc8 commit 6c5e544

17 files changed

Lines changed: 228 additions & 147 deletions

File tree

.github/workflows/cache-uv-build.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
build-cache:
88
runs-on: ubuntu-latest
99
env:
10-
UV_VERSION: '0.10.2'
10+
UV_VERSION: '0.10.6'
1111
PYTHON_VERSION: '3.13'
1212
steps:
1313
- name: Checkout repository

.github/workflows/docker-build-and-scan.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
tags: ${{ inputs.DOCKER_TAGS }}
4747
- name: Run Trivy vulnerability scanner (remote)
4848
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
49-
uses: aquasecurity/trivy-action@0.34.0
49+
uses: aquasecurity/trivy-action@0.34.1
5050
with:
5151
image-ref: docker.io/${{ inputs.DOCKER_TAGS }}
5252
format: 'table'

.github/workflows/pytest.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
permissions:
1010
contents: read
1111
env:
12-
UV_VERSION: '>=0.10.2'
12+
UV_VERSION: '>=0.10.6'
1313
PYTHON_VERSION: '3.13'
1414
steps:
1515
- name: Checkout repository

.github/workflows/release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
dist_artifacts_name: dist
1616
dist_artifacts_dir: dist
1717
lock_file_artifact: uv.lock
18-
UV_VERSION: '>=0.10.2'
18+
UV_VERSION: '>=0.10.6'
1919
PYTHON_VERSION: '3.13'
2020
GITHUB_ACTIONS_AUTHOR_NAME: github-actions
2121
GITHUB_ACTIONS_AUTHOR_EMAIL: actions@users.noreply.github.com

.github/workflows/ruff.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
permissions:
1010
contents: read
1111
env:
12-
UV_VERSION: '>=0.10.2'
12+
UV_VERSION: '>=0.10.6'
1313
PYTHON_VERSION: '3.13'
1414

1515
steps:

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ dependencies = [
2424
]
2525

2626
[project.optional-dependencies]
27-
build = ["uv >= 0.10.2"]
27+
build = ["uv >= 0.10.4"]
2828

2929
[build-system]
30-
requires = ["uv_build >= 0.10.2"]
30+
requires = ["uv_build >= 0.10.4"]
3131
build-backend = "uv_build"
3232

3333
[project.scripts]
3434
sample-python-app = "sample_python_app.main:run_app"
35+
checks = "sample_python_app.scripts:run_checks"
3536

3637
[tool.semantic_release]
3738
build_command = """

scripts/checks.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env bash
2+
# Run project linting, formatting and tests using `uv` as in this repo.
3+
set -euo pipefail
4+
5+
# Change to repo root (script placed in scripts/)
6+
cd "$(dirname "$0")/.."
7+
8+
echo "⚡ Running ruff (auto-fix where possible)"
9+
uv run ruff check . --fix
10+
11+
echo "⚡ Running isort"
12+
uv run isort .
13+
14+
echo "⚡ Running black"
15+
uv run black .
16+
17+
echo "⚡ Re-running ruff to catch any remaining issues"
18+
uv run ruff check .
19+
20+
echo "⚡ Running tests"
21+
uv run -- coverage run -m pytest
22+
23+
echo "All checks completed."

src/sample_python_app/app/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
from sample_python_app.app.lifecycle import start_metrics_server
8-
from sample_python_app.app.runner import fetch_astro_data
8+
from sample_python_app.app.runner import fetcher
99
from sample_python_app.app.scheduler import start_scheduler
1010

11-
__all__ = ["start_metrics_server", "start_scheduler", "fetch_astro_data"]
11+
__all__ = ["start_metrics_server", "start_scheduler", "fetcher"]

src/sample_python_app/app/runner.py

Lines changed: 50 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# app/runner.py
77
import json
88
import time
9+
from datetime import date
910

1011
import httpx
1112
from pydantic import ValidationError
@@ -14,66 +15,67 @@
1415
FETCH_COUNTER,
1516
FETCH_DURATION,
1617
FETCH_ERRORS,
17-
display_astronomical_data,
1818
setup_logger,
1919
weather_settings,
2020
)
2121
from sample_python_app.exceptions import AppError
2222
from sample_python_app.services import fetch_astronomical_data_from_api
23+
from sample_python_app.ui import display_astronomical_data
2324

2425
logger = setup_logger("normal")
2526

2627

27-
def fetch_astro_data(*, exit_on_error: bool = True) -> None:
28-
"""Fetch and display astronomical data once, with error handling."""
29-
lat = weather_settings.LOCATION.latitude
30-
lon = weather_settings.LOCATION.longitude
31-
logger.info(f"Using latitude={lat} longitude={lon}")
28+
class AstroFetcher:
29+
"""Fetches astronomical data and displays only once per day."""
3230

33-
start = time.time()
31+
def __init__(self) -> None:
32+
"""Initialize the AstroFetcher with no last displayed day."""
33+
self._last_displayed_day: str | None = None
3434

35-
try:
36-
astro = fetch_astronomical_data_from_api(lat, lon)
37-
FETCH_COUNTER.inc()
38-
except httpx.HTTPStatusError as exc:
39-
_handle_fetch_error(exc, exit_on_error)
40-
return
41-
except httpx.RequestError as exc:
42-
_handle_fetch_error(exc, exit_on_error)
43-
return
44-
except ValidationError as exc:
45-
_handle_fetch_error(exc, exit_on_error)
46-
return
47-
except json.JSONDecodeError as exc:
48-
_handle_fetch_error(exc, exit_on_error)
49-
return
50-
except AppError as exc:
51-
_handle_fetch_error(exc, exit_on_error)
52-
return
53-
finally:
54-
FETCH_DURATION.observe(time.time() - start)
35+
def fetch(self, *, exit_on_error: bool = True) -> None:
36+
"""Fetch astronomical data and display if not already displayed today."""
37+
lat = weather_settings.LOCATION.latitude
38+
lon = weather_settings.LOCATION.longitude
39+
logger.info(f"Using latitude={lat} longitude={lon}")
40+
start = time.time()
41+
try:
42+
astro = fetch_astronomical_data_from_api(lat, lon)
43+
FETCH_COUNTER.inc()
44+
except (
45+
httpx.HTTPStatusError,
46+
httpx.RequestError,
47+
ValidationError,
48+
json.JSONDecodeError,
49+
AppError,
50+
) as exc:
51+
self._handle_fetch_error(exc, exit_on_error)
52+
return
53+
finally:
54+
FETCH_DURATION.observe(time.time() - start)
55+
today_str = date.today().isoformat()
56+
if self._last_displayed_day != today_str:
57+
display_astronomical_data(astro)
58+
self._last_displayed_day = today_str
5559

56-
display_astronomical_data(astro)
60+
def reset_display(self):
61+
"""Reset the last displayed day so display will occur again."""
62+
self._last_displayed_day = None
5763

64+
def _handle_fetch_error(self, exc: Exception, exit_on_error: bool) -> None:
65+
FETCH_ERRORS.inc()
66+
if isinstance(exc, httpx.HTTPStatusError):
67+
logger.error("HTTP status error: %s", exc)
68+
elif isinstance(exc, httpx.RequestError):
69+
logger.error("Network error: %s", exc)
70+
elif isinstance(exc, ValidationError):
71+
logger.error("Validation error: %s", exc)
72+
elif isinstance(exc, json.JSONDecodeError):
73+
logger.error("JSON decode error: %s", exc)
74+
else:
75+
logger.exception("Unexpected error")
76+
if exit_on_error:
77+
raise SystemExit(1) from exc
78+
raise AppError(str(exc)) from exc
5879

59-
def _handle_fetch_error(exc: Exception, exit_on_error: bool) -> None:
60-
"""Handle errors during the fetch operation.
6180

62-
Log appropriately and update metrics.
63-
"""
64-
FETCH_ERRORS.inc()
65-
if isinstance(exc, httpx.HTTPStatusError):
66-
logger.error("HTTP status error: %s", exc)
67-
elif isinstance(exc, httpx.RequestError):
68-
logger.error("Network error: %s", exc)
69-
elif isinstance(exc, ValidationError):
70-
logger.error("Validation error: %s", exc)
71-
elif isinstance(exc, json.JSONDecodeError):
72-
logger.error("JSON decode error: %s", exc)
73-
else:
74-
logger.exception("Unexpected error")
75-
76-
if exit_on_error:
77-
raise SystemExit(1) from exc
78-
79-
raise AppError(str(exc)) from exc
81+
fetcher = AstroFetcher()
Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,48 @@
1-
"""Scheduler module for periodic astronomical data fetch.
1+
"""Scheduler module to fetch astronomical data every 24 hours."""
22

3-
Initializes and runs the APScheduler job every 24 hours.
4-
"""
5-
6-
from datetime import datetime
3+
import signal
4+
import sys
5+
from datetime import UTC, datetime
76

87
from apscheduler.schedulers.blocking import BlockingScheduler
8+
from loguru import logger
99

10-
from sample_python_app.app.runner import fetch_astro_data
10+
from sample_python_app.app.runner import fetcher
1111
from sample_python_app.core.logging import setup_logger
1212

13-
logger = setup_logger("normal")
14-
1513

1614
def start_scheduler(test_mode: bool = False) -> None:
17-
"""Start the scheduler to run the astronomical data fetch every 24 hours.
15+
"""Start the scheduler to fetch astronomical data every 24 hours."""
16+
setup_logger("normal")
17+
scheduler_logger = logger.bind(component="scheduler")
18+
19+
if test_mode:
20+
# Ensure display will run in tests by clearing any previous state
21+
if hasattr(fetcher, "reset_display") and callable(fetcher.reset_display):
22+
fetcher.reset_display()
23+
fetcher.fetch(exit_on_error=False)
24+
return
25+
26+
scheduler = BlockingScheduler(timezone="UTC")
27+
28+
def shutdown(signum, frame):
29+
del frame
30+
scheduler_logger.debug("Shutdown signal received", signal=signum)
31+
scheduler.shutdown(wait=True)
32+
sys.exit(0)
33+
34+
signal.signal(signal.SIGTERM, shutdown)
35+
signal.signal(signal.SIGINT, shutdown)
1836

19-
In test_mode, run the scheduled job once and do not block.
20-
"""
21-
scheduler = BlockingScheduler()
2237
scheduler.add_job(
23-
fetch_astro_data,
38+
fetcher.fetch,
2439
trigger="interval",
2540
hours=24,
26-
next_run_time=datetime.now(),
41+
next_run_time=datetime.now(UTC),
42+
misfire_grace_time=3600,
43+
coalesce=True,
44+
max_instances=1,
2745
)
28-
logger.info("Scheduled astronomical fetch every 24 hours")
29-
if test_mode:
30-
# Run the job once for testing, do not block
31-
job = scheduler.get_jobs()[0]
32-
job.func()
33-
logger.info("Ran scheduled job once in test mode")
34-
return
35-
try:
36-
scheduler.start()
37-
except (KeyboardInterrupt, SystemExit):
38-
logger.info("Scheduler stopped")
39-
scheduler.shutdown()
46+
47+
scheduler_logger.info("Scheduler started")
48+
scheduler.start()

0 commit comments

Comments
 (0)