crossroads-uk 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
crossroads/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Crossroads-UK: reproducible UK road-safety / weather / boundary data pipeline."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
4
+
5
+ from crossroads.client import Client, init_engine
6
+
7
+ __all__ = ["Client", "init_engine", "SCHEMA_VERSION"]
8
+
9
+ # The version is derived from git tags (via hatch-vcs) and frozen into the
10
+ # installed package's metadata at install/build time. We read it back from that
11
+ # metadata here rather than hardcoding it, so the number can never drift and is
12
+ # available at runtime whether or not git is present. If the package is not
13
+ # installed (e.g. running straight from a source checkout with no install), fall
14
+ # back to a clearly-marked placeholder.
15
+ try:
16
+ __version__ = _pkg_version("crossroads-uk")
17
+ except PackageNotFoundError: # not installed; no metadata to read
18
+ __version__ = "0.0.0+unknown"
19
+
20
+ # Monotonic integer describing the physical shape of the built database (tables, columns,
21
+ # views). Increment by 1 on ANY schema change (new column, new table, new datasource,
22
+ # renamed/removed field). It is a plain literal here (hand-maintained), independent of the
23
+ # git-derived package version. A schema change is also a MINOR (or MAJOR, if breaking)
24
+ # release — see CHANGELOG.md.
25
+ SCHEMA_VERSION = 4
crossroads/client.py ADDED
@@ -0,0 +1,81 @@
1
+ """The pipeline orchestrator and database controller."""
2
+
3
+ import duckdb
4
+
5
+ from crossroads import quality
6
+ from crossroads.registry import Registry
7
+
8
+
9
+ class Client:
10
+ """Owns the DuckDB connection and drives the transformer registry.
11
+
12
+ With no transformers registered, ``build`` is a clean no-op: it opens a usable
13
+ connection, runs an empty loop, and returns. Real data sources are added by
14
+ dropping modules into ``crossroads.transformers`` (no change to this class).
15
+ """
16
+
17
+ def __init__(self, database_path: str = ":memory:", cache_dir: str = ".crossroads_cache"):
18
+ self.database_path = database_path
19
+ self.cache_dir = cache_dir
20
+ self.registry = Registry()
21
+ self.con = None
22
+
23
+ def build(self, **kwargs) -> "Client":
24
+ """Open the database and run the extract/transform_and_load loop.
25
+
26
+ ``**kwargs`` (e.g. ``datasets=["stats19", "era5_weather"]``, ``years=[...]``,
27
+ ``boundary_mode="snapshot"``) are forwarded to each transformer's
28
+ ``is_active`` and ``extract`` methods. An optional ``reject_ceiling``
29
+ kwarg overrides the global default reject-rate ceiling. Returns ``self``.
30
+
31
+ At build end the shared data-quality invariants run (spec §9); any
32
+ violation raises and halts the build.
33
+ """
34
+ self.con = duckdb.connect(self.database_path)
35
+ # Load the DuckDB Spatial Extension once, as foundational infrastructure
36
+ # (spec §5 Phase 1). It is generic (names no data source, so provider-plugin
37
+ # purity holds), idempotent, and cheap. INSTALL needs the network only on the
38
+ # first run on a machine; thereafter the extension is cached locally. Every
39
+ # spatial source (boundaries now, weather later) relies on this being loaded.
40
+ self.con.execute("INSTALL spatial")
41
+ self.con.execute("LOAD spatial")
42
+ # Create the shared audit tables up-front so transformers can write to them.
43
+ quality.ensure_quality_tables(self.con)
44
+
45
+ active = self.registry.get_active(**kwargs)
46
+ for transformer in active:
47
+ # Clear this source's rows from the shared audit tables before it is
48
+ # (re)built, so a re-build against an existing on-disk database stays
49
+ # idempotent (log_exclusion / quarantine_row are plain appends). The
50
+ # transformer is responsible for recreating its own bronze/silver.
51
+ # A transformer may write audit rows under several source_ids (e.g.
52
+ # STATS19's collision/vehicle/casualty) — reset each one.
53
+ for source_id in quality.declared_source_ids(transformer):
54
+ quality.reset_source_audit(self.con, source_id)
55
+ transformer.extract(self.cache_dir, **kwargs)
56
+ transformer.transform_and_load(self.con, self.cache_dir)
57
+
58
+ # Coverage gate: resolve each active source's quality_spec() decision
59
+ # (audit / explicit exemption / undecided), then run the build-end
60
+ # invariants (conservation, flag/ledger agreement, reject-rate tripwire).
61
+ # Both the gate and the invariants are fatal on violation.
62
+ specs = quality.resolve_quality_specs(self.con, active)
63
+ default_ceiling = kwargs.get("reject_ceiling") or quality.DEFAULT_REJECT_CEILING
64
+ quality.run_invariants(self.con, specs, default_ceiling=default_ceiling)
65
+ # Stamp build provenance LAST, so only a database that passed the invariants is recorded.
66
+ quality.write_build_metadata(self.con, parameters=kwargs)
67
+ return self
68
+
69
+ def close(self) -> None:
70
+ """Close the DuckDB connection if open."""
71
+ if self.con is not None:
72
+ self.con.close()
73
+ self.con = None
74
+
75
+
76
+ def init_engine(database_path: str = ":memory:", cache_dir: str = ".crossroads_cache") -> Client:
77
+ """Initialize a local Crossroads engine instance.
78
+
79
+ Mirrors the spec §8 target flow: ``client = cr.init_engine(database_path="local.db")``.
80
+ """
81
+ return Client(database_path=database_path, cache_dir=cache_dir)
crossroads/console.py ADDED
@@ -0,0 +1,448 @@
1
+ """Interactive data-compilation wizard (spec §6).
2
+
3
+ A terminal wizard that gathers build parameters, confirms them, and drives
4
+ ``crossroads.init_engine(...).build(...)``. All input/output is injected
5
+ (``reader``/``writer``) so the wizard is driven by scripted input in tests with
6
+ no real stdin/stdout and no network. Production wires ``reader = lambda: input()``
7
+ and ``writer = print`` (see ``main`` below).
8
+ """
9
+
10
+ import os
11
+ from datetime import date
12
+
13
+ from crossroads import init_engine # used by run_build below
14
+
15
+ # One-line, non-blocking pointer shown before the build. Crossroads does not gate on
16
+ # data licences (see docs/data-sources.md for why); it only reminds the user they exist.
17
+ LICENCE_NOTICE = (
18
+ "Data licences & required attribution: see docs/data-sources.md "
19
+ "(you must attribute DfT/ONS/Copernicus sources when you publish)."
20
+ )
21
+
22
+
23
+ def available_datasets():
24
+ """Discover the user-selectable datasets for the wizard menu.
25
+
26
+ Returns a list of ``(source_id, display_name)`` in the registry's stable order.
27
+ Imported lazily so importing the console module stays cheap.
28
+ """
29
+ from crossroads.registry import Registry
30
+ return [(t.source_id, t.display_name) for t in Registry().selectable()]
31
+
32
+
33
+ def _prompt(reader, writer, message, *, parse, default=None):
34
+ """Ask, validate, and re-ask until ``parse`` accepts the input.
35
+
36
+ ``parse(raw)`` returns the cleaned value or raises ``ValueError`` with a
37
+ human-readable reason. An empty line falls back to ``default`` when one is set.
38
+ """
39
+ # Show the default in the label when one exists, e.g. "Boundary mode [snapshot]: ".
40
+ label = f"{message} [{default}]: " if default is not None else f"{message}: "
41
+ # Re-ask loop: keep prompting until parse() accepts the input. Only ValueError
42
+ # from parse() triggers a re-ask; anything else propagates as a real error.
43
+ while True:
44
+ writer(label)
45
+ raw = reader().strip()
46
+ if not raw and default is not None:
47
+ raw = str(default)
48
+ try:
49
+ return parse(raw)
50
+ except ValueError as exc:
51
+ writer(f" Invalid input: {exc}. Please try again.")
52
+
53
+
54
+ def _prompt_secret(secret_reader, writer, message):
55
+ """Show `message` via writer, then read one masked/secret line via secret_reader.
56
+
57
+ Same label-then-read shape as _prompt, but the value is read through the
58
+ injected secret_reader (getpass in production, scripted in tests) so it is
59
+ never echoed to the terminal.
60
+ """
61
+ writer(f"{message}: ")
62
+ return secret_reader().strip()
63
+
64
+
65
+ def _parse_database_path(raw):
66
+ if not raw:
67
+ raise ValueError("database path cannot be empty")
68
+ return raw
69
+
70
+
71
+ def prompt_database_path(reader, writer):
72
+ """Where to write the DuckDB file. ':memory:' is allowed for a throwaway build."""
73
+ return _prompt(reader, writer,
74
+ "Database file path", parse=_parse_database_path,
75
+ default="crossroads.db")
76
+
77
+
78
+ def _parse_one_index(token, count):
79
+ """Parse a single menu index token to an int and range-check it (1..count)."""
80
+ try:
81
+ i = int(token)
82
+ except ValueError:
83
+ raise ValueError(f"'{token}' is not a whole number")
84
+ if not (1 <= i <= count):
85
+ raise ValueError(f"{i} is not between 1 and {count}")
86
+ return i
87
+
88
+
89
+ def _parse_selection(raw, count):
90
+ """Parse a '1-3, 5' style menu selection into a sorted list of 1-based indices.
91
+
92
+ Same comma/space/range grammar as the years prompt: singles and tight-hyphen
93
+ ranges, deduped and sorted. Requires at least one index.
94
+ """
95
+ tokens = [t for t in raw.replace(",", " ").split() if t]
96
+ if not tokens:
97
+ raise ValueError("select at least one dataset, e.g. 1 or 1-3")
98
+ picked = set()
99
+ for t in tokens:
100
+ if "-" in t:
101
+ # A closed range like "1-3" (tight hyphen, no surrounding spaces).
102
+ start, _, end = t.partition("-")
103
+ lo = _parse_one_index(start, count)
104
+ hi = _parse_one_index(end, count)
105
+ if lo > hi:
106
+ raise ValueError(f"range '{t}' is backwards (start after end)")
107
+ picked.update(range(lo, hi + 1))
108
+ else:
109
+ picked.add(_parse_one_index(t, count))
110
+ return sorted(picked)
111
+
112
+
113
+ def prompt_datasets(reader, writer, available):
114
+ """Multi-select which datasets to build.
115
+
116
+ ``available`` is a list of ``(source_id, display_name)`` in stable order (from
117
+ ``available_datasets()``). Presents them numbered and returns the selected
118
+ ``source_id`` list, using the same range grammar as the years prompt.
119
+ """
120
+ if not available:
121
+ # Defensive: there is always at least one selectable dataset in practice.
122
+ raise ValueError("no selectable datasets are available")
123
+ writer("Which datasets would you like?")
124
+ for i, (_source_id, label) in enumerate(available, start=1):
125
+ writer(f" {i}. {label}")
126
+
127
+ def parse(raw):
128
+ indices = _parse_selection(raw, len(available))
129
+ # Map 1-based menu indices back to source_ids.
130
+ return [available[i - 1][0] for i in indices]
131
+
132
+ return _prompt(reader, writer,
133
+ "Datasets (e.g. 1 or 1-3, 5)", parse=parse, default=None)
134
+
135
+
136
+ _EARLIEST_STATS19_YEAR = 1979
137
+
138
+
139
+ def _parse_one_year(token, latest):
140
+ """Parse a single year token to an int and range-check it. Shared by singles and range endpoints."""
141
+ try:
142
+ y = int(token)
143
+ except ValueError:
144
+ raise ValueError(f"'{token}' is not a whole number")
145
+ if not (_EARLIEST_STATS19_YEAR <= y <= latest):
146
+ raise ValueError(f"{y} is outside {_EARLIEST_STATS19_YEAR}–{latest}")
147
+ return y
148
+
149
+
150
+ def _parse_years(raw):
151
+ # Split on commas and/or whitespace; ignore empty tokens from double separators.
152
+ tokens = [t for t in raw.replace(",", " ").split() if t]
153
+ if not tokens:
154
+ raise ValueError("enter at least one year or range, e.g. 2015-2018 2021")
155
+ latest = date.today().year
156
+ years = set()
157
+ for t in tokens:
158
+ if "-" in t:
159
+ # A closed range like "1990-2000" (tight hyphen, no surrounding spaces).
160
+ start, _, end = t.partition("-")
161
+ lo = _parse_one_year(start, latest)
162
+ hi = _parse_one_year(end, latest)
163
+ if lo > hi:
164
+ raise ValueError(f"range '{t}' is backwards (start after end)")
165
+ years.update(range(lo, hi + 1))
166
+ else:
167
+ years.add(_parse_one_year(t, latest))
168
+ return sorted(years)
169
+
170
+
171
+ def prompt_years(reader, writer):
172
+ """One or more collision years/ranges to ingest (STATS19 is inactive without years)."""
173
+ return _prompt(reader, writer,
174
+ "Years to ingest (e.g. 2015-2018 2021, space or comma separated)",
175
+ parse=_parse_years, default=None)
176
+
177
+
178
+ _BOUNDARY_MODES = {"snapshot", "temporal"}
179
+ _BOUNDARY_ALIASES = {"1": "snapshot", "2": "temporal"}
180
+
181
+
182
+ def _parse_boundary_mode(raw):
183
+ value = _BOUNDARY_ALIASES.get(raw, raw.lower())
184
+ if value not in _BOUNDARY_MODES:
185
+ raise ValueError("choose 'snapshot' (1) or 'temporal' (2)")
186
+ return value
187
+
188
+
189
+ def prompt_boundary_mode(reader, writer):
190
+ """Retrospective snapshot (latest ONS vintage) vs temporally-sliced boundaries."""
191
+ writer("Boundary mode: 1) snapshot = latest ONS boundaries (default); "
192
+ "2) temporal = boundaries as they were at each record's date.")
193
+ return _prompt(reader, writer,
194
+ "Boundary mode", parse=_parse_boundary_mode, default="snapshot")
195
+
196
+
197
+ def gather_parameters(reader, writer, *, available=None):
198
+ """Run the parameter prompts and return the build-parameter dict.
199
+
200
+ Keys map onto the build surface: ``database_path`` feeds
201
+ ``init_engine(database_path=...)``; ``datasets``, ``years`` and
202
+ ``boundary_mode`` feed ``client.build(...)``. ``available`` is the dataset menu
203
+ (list of ``(source_id, display_name)``); defaults to live discovery and is
204
+ injectable so tests supply a fixed menu.
205
+ """
206
+ if available is None:
207
+ available = available_datasets()
208
+ writer("Crossroads-UK — data compilation wizard")
209
+ writer("") # blank spacer line
210
+ return {
211
+ "database_path": prompt_database_path(reader, writer),
212
+ "datasets": prompt_datasets(reader, writer, available),
213
+ "years": prompt_years(reader, writer),
214
+ "boundary_mode": prompt_boundary_mode(reader, writer),
215
+ }
216
+
217
+
218
+ # --- Copernicus CDS credential handling (weather dataset) --------------------
219
+
220
+
221
+ def _cdsapirc_path():
222
+ """Location of the cdsapi credentials file in the user's home directory."""
223
+ return os.path.expanduser("~/.cdsapirc")
224
+
225
+
226
+ def _cds_key_present():
227
+ """True when cdsapi already has credentials, so the wizard should not prompt.
228
+
229
+ Matches the sources cdsapi.Client() actually reads: both CDSAPI_* environment
230
+ variables set, OR a ~/.cdsapirc file already on disk.
231
+ """
232
+ if os.environ.get("CDSAPI_URL") and os.environ.get("CDSAPI_KEY"):
233
+ return True
234
+ return os.path.exists(_cdsapirc_path())
235
+
236
+
237
+ def _write_cdsapirc(token, cds_home_url):
238
+ """Write ~/.cdsapirc with the fixed CDS url and the user's token.
239
+
240
+ Written atomically (temp file + os.replace) so a crash never leaves a half
241
+ file, and with owner-only permissions because it holds a credential. The
242
+ format mirrors weather.py's _missing_key_message so cdsapi.Client() reads it
243
+ unchanged.
244
+ """
245
+ path = _cdsapirc_path()
246
+ content = f"url: {cds_home_url}/api\nkey: {token}\n"
247
+ tmp = path + ".part"
248
+ with open(tmp, "w") as fh:
249
+ fh.write(content)
250
+ try:
251
+ os.chmod(tmp, 0o600) # owner read/write only; POSIX best-effort
252
+ except OSError:
253
+ pass # platforms without POSIX perms: skip silently
254
+ os.replace(tmp, path) # atomic promote
255
+
256
+
257
+ # --- Confirmation, build wiring, and the entry point ------------------------
258
+
259
+
260
+ def _parse_yes_no(raw):
261
+ value = raw.strip().lower()
262
+ if value in ("y", "yes"):
263
+ return True
264
+ if value in ("n", "no"):
265
+ return False
266
+ raise ValueError("answer 'y' or 'n'")
267
+
268
+
269
+ def prompt_confirm(reader, writer, *, message="Proceed with the build? (y/n)", default=True):
270
+ """Ask a yes/no question before a (possibly long) action.
271
+
272
+ Reused for both the build confirmation and the 'continue without weather' branch.
273
+ """
274
+ # Options are shown lowercase; the default is indicated by the "[y]"/"[n]"
275
+ # suffix that _prompt appends from the default value below.
276
+ return _prompt(reader, writer, message,
277
+ parse=_parse_yes_no, default=("y" if default else "n"))
278
+
279
+
280
+ def format_summary(params):
281
+ """Human-readable recap of the gathered parameters, shown before confirmation."""
282
+ years = ", ".join(str(y) for y in params["years"])
283
+ datasets = ", ".join(params["datasets"])
284
+ return (
285
+ "\nBuild summary:\n"
286
+ f" Database file : {params['database_path']}\n"
287
+ f" Datasets : {datasets}\n"
288
+ f" Years : {years}\n"
289
+ f" Boundary mode : {params['boundary_mode']}\n"
290
+ )
291
+
292
+
293
+ def run_build(params, *, engine_factory=init_engine, cache_dir=None, writer=None):
294
+ """Open the engine and run the build for the gathered parameters.
295
+
296
+ ``engine_factory`` defaults to ``crossroads.init_engine`` and is injectable so
297
+ tests can (a) record the exact call without doing work, or (b) point a real
298
+ build at a fixture-seeded ``cache_dir`` and run offline. Returns the Client.
299
+ """
300
+ # No-op writer when the caller doesn't supply one (e.g. a test recording calls).
301
+ say = writer or (lambda _line: None)
302
+ # cache_dir is threaded through only when the caller overrides it (tests);
303
+ # production leaves it None so init_engine uses its default cache location.
304
+ engine_kwargs = {"database_path": params["database_path"]}
305
+ if cache_dir is not None:
306
+ engine_kwargs["cache_dir"] = cache_dir
307
+
308
+ client = engine_factory(**engine_kwargs)
309
+ say("\nBuilding database — this may take a while for large year ranges...")
310
+ client.build(datasets=params["datasets"],
311
+ years=params["years"],
312
+ boundary_mode=params["boundary_mode"])
313
+ say(f"Done. Database written to {params['database_path']}")
314
+ return client
315
+
316
+
317
+ def ensure_weather_credentials(params, reader, secret_reader, writer):
318
+ """Make sure a CDS API key exists before a weather build; prompt and save one if not.
319
+
320
+ Returns True to proceed with the build, False to abort ('nothing to build').
321
+ May remove 'era5_weather' from params['datasets'] if the user chooses to
322
+ continue without weather. A no-op when weather is not selected or a key is
323
+ already configured — so a returning user never sees a prompt.
324
+ """
325
+ # Imported lazily so importing console stays cheap and to reuse the exact
326
+ # CDS constants/source id from the weather transformer (DRY).
327
+ from crossroads.transformers.weather import (
328
+ Era5WeatherTransformer, CDS_HOME_URL, ERA5_LAND_URL)
329
+ weather_id = Era5WeatherTransformer.source_id
330
+
331
+ if weather_id not in params["datasets"]:
332
+ return True # weather not selected: nothing to do
333
+ if _cds_key_present():
334
+ return True # already configured: no prompt
335
+
336
+ writer("") # spacer before the credential block
337
+ writer("Weather data needs a free Copernicus CDS API key, and none was found")
338
+ writer("on this machine (no ~/.cdsapirc and no CDSAPI_* environment variables).")
339
+ writer(f"Get your token from {CDS_HOME_URL} (log in -> your profile ->")
340
+ writer("'Personal Access Token').")
341
+
342
+ while True:
343
+ token = _prompt_secret(secret_reader, writer,
344
+ "Personal Access Token (leave blank to skip)")
345
+ if token:
346
+ try:
347
+ _write_cdsapirc(token, CDS_HOME_URL)
348
+ except OSError as exc:
349
+ # Rare (e.g. a read-only home directory). Don't crash with a raw
350
+ # traceback: explain the problem, show the file to create by hand,
351
+ # then abort cleanly.
352
+ writer("")
353
+ writer(f"Could not write {_cdsapirc_path()}: {exc}")
354
+ writer("Create it by hand with these two lines, then re-run:")
355
+ writer(f" url: {CDS_HOME_URL}/api")
356
+ writer(" key: <your token>")
357
+ return False
358
+ writer("Saved ~/.cdsapirc.")
359
+ writer("")
360
+ writer("Note: you must also accept the ERA5-Land licence once (free) at")
361
+ writer(f" {ERA5_LAND_URL}")
362
+ writer(" (Download tab -> Terms of use -> Accept)")
363
+ return True
364
+
365
+ # Blank token: offer to continue without weather. Default N re-asks for a key.
366
+ if prompt_confirm(reader, writer,
367
+ message="Continue without weather data? (y/n)",
368
+ default=False):
369
+ params["datasets"] = [d for d in params["datasets"] if d != weather_id]
370
+ if not params["datasets"]:
371
+ writer("Nothing to build — aborted.")
372
+ return False
373
+ writer("Continuing without weather data.")
374
+ return True
375
+ # 'n' or blank: loop back and re-ask for the token.
376
+
377
+
378
+ def run_wizard(reader, writer, *, secret_reader=None, engine_factory=init_engine,
379
+ cache_dir=None, available=None):
380
+ """Drive the full wizard. Returns the built Client, or None if the user declined.
381
+
382
+ All I/O is injected so this is fully testable with scripted input.
383
+ ``secret_reader`` reads the (masked) CDS token; it defaults to a getpass-backed
384
+ reader in production and is injected by tests. ``available`` overrides the
385
+ dataset menu for deterministic tests.
386
+ """
387
+ if secret_reader is None:
388
+ import getpass
389
+ # Label is printed via writer; pass an empty getpass prompt so nothing
390
+ # extra is shown, and the typed token is not echoed.
391
+ secret_reader = lambda: getpass.getpass("")
392
+
393
+ params = gather_parameters(reader, writer, available=available)
394
+
395
+ # Temporal + AADF only: an annual traffic average is attributed to the boundary
396
+ # in force at a mid-year (1 July) date, which is approximate in a year when a
397
+ # boundary changed. Surface that honestly and let the user opt out. (Snapshot,
398
+ # or temporal without traffic counts, has no such approximation — no warning.)
399
+ if params["boundary_mode"] == "temporal" and "aadf" in params["datasets"]:
400
+ writer("") # spacer
401
+ writer("Note: temporal mode attributes each traffic count to the area "
402
+ "boundaries in force at its mid-year point; this is approximate in a "
403
+ "year when a boundary changed.")
404
+ if not prompt_confirm(reader, writer, message="Continue? (y/n)", default=True):
405
+ writer("Aborted — no database was built.")
406
+ return None
407
+
408
+ if not ensure_weather_credentials(params, reader, secret_reader, writer):
409
+ writer("Aborted — no database was built.")
410
+ return None
411
+ writer(format_summary(params))
412
+ writer(LICENCE_NOTICE) # non-blocking reminder; not a prompt
413
+ if not prompt_confirm(reader, writer, default=True):
414
+ writer("Aborted — no database was built.")
415
+ return None
416
+ return run_build(params, engine_factory=engine_factory,
417
+ cache_dir=cache_dir, writer=writer)
418
+
419
+
420
+ def main(argv=None):
421
+ """Console entry point. Wires stdin/stdout, returns a process exit code.
422
+
423
+ Handles ``--version``/``-V`` and ``--help``/``-h`` before starting the wizard,
424
+ so a researcher can check the version without triggering the interactive flow.
425
+ Ctrl-C / EOF abort cleanly without a traceback.
426
+ """
427
+ import sys
428
+ from crossroads import __version__
429
+ args = sys.argv[1:] if argv is None else list(argv)
430
+ if args and args[0] in ("--version", "-V"):
431
+ print(f"crossroads {__version__}")
432
+ return 0
433
+ if args and args[0] in ("--help", "-h"):
434
+ print("Usage: crossroads # run the interactive build wizard\n"
435
+ " crossroads --version # print the version and exit")
436
+ return 0
437
+ reader = lambda: input() # prompt text is emitted via writer, so input() gets no prompt
438
+ writer = print
439
+ try:
440
+ client = run_wizard(reader, writer)
441
+ except (KeyboardInterrupt, EOFError):
442
+ writer("\nCancelled.")
443
+ return 130 # conventional exit code for SIGINT
444
+ # Close the client so DuckDB releases the file handle (build already wrote it).
445
+ # On the decline/abort path client is None, so there is nothing to close.
446
+ if client is not None:
447
+ client.close()
448
+ return 0
crossroads/net.py ADDED
@@ -0,0 +1,50 @@
1
+ """Shared HTTP download helper (spec §2 -- a build must fail fast, never hang).
2
+
3
+ Every source that fetches a file uses download_to_file so the four downloaders
4
+ behave identically:
5
+ * a socket-INACTIVITY timeout -- urlopen(timeout=...) applies to the connect and
6
+ to each blocking read, so the deadline is "no bytes for `timeout` seconds",
7
+ NOT a total-download budget. A large but healthy download keeps resetting the
8
+ clock as chunks arrive; only a genuinely stalled endpoint trips it.
9
+ * chunked streaming (shutil.copyfileobj) -- a 150 MB CSV is never read fully
10
+ into memory.
11
+ * an atomic temp-then-rename promote -- an interrupted download never leaves a
12
+ half-file that the cache check would trust.
13
+ * an optional validator run BEFORE the promote -- for content checks a status
14
+ code alone would miss (an HTML error page served as 200 OK).
15
+ """
16
+
17
+ import os
18
+ import shutil
19
+ import urllib.request
20
+
21
+ # Socket INACTIVITY timeout in seconds (not a wall-clock budget): fires only when
22
+ # no bytes arrive for this long. 120s is generous enough for a healthy large
23
+ # download yet fails fast on a stalled DfT/ONS endpoint.
24
+ HTTP_TIMEOUT_SECONDS = 120
25
+
26
+
27
+ def download_to_file(url, dest, *, timeout=HTTP_TIMEOUT_SECONDS, validator=None):
28
+ """Download `url` to `dest`, streamed and atomic.
29
+
30
+ Streams the response in chunks to `dest + '.part'`, optionally validates that
31
+ finished temp file, then atomically renames it onto `dest`. On ANY error the
32
+ temp file is removed, so a bad or interrupted response never poisons the cache.
33
+
34
+ validator: optional callable(tmp_path) -> None, run BEFORE the atomic promote.
35
+ Raise from it (e.g. ValueError) to reject the download; the temp file is then
36
+ removed and the exception propagates.
37
+ """
38
+ tmp = dest + ".part"
39
+ try:
40
+ # urlopen's timeout covers connect AND each socket read; copyfileobj issues
41
+ # repeated reads, so a stall between chunks raises rather than hanging.
42
+ with urllib.request.urlopen(url, timeout=timeout) as resp, open(tmp, "wb") as fh:
43
+ shutil.copyfileobj(resp, fh)
44
+ if validator is not None:
45
+ validator(tmp) # may raise to reject the download
46
+ os.replace(tmp, dest) # atomic within the same directory
47
+ except Exception:
48
+ if os.path.exists(tmp):
49
+ os.remove(tmp)
50
+ raise