Compare commits

...

8 Commits

Author SHA1 Message Date
Peter
cf6d66adb2 feat: implement continuous polling loop
start() now loops indefinitely, calling poll() every 5 minutes.
Each cycle is wrapped in a poll_cycle OTel span with a
flights.published attribute (0 on failure). Transient poll failures
are logged and the loop continues. The Kafka stream engine is stopped
cleanly via try/finally on shutdown.

Closes #5

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 09:11:07 +02:00
Peter
47b8fd29f8 test: add pytest infrastructure and poll() tests
- Add pytest and pytest-asyncio as optional dev dependencies
- Configure asyncio_mode=auto and pythonpath=[src] in pyproject.toml
- Write three tests at the poll() seam:
  * single-page response publishes all Flights and returns the count
  * paginated response follows link:rel=next and publishes all pages
  * HTTP error from raise_for_status() propagates out of poll()

Tests use injected stub HTTP session and mock Kafka engine;
no real network or broker required.

Closes #4

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 09:07:27 +02:00
Peter
6dbc79202c prefactor: extract poll() function from start()
The inner fetch-and-publish sweep is moved into a standalone
async poll(session, stream_engine, api_settings, kafka_settings, tracer) -> int
function. start() calls it exactly once — observable behaviour is unchanged.
This creates the test seam that tickets #4 and #5 depend on.

Closes #3

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 09:03:12 +02:00
Peter
ff5c8ba548 OPT add linting workflow 2025-12-29 17:15:35 +01:00
Peter
1238264f74 OPT add linting workflow 2025-12-29 17:15:35 +01:00
Peter
5c4dc8a630 OPT project structure 2025-12-29 17:15:29 +01:00
Peter
ec0483c409 OPT add linting workflow 2025-12-29 17:09:46 +01:00
Peter
db0c1dcb52 OPT add linting workflow 2025-12-29 17:00:39 +01:00
11 changed files with 273 additions and 1195 deletions

View File

@ -0,0 +1,25 @@
name: Lint and Format
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Check formatting with ruff
run: uvx ruff format --check src/
- name: Lint with ruff
run: uvx ruff check src/

File diff suppressed because it is too large Load Diff

View File

