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 +25 -0
- crossroads/client.py +81 -0
- crossroads/console.py +448 -0
- crossroads/net.py +50 -0
- crossroads/quality.py +626 -0
- crossroads/reference/README.md +119 -0
- crossroads/reference/ons_boundaries.json +32 -0
- crossroads/reference/stats19_codebook.csv +1131 -0
- crossroads/reference/stats19_columns.csv +100 -0
- crossroads/registry.py +142 -0
- crossroads/sql.py +25 -0
- crossroads/transformers/__init__.py +0 -0
- crossroads/transformers/aadf.py +314 -0
- crossroads/transformers/bank_holidays.py +123 -0
- crossroads/transformers/base.py +92 -0
- crossroads/transformers/spatial.py +341 -0
- crossroads/transformers/stats19.py +1086 -0
- crossroads/transformers/weather.py +385 -0
- crossroads_uk-1.0.0.dist-info/METADATA +279 -0
- crossroads_uk-1.0.0.dist-info/RECORD +23 -0
- crossroads_uk-1.0.0.dist-info/WHEEL +4 -0
- crossroads_uk-1.0.0.dist-info/entry_points.txt +2 -0
- crossroads_uk-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|