101b26d
commit 101b26d
9 files changed
+483 -54
Showing 9 changed files+483-54
▼.github/workflows/lake.yml
+0-3
| @@ -94,15 +94,12 @@ jobs: | ||
94 | 94 | |
95 | 95 | - name: Publish Gold to MotherDuck |
96 | 96 | run: uv run python -m scripts.publish_motherduck |
97 | - continue-on-error: true | |
98 | 97 | |
99 | 98 | - name: Publish site tables (stories, ops, lineage) to MotherDuck |
100 | 99 | run: uv run python -m scripts.publish_site_tables |
101 | - continue-on-error: true | |
102 | 100 | |
103 | 101 | - name: Publish serving copy to Turso (the site reads this) |
104 | 102 | run: uv run python -m scripts.publish_turso |
105 | - continue-on-error: true | |
106 | 103 | |
107 | 104 | - name: Upload quality report |
108 | 105 | if: always() |
109 | 106 | |
▼dbt/models/staging/schema.yml
+2-4
| @@ -21,17 +21,15 @@ models: | ||
21 | 21 | config: |
22 | 22 | severity: warn |
23 | 23 | - name: departure_icao |
24 | - description: "Departure airport ICAO code" | |
24 | + description: "Departure airport ICAO code (null when only the arrival end is known)" | |
25 | 25 | tests: |
26 | - - not_null | |
27 | 26 | - relationships: |
28 | 27 | arguments: |
29 | 28 | to: ref('stg_dim_airport') |
30 | 29 | field: airport_icao |
31 | 30 | - name: arrival_icao |
32 | - description: "Arrival airport ICAO code" | |
31 | + description: "Arrival airport ICAO code (null when only the departure end is known)" | |
33 | 32 | tests: |
34 | - - not_null | |
35 | 33 | - relationships: |
36 | 34 | arguments: |
37 | 35 | to: ref('stg_dim_airport') |
38 | 36 | |
▼dbt/tests/at_least_one_airport.sql
+7-0
| @@ -0,0 +1,7 @@ | ||
1 | +-- OpenSky usually knows only one end of a flight movement, but a record with | |
2 | +-- neither airport is useless. Fail if any flight has both ends missing. | |
3 | +select | |
4 | + flight_id | |
5 | +from {{ ref('stg_fact_flights') }} | |
6 | +where departure_icao is null | |
7 | + and arrival_icao is null | |
0 | 8 | |
▼pipelines/silver.py
+21-3
| @@ -160,12 +160,21 @@ def transform_flights(df: pl.DataFrame, app_settings: Settings | None = None) -> | ||
160 | 160 | if "cancelled" in df.columns: |
161 | 161 | df = df.with_columns(pl.col("cancelled").cast(pl.Boolean).fill_null(False)) |
162 | 162 | |
163 | + # OpenSky usually knows only one end of a movement: a departure query gives | |
164 | + # the origin, an arrival query the destination. Require at least one known | |
165 | + # airport instead of both, and only reject when the two ends agree. | |
166 | + has_departure = pl.col("departure_icao").is_not_null() & (pl.col("departure_icao") != "") | |
167 | + has_arrival = pl.col("arrival_icao").is_not_null() & (pl.col("arrival_icao") != "") | |
168 | + ends_differ = ( | |
169 | + pl.col("departure_icao").is_null() | |
170 | + | pl.col("arrival_icao").is_null() | |
171 | + | (pl.col("departure_icao") != pl.col("arrival_icao")) | |
172 | + ) | |
163 | 173 | valid = ( |
164 | 174 | pl.col("flight_id").is_not_null() |
165 | 175 | & (pl.col("flight_id") != "") |
166 | - & pl.col("departure_icao").is_not_null() | |
167 | - & pl.col("arrival_icao").is_not_null() | |
168 | - & (pl.col("departure_icao") != pl.col("arrival_icao")) | |
176 | + & (has_departure | has_arrival) | |
177 | + & ends_differ | |
169 | 178 | ) |
170 | 179 | df, _ = _split_quarantine( |
171 | 180 | df, valid, "flights", "missing or invalid flight/departure/arrival keys", app_settings |
| @@ -173,6 +182,15 @@ def transform_flights(df: pl.DataFrame, app_settings: Settings | None = None) -> | ||
173 | 182 | |
174 | 183 | if df.is_empty(): |
175 | 184 | return df |
185 | + # The same flight can be observed twice (once at each end). Dedup keeps the | |
186 | + # last row, so order the most complete record last and let it win. | |
187 | + df = df.with_columns( | |
188 | + ( | |
189 | + pl.col("departure_icao").is_null().cast(pl.Int8) | |
190 | + + pl.col("arrival_icao").is_null().cast(pl.Int8) | |
191 | + ).alias("_missing_ends") | |
192 | + ) | |
193 | + df = df.sort(["_missing_ends", "flight_id"], descending=[True, False]).drop("_missing_ends") | |
176 | 194 | df = df.unique(subset=["flight_id"], keep="last") |
177 | 195 | if "scheduled_departure" in df.columns: |
178 | 196 | df = df.sort("scheduled_departure", nulls_last=True) |
179 | 197 | |
▼scripts/publish_turso.py
+113-9
| @@ -17,6 +17,7 @@ fact tables are synced **incrementally** against a watermark kept in Turso's | ||
17 | 17 | from __future__ import annotations |
18 | 18 | |
19 | 19 | import argparse |
20 | +import base64 | |
20 | 21 | import json |
21 | 22 | from datetime import date, datetime |
22 | 23 | from decimal import Decimal |
| @@ -24,6 +25,7 @@ from pathlib import Path | ||
24 | 25 | from typing import Any |
25 | 26 | |
26 | 27 | import duckdb |
28 | +import requests | |
27 | 29 | |
28 | 30 | from config.logging import logger |
29 | 31 | from config.settings import settings |
| @@ -83,6 +85,106 @@ def _value(value: Any) -> Any: | ||
83 | 85 | return json.dumps(value, default=str) |
84 | 86 | |
85 | 87 | |
88 | +class _Rows: | |
89 | + """Result wrapper exposing the ``.rows`` attribute the publisher uses.""" | |
90 | + | |
91 | + def __init__(self, rows: list[list[Any]]) -> None: | |
92 | + self.rows = rows | |
93 | + | |
94 | + | |
95 | +class _TursoHttpClient: | |
96 | + """Minimal client for Turso's HTTP pipeline API (``POST /v2/pipeline``). | |
97 | + | |
98 | + The ``libsql-client`` Python package is unmaintained and speaks the retired | |
99 | + Hrana v1 protocol (``wss://`` + ``/v1/execute``), which Turso rejects with | |
100 | + HTTP 400. The v2 pipeline is the same protocol ``@libsql/client/web`` uses | |
101 | + in the browser, so the write path and the read path agree. | |
102 | + """ | |
103 | + | |
104 | + def __init__(self, url: str, token: str, timeout: float = 60.0) -> None: | |
105 | + base = url | |
106 | + for scheme in ("libsql://", "wss://"): | |
107 | + if base.startswith(scheme): | |
108 | + base = "https://" + base[len(scheme) :] | |
109 | + self._endpoint = base.rstrip("/") + "/v2/pipeline" | |
110 | + self._timeout = timeout | |
111 | + self._session = requests.Session() | |
112 | + headers = {"Content-Type": "application/json"} | |
113 | + if token: | |
114 | + headers["Authorization"] = f"Bearer {token}" | |
115 | + self._session.headers.update(headers) | |
116 | + | |
117 | + @staticmethod | |
118 | + def _arg(value: Any) -> dict[str, Any]: | |
119 | + if value is None: | |
120 | + return {"type": "null"} | |
121 | + if isinstance(value, bool): | |
122 | + return {"type": "integer", "value": "1" if value else "0"} | |
123 | + if isinstance(value, int): | |
124 | + return {"type": "integer", "value": str(value)} | |
125 | + if isinstance(value, float): | |
126 | + return {"type": "float", "value": repr(value)} | |
127 | + if isinstance(value, (bytes, bytearray)): | |
128 | + return {"type": "blob", "base64": base64.b64encode(bytes(value)).decode("ascii")} | |
129 | + return {"type": "text", "value": str(value)} | |
130 | + | |
131 | + @staticmethod | |
132 | + def _cell(cell: dict[str, Any]) -> Any: | |
133 | + kind = cell.get("type") | |
134 | + if kind == "null": | |
135 | + return None | |
136 | + if kind == "integer": | |
137 | + return int(cell["value"]) | |
138 | + if kind == "float": | |
139 | + return float(cell["value"]) | |
140 | + if kind == "blob": | |
141 | + return base64.b64decode(cell.get("base64") or "") | |
142 | + return cell.get("value") | |
143 | + | |
144 | + @staticmethod | |
145 | + def _statement(sql: str, args: Any) -> dict[str, Any]: | |
146 | + return { | |
147 | + "type": "execute", | |
148 | + "stmt": { | |
149 | + "sql": sql, | |
150 | + "args": [_TursoHttpClient._arg(value) for value in (args or [])], | |
151 | + }, | |
152 | + } | |
153 | + | |
154 | + def _pipeline(self, payload: list[dict[str, Any]]) -> list[list[Any]]: | |
155 | + response = self._session.post( | |
156 | + self._endpoint, | |
157 | + json={"requests": payload}, | |
158 | + timeout=self._timeout, | |
159 | + ) | |
160 | + if response.status_code != 200: | |
161 | + raise RuntimeError(f"Turso HTTP {response.status_code}: {response.text[:300]}") | |
162 | + results: list[list[Any]] = [] | |
163 | + for entry in response.json().get("results", []): | |
164 | + if entry.get("type") != "ok": | |
165 | + message = (entry.get("error") or {}).get("message", "unknown error") | |
166 | + raise RuntimeError(f"Turso error: {message}") | |
167 | + body = entry.get("response") or {} | |
168 | + if body.get("type") != "execute": | |
169 | + results.append([]) | |
170 | + continue | |
171 | + result = body.get("result") or {} | |
172 | + results.append([[self._cell(cell) for cell in row] for row in result.get("rows", [])]) | |
173 | + return results | |
174 | + | |
175 | + def execute(self, sql: str, args: Any = None) -> _Rows: | |
176 | + rows = self._pipeline([self._statement(sql, args)]) | |
177 | + return _Rows(rows[0] if rows else []) | |
178 | + | |
179 | + def batch(self, statements: list[tuple[str, Any]]) -> None: | |
180 | + payload = [self._statement(sql, args) for sql, args in statements] | |
181 | + if payload: | |
182 | + self._pipeline(payload) | |
183 | + | |
184 | + def close(self) -> None: | |
185 | + self._session.close() | |
186 | + | |
187 | + | |
86 | 188 | class TursoPublisher: |
87 | 189 | def __init__( |
88 | 190 | self, url: str | None = None, token: str | None = None, db_path: Path | None = None |
| @@ -96,8 +198,6 @@ class TursoPublisher: | ||
96 | 198 | |
97 | 199 | # ── connections ──────────────────────────────────────────────────────── |
98 | 200 | def _connect(self) -> bool: |
99 | - from libsql_client import create_client_sync | |
100 | - | |
101 | 201 | if not self.url: |
102 | 202 | logger.warning("[turso] TURSO_DATABASE_URL not set — skipping") |
103 | 203 | return False |
| @@ -105,10 +205,16 @@ class TursoPublisher: | ||
105 | 205 | logger.error("[turso] local warehouse missing: %s", self.db_path) |
106 | 206 | return False |
107 | 207 | self.local = duckdb.connect(str(self.db_path), read_only=True) |
108 | - kwargs: dict[str, Any] = {"url": self.url} | |
109 | - if self.token: | |
110 | - kwargs["auth_token"] = self.token | |
111 | - self.remote = create_client_sync(**kwargs) | |
208 | + if self.url.startswith(("file:", "sqlite:")): | |
209 | + # Local libSQL file, used by the tests. | |
210 | + from libsql_client import create_client_sync | |
211 | + | |
212 | + kwargs: dict[str, Any] = {"url": self.url} | |
213 | + if self.token: | |
214 | + kwargs["auth_token"] = self.token | |
215 | + self.remote = create_client_sync(**kwargs) | |
216 | + else: | |
217 | + self.remote = _TursoHttpClient(self.url, self.token or "") | |
112 | 218 | return True |
113 | 219 | |
114 | 220 | def _close(self) -> None: |
| @@ -172,8 +278,6 @@ class TursoPublisher: | ||
172 | 278 | |
173 | 279 | # ── writes ───────────────────────────────────────────────────────────── |
174 | 280 | def _write_rows(self, table: str, columns: list[str], rows: list[tuple[Any, ...]]) -> int: |
175 | - from libsql_client import Statement | |
176 | - | |
177 | 281 | if not rows: |
178 | 282 | return 0 |
179 | 283 | placeholders = ", ".join("?" for _ in columns) |
| @@ -182,7 +286,7 @@ class TursoPublisher: | ||
182 | 286 | written = 0 |
183 | 287 | for start in range(0, len(rows), BATCH_SIZE): |
184 | 288 | chunk = rows[start : start + BATCH_SIZE] |
185 | - self.remote.batch([Statement(sql, list(row)) for row in chunk]) | |
289 | + self.remote.batch([(sql, list(row)) for row in chunk]) # type: ignore[union-attr] | |
186 | 290 | written += len(chunk) |
187 | 291 | return written |
188 | 292 | |
189 | 293 | |
▼services/sources/flights.py
+53-34
| @@ -1,6 +1,9 @@ | ||
1 | 1 | """Flight movements (arrivals/departures) from OpenSky Network. |
2 | 2 | |
3 | -Polls ``/flights/airport`` for the busiest European hubs. Requires OpenSky | |
3 | +Polls ``/flights/departure`` and ``/flights/arrival`` for the busiest European | |
4 | +hubs. The combined ``/flights/airport`` endpoint only yields departures, so a | |
5 | +store ends up with one known end per flight; the dedicated endpoints give both | |
6 | +directions and the arrival one normally knows both ends. Requires OpenSky | |
4 | 7 | credentials; without them the source returns an empty list rather than failing. |
5 | 8 | """ |
6 | 9 | |
| @@ -67,21 +70,28 @@ class FlightsSource(Source): | ||
67 | 70 | return (s.opensky_username, s.opensky_password) |
68 | 71 | return None |
69 | 72 | |
70 | - def _airport_flights(self, icao: str, begin: int, end: int) -> list[dict[str, Any]]: | |
73 | + def _airport_flights(self, icao: str, begin: int, end: int, kind: str) -> list[dict[str, Any]]: | |
74 | + """``kind`` is ``departure`` or ``arrival``. | |
75 | + | |
76 | + The combined ``/flights/airport`` endpoint only returns departures in | |
77 | + practice, which left every store as "departure known, arrival unknown". | |
78 | + The dedicated endpoints return each direction, and the arrival one | |
79 | + usually knows both ends. | |
80 | + """ | |
71 | 81 | params: dict[str, str | int] = {"airport": icao, "begin": begin, "end": end} |
72 | 82 | try: |
73 | 83 | res = self._session.get( |
74 | - f"{OPENSKY}/flights/airport", | |
84 | + f"{OPENSKY}/flights/{kind}", | |
75 | 85 | params=params, |
76 | 86 | headers=HEADERS, |
77 | 87 | auth=self._auth, |
78 | 88 | timeout=self.settings.request_timeout_seconds, |
79 | 89 | ) |
80 | 90 | except requests.RequestException as exc: |
81 | - logger.debug("[flights] opensky %s: %s", icao, exc) | |
91 | + logger.debug("[flights] opensky %s %s: %s", kind, icao, exc) | |
82 | 92 | return [] |
83 | 93 | if res.status_code != 200: |
84 | - logger.debug("[flights] opensky %s -> HTTP %s", icao, res.status_code) | |
94 | + logger.debug("[flights] opensky %s %s -> HTTP %s", kind, icao, res.status_code) | |
85 | 95 | return [] |
86 | 96 | try: |
87 | 97 | payload = res.json() |
| @@ -104,35 +114,44 @@ class FlightsSource(Source): | ||
104 | 114 | |
105 | 115 | def fetch_airport(icao: str) -> list[dict[str, Any]]: |
106 | 116 | out: list[dict[str, Any]] = [] |
107 | - for flight in self._airport_flights(icao, begin, end): | |
108 | - icao24 = flight.get("icao24") | |
109 | - if not icao24: | |
110 | - continue | |
111 | - first_seen = flight.get("firstSeen") | |
112 | - last_seen = flight.get("lastSeen") | |
113 | - out.append( | |
114 | - { | |
115 | - "flight_id": f"{icao24}_{first_seen or end}", | |
116 | - "icao24": str(icao24).upper(), | |
117 | - "callsign": (flight.get("callsign") or "").strip(), | |
118 | - "departure_icao": flight.get("estDepartureAirport"), | |
119 | - "arrival_icao": flight.get("estArrivalAirport"), | |
120 | - "actual_departure": ( | |
121 | - datetime.fromtimestamp(first_seen, tz=UTC).isoformat() | |
122 | - if first_seen | |
123 | - else None | |
124 | - ), | |
125 | - "actual_arrival": ( | |
126 | - datetime.fromtimestamp(last_seen, tz=UTC).isoformat() | |
127 | - if last_seen | |
128 | - else None | |
129 | - ), | |
130 | - "status": "landed" if flight.get("estArrivalAirport") else "en_route", | |
131 | - "delay_minutes": 0, | |
132 | - "source": "opensky", | |
133 | - "collected_at": collected_at, | |
134 | - } | |
135 | - ) | |
117 | + for kind in ("departure", "arrival"): | |
118 | + for flight in self._airport_flights(icao, begin, end, kind): | |
119 | + icao24 = flight.get("icao24") | |
120 | + if not icao24: | |
121 | + continue | |
122 | + first_seen = flight.get("firstSeen") | |
123 | + last_seen = flight.get("lastSeen") | |
124 | + # The queried airport is authoritative for its own direction | |
125 | + # even when OpenSky omits the estimated field. | |
126 | + departure = flight.get("estDepartureAirport") | |
127 | + arrival = flight.get("estArrivalAirport") | |
128 | + if kind == "departure" and not departure: | |
129 | + departure = icao | |
130 | + if kind == "arrival" and not arrival: | |
131 | + arrival = icao | |
132 | + out.append( | |
133 | + { | |
134 | + "flight_id": f"{icao24}_{first_seen or end}", | |
135 | + "icao24": str(icao24).upper(), | |
136 | + "callsign": (flight.get("callsign") or "").strip(), | |
137 | + "departure_icao": departure, | |
138 | + "arrival_icao": arrival, | |
139 | + "actual_departure": ( | |
140 | + datetime.fromtimestamp(first_seen, tz=UTC).isoformat() | |
141 | + if first_seen | |
142 | + else None | |
143 | + ), | |
144 | + "actual_arrival": ( | |
145 | + datetime.fromtimestamp(last_seen, tz=UTC).isoformat() | |
146 | + if last_seen | |
147 | + else None | |
148 | + ), | |
149 | + "status": "landed" if arrival else "en-route", | |
150 | + "delay_minutes": 0, | |
151 | + "source": "opensky", | |
152 | + "collected_at": collected_at, | |
153 | + } | |
154 | + ) | |
136 | 155 | return out |
137 | 156 | |
138 | 157 | workers = min(self.settings.flights_max_workers, len(airports)) |
139 | 158 | |
▼tests/unit/test_live.py
+89-0
| @@ -220,6 +220,95 @@ def test_collector_publishes_positions_on_a_slower_cadence(monkeypatch) -> None: | ||
220 | 220 | assert svc.poll_once(svc.sources[0]) == 1 |
221 | 221 | |
222 | 222 | |
223 | +class _FlightSession: | |
224 | + """OpenSky's dedicated endpoints, one record per direction.""" | |
225 | + | |
226 | + def __init__(self): | |
227 | + self.calls: list[str] = [] | |
228 | + | |
229 | + def get(self, url, headers=None, timeout=None, params=None, auth=None): | |
230 | + self.calls.append(url) | |
231 | + if url.endswith("/flights/departure"): | |
232 | + return _FakeResponse( | |
233 | + payload=[ | |
234 | + { | |
235 | + "icao24": "aaa111", | |
236 | + "firstSeen": 100, | |
237 | + "lastSeen": 200, | |
238 | + "callsign": "DLH1", | |
239 | + "estDepartureAirport": "EDDF", | |
240 | + "estArrivalAirport": None, | |
241 | + } | |
242 | + ] | |
243 | + ) | |
244 | + return _FakeResponse( | |
245 | + payload=[ | |
246 | + { | |
247 | + "icao24": "bbb222", | |
248 | + "firstSeen": 300, | |
249 | + "lastSeen": 400, | |
250 | + "callsign": "DLH2", | |
251 | + "estDepartureAirport": "EGLL", | |
252 | + "estArrivalAirport": "EDDF", | |
253 | + } | |
254 | + ] | |
255 | + ) | |
256 | + | |
257 | + | |
258 | +def test_flights_source_queries_both_directions(monkeypatch) -> None: | |
259 | + from services.sources.flights import FlightsSource | |
260 | + | |
261 | + session = _FlightSession() | |
262 | + src = FlightsSource(session=session) | |
263 | + monkeypatch.setattr(src.settings, "opensky_username", "user") | |
264 | + monkeypatch.setattr(src.settings, "opensky_password", "pass") | |
265 | + monkeypatch.setattr(src.settings, "flights_airports", 1) | |
266 | + | |
267 | + rows = src.fetch() | |
268 | + assert any(url.endswith("/flights/departure") for url in session.calls) | |
269 | + assert any(url.endswith("/flights/arrival") for url in session.calls) | |
270 | + by_id = {row["flight_id"]: row for row in rows} | |
271 | + assert by_id["aaa111_100"]["departure_icao"] == "EDDF" | |
272 | + assert by_id["aaa111_100"]["arrival_icao"] is None | |
273 | + assert by_id["aaa111_100"]["status"] == "en-route" | |
274 | + assert by_id["bbb222_300"]["arrival_icao"] == "EDDF" | |
275 | + assert by_id["bbb222_300"]["status"] == "landed" | |
276 | + | |
277 | + | |
278 | +def test_flights_source_falls_back_to_queried_airport(monkeypatch) -> None: | |
279 | + from services.sources.flights import FlightsSource | |
280 | + | |
281 | + class _NoEstimate(_FlightSession): | |
282 | + def get(self, url, headers=None, timeout=None, params=None, auth=None): | |
283 | + self.calls.append(url) | |
284 | + return _FakeResponse( | |
285 | + payload=[ | |
286 | + { | |
287 | + "icao24": "ccc333", | |
288 | + "firstSeen": 500, | |
289 | + "lastSeen": 600, | |
290 | + "callsign": "DLH3", | |
291 | + "estDepartureAirport": None, | |
292 | + "estArrivalAirport": None, | |
293 | + } | |
294 | + ] | |
295 | + ) | |
296 | + | |
297 | + session = _NoEstimate() | |
298 | + src = FlightsSource(session=session) | |
299 | + monkeypatch.setattr(src.settings, "opensky_username", "user") | |
300 | + monkeypatch.setattr(src.settings, "opensky_password", "pass") | |
301 | + monkeypatch.setattr(src.settings, "flights_airports", 1) | |
302 | + | |
303 | + rows = src.fetch() | |
304 | + # The airport we queried is authoritative for its own direction. | |
305 | + assert len(rows) == 2 | |
306 | + departure = next(r for r in rows if r["departure_icao"] == "EDDF") | |
307 | + arrival = next(r for r in rows if r["arrival_icao"] == "EDDF") | |
308 | + assert departure["arrival_icao"] is None | |
309 | + assert arrival["departure_icao"] is None | |
310 | + | |
311 | + | |
223 | 312 | def test_ground_vehicle_emissions_are_zero() -> None: |
224 | 313 | from services.emissions import enrich_emissions |
225 | 314 | |
226 | 315 | |
▼tests/unit/test_silver.py
+27-0
| @@ -47,6 +47,33 @@ def test_transform_flights_deduplicates_and_drops_bad(): | ||
47 | 47 | assert out["delay_minutes"].min() >= 0 |
48 | 48 | |
49 | 49 | |
50 | +def test_transform_flights_accepts_one_known_end(): | |
51 | + """OpenSky usually estimates only one end of a movement.""" | |
52 | + df = pl.DataFrame( | |
53 | + { | |
54 | + "flight_id": ["DEP1", "ARR1", "BAD1", "SAME"], | |
55 | + "departure_icao": ["EDDF", None, None, "EDDF"], | |
56 | + "arrival_icao": [None, "EGLL", None, "EDDF"], | |
57 | + } | |
58 | + ) | |
59 | + out = transform_flights(df) | |
60 | + # Departure-only and arrival-only survive; nothing-known and same-end drop. | |
61 | + assert set(out["flight_id"].to_list()) == {"DEP1", "ARR1"} | |
62 | + | |
63 | + | |
64 | +def test_transform_flights_prefers_complete_record_on_duplicate(): | |
65 | + df = pl.DataFrame( | |
66 | + { | |
67 | + "flight_id": ["A1", "A1"], | |
68 | + "departure_icao": ["EDDF", "EDDF"], | |
69 | + "arrival_icao": [None, "EGLL"], | |
70 | + } | |
71 | + ) | |
72 | + out = transform_flights(df) | |
73 | + assert out.height == 1 | |
74 | + assert out["arrival_icao"].to_list() == ["EGLL"] | |
75 | + | |
76 | + | |
50 | 77 | def test_transform_flights_utc_normalisation(): |
51 | 78 | out = transform_flights(_flights()) |
52 | 79 | first = out["scheduled_departure"].dt.replace_time_zone("UTC").to_list()[0] |
53 | 80 | |
▼uv.lock
+171-1
| @@ -195,6 +195,12 @@ etl = [ | ||
195 | 195 | { name = "pyopensky" }, |
196 | 196 | { name = "traffic" }, |
197 | 197 | ] |
198 | +stream = [ | |
199 | + { name = "kafka-python-ng" }, | |
200 | +] | |
201 | +turso = [ | |
202 | + { name = "libsql-client" }, | |
203 | +] | |
198 | 204 | |
199 | 205 | [package.metadata] |
200 | 206 | requires-dist = [ |
| @@ -206,6 +212,8 @@ requires-dist = [ | ||
206 | 212 | { name = "holidays", specifier = ">=0.51" }, |
207 | 213 | { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, |
208 | 214 | { name = "huggingface-hub", specifier = ">=0.23.0" }, |
215 | + { name = "kafka-python-ng", marker = "extra == 'stream'", specifier = ">=2.2.0" }, | |
216 | + { name = "libsql-client", marker = "extra == 'turso'", specifier = ">=0.3.1" }, | |
209 | 217 | { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" }, |
210 | 218 | { name = "opensky-api", marker = "extra == 'etl'", git = "https://github.com/openskynetwork/opensky-api.git?subdirectory=python" }, |
211 | 219 | { name = "pandas", specifier = "==2.2.3" }, |
| @@ -226,7 +234,16 @@ requires-dist = [ | ||
226 | 234 | { name = "traffic", marker = "extra == 'etl'", specifier = ">=2.13" }, |
227 | 235 | { name = "uvicorn", specifier = ">=0.30.1" }, |
228 | 236 | ] |
229 | -provides-extras = ["etl", "dev"] | |
237 | +provides-extras = ["etl", "dev", "stream", "turso"] | |
238 | + | |
239 | +[[package]] | |
240 | +name = "alabaster" | |
241 | +version = "1.0.0" | |
242 | +source = { registry = "https://pypi.org/simple" } | |
243 | +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } | |
244 | +wheels = [ | |
245 | + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, | |
246 | +] | |
230 | 247 | |
231 | 248 | [[package]] |
232 | 249 | name = "altair" |
| @@ -1060,6 +1077,15 @@ wheels = [ | ||
1060 | 1077 | { url = "https://files.pythonhosted.org/packages/2b/5f/c52bd1255db763d0cdcb7084d2e90c42119cb229302c56bdf1d0aa78abd2/deepdiff-8.6.2-py3-none-any.whl", hash = "sha256:4d22034a866c3928303a9332c279362f714192d9305bac17c498720d095fd1b4", size = 91979, upload-time = "2026-03-18T17:16:32.171Z" }, |
1061 | 1078 | ] |
1062 | 1079 | |
1080 | +[[package]] | |
1081 | +name = "docutils" | |
1082 | +version = "0.22.4" | |
1083 | +source = { registry = "https://pypi.org/simple" } | |
1084 | +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } | |
1085 | +wheels = [ | |
1086 | + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, | |
1087 | +] | |
1088 | + | |
1063 | 1089 | [[package]] |
1064 | 1090 | name = "duckdb" |
1065 | 1091 | version = "1.5.4" |
| @@ -1567,6 +1593,15 @@ wheels = [ | ||
1567 | 1593 | { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, |
1568 | 1594 | ] |
1569 | 1595 | |
1596 | +[[package]] | |
1597 | +name = "imagesize" | |
1598 | +version = "2.0.1" | |
1599 | +source = { registry = "https://pypi.org/simple" } | |
1600 | +sdist = { url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz", hash = "sha256:b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5", size = 1883774, upload-time = "2026-08-24T12:35:19.13Z" } | |
1601 | +wheels = [ | |
1602 | + { url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl", hash = "sha256:ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02", size = 14794, upload-time = "2026-08-24T12:35:12.548Z" }, | |
1603 | +] | |
1604 | + | |
1570 | 1605 | [[package]] |
1571 | 1606 | name = "importlib-metadata" |
1572 | 1607 | version = "9.0.0" |
| @@ -1707,6 +1742,15 @@ wheels = [ | ||
1707 | 1742 | { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, |
1708 | 1743 | ] |
1709 | 1744 | |
1745 | +[[package]] | |
1746 | +name = "kafka-python-ng" | |
1747 | +version = "2.2.3" | |
1748 | +source = { registry = "https://pypi.org/simple" } | |
1749 | +sdist = { url = "https://files.pythonhosted.org/packages/ce/04/1d65bdf3f0103a08710e226b851de4b357ac702f1cadabf6128bab7518a7/kafka_python_ng-2.2.3.tar.gz", hash = "sha256:f79f28e10ade9b5a9860b2ec15b7cc8dc510d5702f5a399430478cff5f93a05a", size = 330644, upload-time = "2024-10-02T16:56:58.639Z" } | |
1750 | +wheels = [ | |
1751 | + { url = "https://files.pythonhosted.org/packages/0f/61/22e778f642465a157c449782300d8817ebbc106794a8a7ebe88cbb846b05/kafka_python_ng-2.2.3-py2.py3-none-any.whl", hash = "sha256:adc6e82147c441ca4ae1f22e291fc08efab0d10971cbd4aa1481d2ffa38e9480", size = 232824, upload-time = "2024-10-02T16:56:56.932Z" }, | |
1752 | +] | |
1753 | + | |
1710 | 1754 | [[package]] |
1711 | 1755 | name = "kiwisolver" |
1712 | 1756 | version = "1.5.0" |
| @@ -1864,6 +1908,20 @@ wheels = [ | ||
1864 | 1908 | { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, |
1865 | 1909 | ] |
1866 | 1910 | |
1911 | +[[package]] | |
1912 | +name = "libsql-client" | |
1913 | +version = "0.3.1" | |
1914 | +source = { registry = "https://pypi.org/simple" } | |
1915 | +dependencies = [ | |
1916 | + { name = "aiohttp" }, | |
1917 | + { name = "sphinx-press-theme" }, | |
1918 | + { name = "typing-extensions" }, | |
1919 | +] | |
1920 | +sdist = { url = "https://files.pythonhosted.org/packages/d2/76/8968356d45ee174ebe966ff0112bed1bd932851d6532737c2ee8b40ed6e0/libsql_client-0.3.1.tar.gz", hash = "sha256:1815893bf5271f1940919b2a583ebbafb0ec54028e9d01e4eda151a6e85a8618", size = 39605, upload-time = "2024-05-03T12:48:19.636Z" } | |
1921 | +wheels = [ | |
1922 | + { url = "https://files.pythonhosted.org/packages/83/f0/b0d7c7331f975bcc3effd01abb094723891fd42bca7f702b4a0cf06c1bff/libsql_client-0.3.1-py3-none-any.whl", hash = "sha256:088756f65802f5ca5857a99cf374d9b7aa015b97b91b0cb725ef6a033e23268b", size = 46100, upload-time = "2024-05-03T12:48:18.128Z" }, | |
1923 | +] | |
1924 | + | |
1867 | 1925 | [[package]] |
1868 | 1926 | name = "lxml" |
1869 | 1927 | version = "6.1.1" |
| @@ -3733,6 +3791,15 @@ wheels = [ | ||
3733 | 3791 | { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, |
3734 | 3792 | ] |
3735 | 3793 | |
3794 | +[[package]] | |
3795 | +name = "roman-numerals" | |
3796 | +version = "4.1.0" | |
3797 | +source = { registry = "https://pypi.org/simple" } | |
3798 | +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } | |
3799 | +wheels = [ | |
3800 | + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, | |
3801 | +] | |
3802 | + | |
3736 | 3803 | [[package]] |
3737 | 3804 | name = "rpds-py" |
3738 | 3805 | version = "2026.6.3" |
| @@ -4043,6 +4110,15 @@ wheels = [ | ||
4043 | 4110 | { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, |
4044 | 4111 | ] |
4045 | 4112 | |
4113 | +[[package]] | |
4114 | +name = "snowballstemmer" | |
4115 | +version = "3.1.1" | |
4116 | +source = { registry = "https://pypi.org/simple" } | |
4117 | +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } | |
4118 | +wheels = [ | |
4119 | + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, | |
4120 | +] | |
4121 | + | |
4046 | 4122 | [[package]] |
4047 | 4123 | name = "snowplow-tracker" |
4048 | 4124 | version = "1.1.0" |
| @@ -4065,6 +4141,100 @@ wheels = [ | ||
4065 | 4141 | { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, |
4066 | 4142 | ] |
4067 | 4143 | |
4144 | +[[package]] | |
4145 | +name = "sphinx" | |
4146 | +version = "9.1.0" | |
4147 | +source = { registry = "https://pypi.org/simple" } | |
4148 | +dependencies = [ | |
4149 | + { name = "alabaster" }, | |
4150 | + { name = "babel" }, | |
4151 | + { name = "colorama", marker = "sys_platform == 'win32'" }, | |
4152 | + { name = "docutils" }, | |
4153 | + { name = "imagesize" }, | |
4154 | + { name = "jinja2" }, | |
4155 | + { name = "packaging" }, | |
4156 | + { name = "pygments" }, | |
4157 | + { name = "requests" }, | |
4158 | + { name = "roman-numerals" }, | |
4159 | + { name = "snowballstemmer" }, | |
4160 | + { name = "sphinxcontrib-applehelp" }, | |
4161 | + { name = "sphinxcontrib-devhelp" }, | |
4162 | + { name = "sphinxcontrib-htmlhelp" }, | |
4163 | + { name = "sphinxcontrib-jsmath" }, | |
4164 | + { name = "sphinxcontrib-qthelp" }, | |
4165 | + { name = "sphinxcontrib-serializinghtml" }, | |
4166 | +] | |
4167 | +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } | |
4168 | +wheels = [ | |
4169 | + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, | |
4170 | +] | |
4171 | + | |
4172 | +[[package]] | |
4173 | +name = "sphinx-press-theme" | |
4174 | +version = "0.8.0" | |
4175 | +source = { registry = "https://pypi.org/simple" } | |
4176 | +dependencies = [ | |
4177 | + { name = "sphinx" }, | |
4178 | +] | |
4179 | +sdist = { url = "https://files.pythonhosted.org/packages/12/4b/73b14b65ba498e13c2c8eafac4d8ed9b750a916309116dae0a472dd23443/sphinx_press_theme-0.8.0.tar.gz", hash = "sha256:2884caab1dc01ecb11d158d4dd6d3179e2dd97cd48516c769cc27360272e62b3", size = 68878, upload-time = "2021-07-22T09:53:13.398Z" } | |
4180 | +wheels = [ | |
4181 | + { url = "https://files.pythonhosted.org/packages/61/84/f9c86260e39b59527dbf24daaadbbcfec822d0114e36bf27795e3a96be1f/sphinx_press_theme-0.8.0-py3-none-any.whl", hash = "sha256:ddf877d414a2c66e13396d726115aa3f0c94d1ac9133b4df028c261bf388ab25", size = 65875, upload-time = "2021-07-22T09:53:32.444Z" }, | |
4182 | +] | |
4183 | + | |
4184 | +[[package]] | |
4185 | +name = "sphinxcontrib-applehelp" | |
4186 | +version = "2.0.0" | |
4187 | +source = { registry = "https://pypi.org/simple" } | |
4188 | +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } | |
4189 | +wheels = [ | |
4190 | + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, | |
4191 | +] | |
4192 | + | |
4193 | +[[package]] | |
4194 | +name = "sphinxcontrib-devhelp" | |
4195 | +version = "2.0.0" | |
4196 | +source = { registry = "https://pypi.org/simple" } | |
4197 | +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } | |
4198 | +wheels = [ | |
4199 | + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, | |
4200 | +] | |
4201 | + | |
4202 | +[[package]] | |
4203 | +name = "sphinxcontrib-htmlhelp" | |
4204 | +version = "2.1.0" | |
4205 | +source = { registry = "https://pypi.org/simple" } | |
4206 | +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } | |
4207 | +wheels = [ | |
4208 | + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, | |
4209 | +] | |
4210 | + | |
4211 | +[[package]] | |
4212 | +name = "sphinxcontrib-jsmath" | |
4213 | +version = "1.0.1" | |
4214 | +source = { registry = "https://pypi.org/simple" } | |
4215 | +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } | |
4216 | +wheels = [ | |
4217 | + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, | |
4218 | +] | |
4219 | + | |
4220 | +[[package]] | |
4221 | +name = "sphinxcontrib-qthelp" | |
4222 | +version = "2.0.0" | |
4223 | +source = { registry = "https://pypi.org/simple" } | |
4224 | +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } | |
4225 | +wheels = [ | |
4226 | + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, | |
4227 | +] | |
4228 | + | |
4229 | +[[package]] | |
4230 | +name = "sphinxcontrib-serializinghtml" | |
4231 | +version = "2.0.0" | |
4232 | +source = { registry = "https://pypi.org/simple" } | |
4233 | +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } | |
4234 | +wheels = [ | |
4235 | + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, | |
4236 | +] | |
4237 | + | |
4068 | 4238 | [[package]] |
4069 | 4239 | name = "sqlalchemy" |
4070 | 4240 | version = "2.0.51" |
4071 | 4241 | |