@ -19,3 +19,37 @@ dependencies = [
"opentelemetry-exporter-otlp>=1.27.0",
"opentelemetry-instrumentation-requests>=0.48b0",
]
[project.scripts]
schiphol = "src.main:main"
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["src"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = []
[tool.ruff.format]
quote-style = "double"
indent-style = "space"

1
src/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Schiphol flight data producer."""

View File

@ -1,53 +1,36 @@
from settings import SchipholApiSettings, KafkaSettings
from requests_ratelimiter import LimiterSession
from kstreams import create_engine
from kstreams.backends.kafka import Kafka
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.requests import RequestsInstrumentor
import aiorun
import asyncio
import json
import logging
import aiorun
from kstreams import create_engine
from kstreams.backends.kafka import Kafka
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from requests_ratelimiter import LimiterSession
from settings import KafkaSettings, OtelSettings, SchipholApiSettings
logger = logging.getLogger(__name__)
POLL_INTERVAL_SECONDS = 300 # 5 minutes between Polls
async def start():
# Initialize OpenTelemetry tracing (minimal, env-driven)
resource = Resource.create({"service.name": "schiphol-producer"})
provider = TracerProvider(resource=resource)
trace.set_tracer_provider(provider)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
RequestsInstrumentor().instrument()
tracer = trace.get_tracer("schiphol")
api_settings = SchipholApiSettings()
kafka_settings = KafkaSettings()
backend = Kafka(bootstrap_servers=kafka_settings.bootstrap_servers)
stream_engine = create_engine(title="schiphol-flights-engine", backend=backend)
await stream_engine.start()
session = LimiterSession(per_second=1)
session.headers.update(
{
"Accept": "application/json",
"app_id": api_settings.api_id,
"app_key": api_settings.api_key,
"ResourceVersion": "v4",
}
)
async def poll(session, stream_engine, api_settings, kafka_settings, tracer) -> int:
"""Fetch all Flights from the Schiphol API and publish a Snapshot for each to Kafka.
Follows pagination via ``link: rel="next"`` response headers until all pages
are consumed. Returns the total number of Flights published.
"""
next_url = api_settings.base_url
total_flights = 0
while next_url:
with tracer.start_as_current_span(
"fetch_page", attributes={"http.url": next_url}
):
with tracer.start_as_current_span("fetch_page", attributes={"http.url": next_url}):
response = session.get(next_url)
response.raise_for_status()
data = response.json()
@ -80,17 +63,66 @@ async def start():
next_url = link.split(";")[0].strip().strip("<>")
break
logger.info(
f"Published {total_flights} flights to Kafka topic '{kafka_settings.topic}'"
return total_flights
async def start():
# Initialize OpenTelemetry tracing (minimal, env-driven)
otel_settings = OtelSettings()
resource = Resource.create({"service.name": "schiphol-producer"})
provider = TracerProvider(resource=resource)
trace.set_tracer_provider(provider)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=otel_settings.endpoint))
)
RequestsInstrumentor().instrument()
tracer = trace.get_tracer("schiphol")
api_settings = SchipholApiSettings()
kafka_settings = KafkaSettings()
backend = Kafka(bootstrap_servers=kafka_settings.bootstrap_servers)
stream_engine = create_engine(title="schiphol-flights-engine", backend=backend)
await stream_engine.start()
session = LimiterSession(per_second=1)
session.headers.update(
{
"Accept": "application/json",
"app_id": api_settings.api_id,
"app_key": api_settings.api_key,
"ResourceVersion": "v4",
}
)
await stream_engine.stop()
try:
while True:
with tracer.start_as_current_span("poll_cycle") as span:
try:
total_flights = await poll(
session, stream_engine, api_settings, kafka_settings, tracer
)
span.set_attribute("flights.published", total_flights)
logger.info(
f"Published {total_flights} flights to Kafka topic '{kafka_settings.topic}'"
)
except Exception:
span.set_attribute("flights.published", 0)
logger.exception("Poll failed — will retry after %ds", POLL_INTERVAL_SECONDS)
await asyncio.sleep(POLL_INTERVAL_SECONDS)
finally:
await stream_engine.stop()
async def shutdown(loop):
logger.info("Shutdown")
if __name__ == "__main__":
def main():
logging.basicConfig(level=logging.INFO)
aiorun.run(start(), stop_on_unhandled_errors=True, shutdown_callback=shutdown)
if __name__ == "__main__":
main()

View File

@ -1,17 +1,14 @@
# middleware.py
from kstreams import middleware, ConsumerRecord
from schema_registry.client import AsyncSchemaRegistryClient
from schema_registry.serializers import AsyncJsonMessageSerializer
from typing import Optional, Dict
import logging
from kstreams import ConsumerRecord, middleware
from schema_registry.client import AsyncSchemaRegistryClient
from schema_registry.serializers import AsyncJsonMessageSerializer
logger = logging.getLogger(__name__)
class ConfluentJsonSchemaMiddleware(
middleware.BaseMiddleware, AsyncJsonMessageSerializer
):
class ConfluentJsonSchemaMiddleware(middleware.BaseMiddleware, AsyncJsonMessageSerializer):
"""
Middleware to deserialize JSON messages using Confluent Schema Registry
"""
@ -20,13 +17,13 @@ class ConfluentJsonSchemaMiddleware(
self,
*,
schema_registry_client: AsyncSchemaRegistryClient,
reader_schema: Optional[Dict] = None,
reader_schema: dict | None = None,
**kwargs,
):
super().__init__(**kwargs)
self.schemaregistry_client = schema_registry_client
self.reader_schema = reader_schema
self.id_to_decoder: Dict = {}
self.id_to_decoder: dict = {}
async def __call__(self, cr: ConsumerRecord):
"""

View File

@ -59,12 +59,8 @@ class FlightDirection(Enum):
class RouteType(BaseModel):
destinations: list[str] | None = None
eu: str | None = Field(
None, description="S (Schengen), E (Europe) or N (non-Europe)"
)
visa: bool | None = Field(
None, description="Indicates if a visum is required for destination"
)
eu: str | None = Field(None, description="S (Schengen), E (Europe) or N (non-Europe)")
visa: bool | None = Field(None, description="Indicates if a visum is required for destination")
class AircraftTypeType(BaseModel):
@ -120,35 +116,19 @@ class CheckinAllocationsType(BaseModel):
class Flight(BaseModel):
lastUpdatedAt: datetime | None = None
actualLandingTime: datetime | None = Field(
None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ"
)
actualOffBlockTime: datetime | None = Field(
None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ"
)
actualLandingTime: datetime | None = Field(None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ")
actualOffBlockTime: datetime | None = Field(None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ")
aircraftRegistration: str | None = None
aircraftType: AircraftTypeType | None = None
baggageClaim: BaggageClaimType | None = None
checkinAllocations: CheckinAllocationsType | None = None
codeshares: CodesharesType | None = None
estimatedLandingTime: datetime | None = Field(
None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ"
)
expectedTimeBoarding: datetime | None = Field(
None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ"
)
expectedTimeGateClosing: datetime | None = Field(
None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ"
)
expectedTimeGateOpen: datetime | None = Field(
None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ"
)
expectedTimeOnBelt: datetime | None = Field(
None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ"
)
expectedSecurityFilter: str | None = Field(
None, description="expected security filter"
)
estimatedLandingTime: datetime | None = Field(None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ")
expectedTimeBoarding: datetime | None = Field(None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ")
expectedTimeGateClosing: datetime | None = Field(None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ")
expectedTimeGateOpen: datetime | None = Field(None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ")
expectedTimeOnBelt: datetime | None = Field(None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ")
expectedSecurityFilter: str | None = Field(None, description="expected security filter")
flightDirection: FlightDirection | None = None
flightName: str | None = None
flightNumber: int | None = None
@ -165,14 +145,12 @@ class Flight(BaseModel):
)
publicFlightState: PublicFlightStateType | None = None
route: RouteType | None = None
scheduleDateTime: datetime | None = Field(
None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ"
)
scheduleDateTime: datetime | None = Field(None, description="yyyy-MM-dd'T'HH:mm:ss.SSSZ")
scheduleDate: date | None = Field(None, description="yyyy-MM-dd")
scheduleTime: str | None = Field(None, description="hh:mm:ss")
serviceType: str | None = Field(
None,
description="The service type category of the commercial flight. For example: J = Passenger Line, C=Passenger Charter, F = Freight Line and H = Freight Charter etc.",
description="The service type category of the commercial flight.",
)
terminal: int | None = None
transferPositions: TransferPositionsType | None = None

0
tests/__init__.py Normal file
View File

121
tests/test_poll.py Normal file
View File

@ -0,0 +1,121 @@
"""Tests for poll() — the Schiphol API fetch-and-publish sweep."""
import pytest
import requests
from main import poll
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _NoOpSpan:
def __enter__(self):
return self
def __exit__(self, *args):
pass
class StubTracer:
def start_as_current_span(self, name, attributes=None):
return _NoOpSpan()
class StubResponse:
"""Minimal requests.Response lookalike."""
def __init__(self, flights, *, next_url=None, raise_on_status=False):
self._flights = flights
self._raise = raise_on_status
self.headers = {}
if next_url:
self.headers["link"] = f'<{next_url}>; rel="next"'
def raise_for_status(self):
if self._raise:
raise requests.HTTPError("500 Server Error")
def json(self):
return {"flights": self._flights}
class StubSession:
"""Returns pre-configured StubResponse objects keyed by URL."""
def __init__(self, responses_by_url: dict):
self._responses = responses_by_url
def get(self, url):
return self._responses[url]
class MockEngine:
"""Captures every send() call so tests can assert on Kafka output."""
def __init__(self):
self.sent: list[dict] = []
async def send(self, topic, *, value, key):
self.sent.append({"topic": topic, "value": value, "key": key})
class FakeApiSettings:
base_url = "https://api.example.com/flights"
class FakeKafkaSettings:
topic = "flights-test"
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
async def test_single_page_publishes_all_flights_and_returns_count():
"""poll() publishes every Flight on a single-page response and returns the count."""
flights = [{"id": "1", "flightName": "KL100"}, {"id": "2", "flightName": "KL200"}]
session = StubSession({FakeApiSettings.base_url: StubResponse(flights)})
engine = MockEngine()
count = await poll(session, engine, FakeApiSettings(), FakeKafkaSettings(), StubTracer())
assert count == 2
assert len(engine.sent) == 2
published_keys = {msg["key"] for msg in engine.sent}
assert published_keys == {"1", "2"}
async def test_pagination_publishes_flights_across_all_pages():
"""poll() follows link: rel="next" headers and publishes Flights from every page."""
page1_url = FakeApiSettings.base_url
page2_url = "https://api.example.com/flights?page=2"
page1_flights = [{"id": "A"}, {"id": "B"}]
page2_flights = [{"id": "C"}, {"id": "D"}, {"id": "E"}]
session = StubSession(
{
page1_url: StubResponse(page1_flights, next_url=page2_url),
page2_url: StubResponse(page2_flights),
}
)
engine = MockEngine()
count = await poll(session, engine, FakeApiSettings(), FakeKafkaSettings(), StubTracer())
assert count == 5
assert len(engine.sent) == 5
async def test_http_error_propagates_out_of_poll():
"""poll() lets HTTP errors from raise_for_status() propagate to the caller."""
session = StubSession(
{FakeApiSettings.base_url: StubResponse([], raise_on_status=True)}
)
engine = MockEngine()
with pytest.raises(requests.HTTPError):
await poll(session, engine, FakeApiSettings(), FakeKafkaSettings(), StubTracer())