swadhinbiswas

[email protected]
2026-09-13 00:15:53 +0600 committed NaN years ago
101b26d
commit 101b26d
9 files changed
+483 -54
▼.github/workflows/lake.yml
+0-3
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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