eolas-data 1.3.2__tar.gz → 1.3.5__tar.gz
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.
- {eolas_data-1.3.2 → eolas_data-1.3.5}/PKG-INFO +3 -3
- {eolas_data-1.3.2 → eolas_data-1.3.5}/README.md +1 -1
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/__init__.py +1 -1
- eolas_data-1.3.5/eolas_data/_regen_names.py +58 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/client.py +237 -68
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/meta.py +29 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/pyproject.toml +2 -2
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_as_arrow.py +1 -1
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_client.py +73 -1
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_progress.py +19 -0
- eolas_data-1.3.2/eolas_data/_regen_names.py +0 -57
- {eolas_data-1.3.2 → eolas_data-1.3.5}/.github/workflows/catalog-drift.yml +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/.github/workflows/publish.yml +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/.github/workflows/smoke.yml +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/.github/workflows/test.yml +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/.gitignore +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/_dataset_names.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/cdc.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/cli.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/console.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/dataset.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/exceptions.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/library.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/rows.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/schedule.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/search.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/scripts/preflight.sh +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_cdc_roundtrip.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_cli.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_keyring.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_library.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_meta.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_rows.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_schedule.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_search.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_smoke_live.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_sync_bulk.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_sync_changes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: eolas-data
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.5
|
|
4
4
|
Summary: Python client for the eolas.fyi statistical data API (NZ, Australia, OECD)
|
|
5
5
|
Project-URL: Homepage, https://eolas.fyi
|
|
6
6
|
Project-URL: Documentation, https://docs.eolas.fyi/
|
|
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Topic :: Scientific/Engineering
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Requires-Dist: pandas>=1.5
|
|
24
|
-
Requires-Dist: pyarrow>=
|
|
24
|
+
Requires-Dist: pyarrow>=24
|
|
25
25
|
Requires-Dist: requests>=2.28
|
|
26
26
|
Requires-Dist: rich>=13
|
|
27
27
|
Requires-Dist: tqdm>=4.60
|
|
@@ -231,7 +231,7 @@ r = client.sync_bulk("nz_cpi", path="nz_cpi.parquet")
|
|
|
231
231
|
path = client.download_bulk("treasury_fiscal_spending", path="t.parquet")
|
|
232
232
|
```
|
|
233
233
|
|
|
234
|
-
**Progress bars:** `
|
|
234
|
+
**Progress bars:** `get_local()` shows two phases in interactive sessions — a **download** byte bar while fetching from CDN, then a **read** spinner while Parquet/GeoParquet is loaded (often the slow part on multi-million-row geo datasets). Control with `progress=True` (both), `False` (neither), `"download"`, or `"read"`. Set `EOLAS_NO_PROGRESS=1` to suppress both in batch scripts. Cached files skip the download bar and print an informative message instead.
|
|
235
235
|
|
|
236
236
|
CLI mirror: `eolas download <name>` for one-shot, `eolas sync <name> [--watch hourly]` for an incremental check. Full docs: [docs.eolas.fyi/bulk-downloads/](https://docs.eolas.fyi/bulk-downloads/).
|
|
237
237
|
|
|
@@ -183,7 +183,7 @@ r = client.sync_bulk("nz_cpi", path="nz_cpi.parquet")
|
|
|
183
183
|
path = client.download_bulk("treasury_fiscal_spending", path="t.parquet")
|
|
184
184
|
```
|
|
185
185
|
|
|
186
|
-
**Progress bars:** `
|
|
186
|
+
**Progress bars:** `get_local()` shows two phases in interactive sessions — a **download** byte bar while fetching from CDN, then a **read** spinner while Parquet/GeoParquet is loaded (often the slow part on multi-million-row geo datasets). Control with `progress=True` (both), `False` (neither), `"download"`, or `"read"`. Set `EOLAS_NO_PROGRESS=1` to suppress both in batch scripts. Cached files skip the download bar and print an informative message instead.
|
|
187
187
|
|
|
188
188
|
CLI mirror: `eolas download <name>` for one-shot, `eolas sync <name> [--watch hourly]` for an incremental check. Full docs: [docs.eolas.fyi/bulk-downloads/](https://docs.eolas.fyi/bulk-downloads/).
|
|
189
189
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Regenerate ``_dataset_names.py`` from the live API.
|
|
2
|
+
|
|
3
|
+
Run before each release:
|
|
4
|
+
|
|
5
|
+
python -m eolas_data._regen_names
|
|
6
|
+
|
|
7
|
+
Uses the same API-key resolution as :class:`~eolas_data.client.Client`
|
|
8
|
+
(env var → keyring → ``~/.eolas/config.json``). The catalog endpoint
|
|
9
|
+
requires authentication.
|
|
10
|
+
|
|
11
|
+
Writes to the same file inside the package. Commit the result.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as _dt
|
|
16
|
+
import pathlib as _pathlib
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def regenerate() -> None:
|
|
20
|
+
from .client import Client
|
|
21
|
+
|
|
22
|
+
df = Client().list()
|
|
23
|
+
names = sorted(df["name"].tolist())
|
|
24
|
+
today = _dt.date.today().isoformat()
|
|
25
|
+
|
|
26
|
+
out = _pathlib.Path(__file__).with_name("_dataset_names.py")
|
|
27
|
+
lines: list[str] = []
|
|
28
|
+
lines.append('"""')
|
|
29
|
+
lines.append("Type stubs for dataset names.")
|
|
30
|
+
lines.append("")
|
|
31
|
+
lines.append("Auto-generated from https://api.eolas.fyi/v1/datasets at release time.")
|
|
32
|
+
lines.append(f"Snapshot: {today} ({len(names)} datasets).")
|
|
33
|
+
lines.append("Regenerate before each release with `python -m eolas_data._regen_names`.")
|
|
34
|
+
lines.append("")
|
|
35
|
+
lines.append("At runtime this is just a string — `Literal[...]` only constrains static type")
|
|
36
|
+
lines.append("checkers like mypy/pyright, so passing a name not in this list still works,")
|
|
37
|
+
lines.append("it just doesn't autocomplete.")
|
|
38
|
+
lines.append('"""')
|
|
39
|
+
lines.append("from typing import Literal")
|
|
40
|
+
lines.append("")
|
|
41
|
+
lines.append(f'CATALOG_SNAPSHOT_DATE = "{today}"')
|
|
42
|
+
lines.append(f"CATALOG_SNAPSHOT_COUNT = {len(names)}")
|
|
43
|
+
lines.append("")
|
|
44
|
+
lines.append("DatasetName = Literal[")
|
|
45
|
+
for n in names:
|
|
46
|
+
lines.append(f" {n!r},")
|
|
47
|
+
lines.append("]")
|
|
48
|
+
lines.append("")
|
|
49
|
+
lines.append("ALL_NAMES: tuple[str, ...] = (")
|
|
50
|
+
for n in names:
|
|
51
|
+
lines.append(f" {n!r},")
|
|
52
|
+
lines.append(")")
|
|
53
|
+
out.write_text("\n".join(lines) + "\n")
|
|
54
|
+
print(f"wrote {len(names)} datasets to {out}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
regenerate()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import datetime
|
|
4
5
|
import json
|
|
5
6
|
import logging
|
|
@@ -7,7 +8,10 @@ import os
|
|
|
7
8
|
import pathlib
|
|
8
9
|
import sys
|
|
9
10
|
from dataclasses import dataclass
|
|
10
|
-
from typing import Optional, Union
|
|
11
|
+
from typing import Literal, Optional, Union
|
|
12
|
+
|
|
13
|
+
ProgressControl = Union[bool, str, None]
|
|
14
|
+
ProgressPhase = Literal["download", "read"]
|
|
11
15
|
|
|
12
16
|
import pandas as pd
|
|
13
17
|
import requests
|
|
@@ -15,7 +19,7 @@ import requests
|
|
|
15
19
|
from .console import nag_json_transport_once
|
|
16
20
|
from .dataset import Dataset
|
|
17
21
|
from .library import resolve_library_dir
|
|
18
|
-
from .meta import attach_meta, split_meta
|
|
22
|
+
from .meta import attach_meta, merge_provenance, split_meta
|
|
19
23
|
from .exceptions import (
|
|
20
24
|
APIError,
|
|
21
25
|
AuthenticationError,
|
|
@@ -269,6 +273,8 @@ class Client:
|
|
|
269
273
|
*,
|
|
270
274
|
source: str = "",
|
|
271
275
|
meta: bool = True,
|
|
276
|
+
provenance: Optional[dict] = None,
|
|
277
|
+
data_sources: Optional[list] = None,
|
|
272
278
|
) -> "pd.DataFrame":
|
|
273
279
|
table_meta: dict = {}
|
|
274
280
|
column_meta = None
|
|
@@ -277,10 +283,14 @@ class Client:
|
|
|
277
283
|
table_meta, column_meta = split_meta(self._info_cached(name))
|
|
278
284
|
except Exception:
|
|
279
285
|
pass
|
|
286
|
+
if provenance:
|
|
287
|
+
table_meta = merge_provenance(table_meta, provenance)
|
|
288
|
+
if data_sources:
|
|
289
|
+
table_meta = {**table_meta, "data_sources": data_sources}
|
|
280
290
|
return attach_meta(
|
|
281
291
|
result,
|
|
282
292
|
name=str(name),
|
|
283
|
-
source=source,
|
|
293
|
+
source=source or (table_meta.get("source") or ""),
|
|
284
294
|
table_meta=table_meta,
|
|
285
295
|
column_meta=column_meta,
|
|
286
296
|
)
|
|
@@ -325,6 +335,19 @@ class Client:
|
|
|
325
335
|
"csv_gz": ".csv.gz",
|
|
326
336
|
"geoparquet": ".geo.parquet",
|
|
327
337
|
}
|
|
338
|
+
# Mirrors the API guard in datasets.py — unbounded live pulls on datasets
|
|
339
|
+
# above this row count (or with geometry) return HTTP 413.
|
|
340
|
+
_LARGE_DATASET_ROW_THRESHOLD = 100_000
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _bulk_export_allowed(meta: dict) -> bool:
|
|
344
|
+
return (meta.get("bulk_export_class") or "").lower() not in ("", "none")
|
|
345
|
+
|
|
346
|
+
@classmethod
|
|
347
|
+
def _live_pull_blocked(cls, meta: dict) -> bool:
|
|
348
|
+
"""True when limit=0 with no date bounds would hit the API 413 guard."""
|
|
349
|
+
row_count = int(meta.get("row_count_at_last_refresh") or 0)
|
|
350
|
+
return bool(meta.get("has_geometry")) or row_count > cls._LARGE_DATASET_ROW_THRESHOLD
|
|
328
351
|
|
|
329
352
|
@staticmethod
|
|
330
353
|
def _require_bulk_export(meta: dict, name: Union[str, "DatasetName"]) -> None:
|
|
@@ -343,7 +366,7 @@ class Client:
|
|
|
343
366
|
freshness: str = "auto",
|
|
344
367
|
format: str = "parquet",
|
|
345
368
|
path: Optional[Union[str, "pathlib.Path"]] = None,
|
|
346
|
-
progress:
|
|
369
|
+
progress: ProgressControl = None,
|
|
347
370
|
) -> "Union[pathlib.Path, bytes]":
|
|
348
371
|
"""Download a complete dataset as a single binary file.
|
|
349
372
|
|
|
@@ -368,14 +391,10 @@ class Client:
|
|
|
368
391
|
bytes. Pass a ``str`` or ``pathlib.Path`` to write to disk and
|
|
369
392
|
return the resolved path. Parent directories are created if
|
|
370
393
|
needed.
|
|
371
|
-
progress: Control the download progress bar.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
``True`` forces the bar on regardless (useful in log-tailing
|
|
376
|
-
scenarios). ``False`` forces it off. When ``path`` is
|
|
377
|
-
``None`` (bytes mode) progress is always disabled — there is
|
|
378
|
-
no file path to label.
|
|
394
|
+
progress: Control the download progress bar (``"download"`` phase).
|
|
395
|
+
See :meth:`get_local` for the full selector vocabulary
|
|
396
|
+
(``"read"`` applies only to :meth:`get_local`). When ``path``
|
|
397
|
+
is ``None`` (bytes mode) progress is always disabled.
|
|
379
398
|
|
|
380
399
|
Returns:
|
|
381
400
|
``pathlib.Path`` when ``path`` is set (the resolved, written path).
|
|
@@ -448,13 +467,13 @@ class Client:
|
|
|
448
467
|
out = pathlib.Path(path).expanduser().resolve()
|
|
449
468
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
450
469
|
|
|
451
|
-
show = self._resolve_show_progress(progress)
|
|
470
|
+
show = self._resolve_show_progress(progress, "download")
|
|
452
471
|
resp = self._raw_bulk_get(bulk_path, params=params, stream=True)
|
|
453
472
|
total = int(resp.headers.get("Content-Length", 0)) or None
|
|
454
473
|
self._stream_to_file_with_progress(
|
|
455
474
|
resp, out,
|
|
456
475
|
total_bytes=total,
|
|
457
|
-
label=out.name,
|
|
476
|
+
label=f"Downloading {out.name}",
|
|
458
477
|
show_progress=show,
|
|
459
478
|
)
|
|
460
479
|
return out
|
|
@@ -466,7 +485,7 @@ class Client:
|
|
|
466
485
|
path: Union[str, "pathlib.Path"],
|
|
467
486
|
format: str = "parquet",
|
|
468
487
|
freshness: str = "auto",
|
|
469
|
-
progress:
|
|
488
|
+
progress: ProgressControl = None,
|
|
470
489
|
) -> SyncResult:
|
|
471
490
|
"""Incrementally sync a bulk dataset file — only re-download when the snapshot changes.
|
|
472
491
|
|
|
@@ -495,11 +514,10 @@ class Client:
|
|
|
495
514
|
``"geoparquet"``.
|
|
496
515
|
freshness: ``"auto"`` (default), ``"monthly"``, or ``"current"``.
|
|
497
516
|
Passed verbatim to the bulk endpoint.
|
|
498
|
-
progress: Control the download progress bar.
|
|
499
|
-
|
|
500
|
-
``
|
|
501
|
-
|
|
502
|
-
is shown regardless of this setting.
|
|
517
|
+
progress: Control the download progress bar (``"download"`` phase).
|
|
518
|
+
See :meth:`get_local` for the full selector vocabulary.
|
|
519
|
+
When ``status="unchanged"`` no download bar is shown; an
|
|
520
|
+
informative cached-file message is printed instead.
|
|
503
521
|
|
|
504
522
|
Returns:
|
|
505
523
|
A :class:`SyncResult` dataclass with ``status``,
|
|
@@ -587,6 +605,7 @@ class Client:
|
|
|
587
605
|
and prev.get("snapshot_id") == current_sid
|
|
588
606
|
and out.exists()
|
|
589
607
|
):
|
|
608
|
+
print(f"Using cached {out.name} (up to date).", file=sys.stderr)
|
|
590
609
|
return SyncResult(
|
|
591
610
|
status="unchanged",
|
|
592
611
|
previous_snapshot_id=prev.get("snapshot_id"),
|
|
@@ -598,14 +617,14 @@ class Client:
|
|
|
598
617
|
# Download (atomic replace).
|
|
599
618
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
600
619
|
tmp = out.with_suffix(out.suffix + f".eolas-tmp-{os.urandom(4).hex()}")
|
|
601
|
-
show = self._resolve_show_progress(progress)
|
|
620
|
+
show = self._resolve_show_progress(progress, "download")
|
|
602
621
|
try:
|
|
603
622
|
resp = self._raw_bulk_get(bulk_path, params=params, stream=True)
|
|
604
623
|
total = int(resp.headers.get("Content-Length", 0)) or None
|
|
605
624
|
bytes_dl = self._stream_to_file_with_progress(
|
|
606
625
|
resp, tmp,
|
|
607
626
|
total_bytes=total,
|
|
608
|
-
label=out.name,
|
|
627
|
+
label=f"Downloading {out.name}",
|
|
609
628
|
show_progress=show,
|
|
610
629
|
)
|
|
611
630
|
if bytes_dl == 0:
|
|
@@ -1140,28 +1159,73 @@ class Client:
|
|
|
1140
1159
|
# ------------------------------------------------------------------
|
|
1141
1160
|
|
|
1142
1161
|
@staticmethod
|
|
1143
|
-
def
|
|
1144
|
-
"""
|
|
1145
|
-
|
|
1146
|
-
Priority (highest first):
|
|
1147
|
-
1. Explicit ``progress=True/False`` kwarg from the caller.
|
|
1148
|
-
2. ``EOLAS_NO_PROGRESS=1`` environment variable — always suppresses.
|
|
1149
|
-
3. Jupyter / IPython kernel detection — Jupyter wraps stdout in an
|
|
1150
|
-
``OutStream``, so ``sys.stdout.isatty()`` returns False even when
|
|
1151
|
-
the user is clearly in an interactive notebook session. If
|
|
1152
|
-
``ipykernel`` is loaded (the standard signal used by tqdm itself),
|
|
1153
|
-
we're in a notebook → show the bar; ``tqdm.auto`` then renders it
|
|
1154
|
-
as an ipywidget (or text bar as fallback).
|
|
1155
|
-
4. ``sys.stdout.isatty()`` auto-detection for plain terminals.
|
|
1156
|
-
"""
|
|
1157
|
-
if progress is not None:
|
|
1158
|
-
return bool(progress)
|
|
1162
|
+
def _progress_auto_detect() -> bool:
|
|
1163
|
+
"""Whether progress feedback should show when ``progress`` is None."""
|
|
1159
1164
|
if os.getenv("EOLAS_NO_PROGRESS", "").strip() in ("1", "true", "yes"):
|
|
1160
1165
|
return False
|
|
1161
1166
|
if "ipykernel" in sys.modules:
|
|
1162
1167
|
return True
|
|
1163
1168
|
return sys.stdout.isatty()
|
|
1164
1169
|
|
|
1170
|
+
@staticmethod
|
|
1171
|
+
def _resolve_progress_phases(progress: ProgressControl) -> dict[str, bool]:
|
|
1172
|
+
"""Map ``progress`` to download/read phase flags.
|
|
1173
|
+
|
|
1174
|
+
Accepts ``None``, ``True``/``False``, or ``"both"``, ``"download"``,
|
|
1175
|
+
``"read"``, ``"none"`` (and ``"all"`` as alias for ``"both"``).
|
|
1176
|
+
"""
|
|
1177
|
+
if progress is False:
|
|
1178
|
+
return {"download": False, "read": False}
|
|
1179
|
+
if progress is True:
|
|
1180
|
+
return {"download": True, "read": True}
|
|
1181
|
+
if isinstance(progress, str):
|
|
1182
|
+
key = progress.strip().lower()
|
|
1183
|
+
table = {
|
|
1184
|
+
"both": (True, True),
|
|
1185
|
+
"all": (True, True),
|
|
1186
|
+
"download": (True, False),
|
|
1187
|
+
"read": (False, True),
|
|
1188
|
+
"none": (False, False),
|
|
1189
|
+
}
|
|
1190
|
+
if key not in table:
|
|
1191
|
+
raise ValueError(
|
|
1192
|
+
"progress must be True, False, None, or one of "
|
|
1193
|
+
"'both', 'download', 'read', 'none'."
|
|
1194
|
+
)
|
|
1195
|
+
d, r = table[key]
|
|
1196
|
+
return {"download": d, "read": r}
|
|
1197
|
+
auto = Client._progress_auto_detect()
|
|
1198
|
+
return {"download": auto, "read": auto}
|
|
1199
|
+
|
|
1200
|
+
@staticmethod
|
|
1201
|
+
def _resolve_show_progress(
|
|
1202
|
+
progress: ProgressControl,
|
|
1203
|
+
phase: ProgressPhase = "download",
|
|
1204
|
+
) -> bool:
|
|
1205
|
+
"""Resolve ``progress`` for one bulk phase (download or read)."""
|
|
1206
|
+
return Client._resolve_progress_phases(progress)[phase]
|
|
1207
|
+
|
|
1208
|
+
@staticmethod
|
|
1209
|
+
@contextlib.contextmanager
|
|
1210
|
+
def _with_read_progress(label: str, show: bool):
|
|
1211
|
+
"""Indeterminate spinner while materialising a cached bulk file."""
|
|
1212
|
+
if not show:
|
|
1213
|
+
yield
|
|
1214
|
+
return
|
|
1215
|
+
import tqdm.auto
|
|
1216
|
+
|
|
1217
|
+
bar = tqdm.auto.tqdm(
|
|
1218
|
+
total=1,
|
|
1219
|
+
desc=f"Loading {label} from disk",
|
|
1220
|
+
leave=False,
|
|
1221
|
+
bar_format="{desc}…",
|
|
1222
|
+
)
|
|
1223
|
+
try:
|
|
1224
|
+
yield
|
|
1225
|
+
finally:
|
|
1226
|
+
bar.update(1)
|
|
1227
|
+
bar.close()
|
|
1228
|
+
|
|
1165
1229
|
@staticmethod
|
|
1166
1230
|
def _stream_to_file_with_progress(
|
|
1167
1231
|
resp: "requests.Response",
|
|
@@ -1777,7 +1841,7 @@ class Client:
|
|
|
1777
1841
|
as_geo: Optional[bool] = None,
|
|
1778
1842
|
as_arrow: bool = False,
|
|
1779
1843
|
meta: bool = True,
|
|
1780
|
-
progress:
|
|
1844
|
+
progress: ProgressControl = None,
|
|
1781
1845
|
) -> "pd.DataFrame":
|
|
1782
1846
|
"""Download (or serve from cache) a whole dataset as a local DataFrame.
|
|
1783
1847
|
|
|
@@ -1814,7 +1878,14 @@ class Client:
|
|
|
1814
1878
|
meta: When ``True`` (default), attach table/column metadata from
|
|
1815
1879
|
:meth:`info` (session-cached). Pass ``False`` to skip the extra
|
|
1816
1880
|
round-trip.
|
|
1817
|
-
progress:
|
|
1881
|
+
progress: Control progress for two phases: **download** (streaming
|
|
1882
|
+
byte bar via :meth:`sync_bulk`) and **read** (spinner while
|
|
1883
|
+
Parquet/GeoParquet is loaded into a DataFrame). ``None``
|
|
1884
|
+
(default) enables both in interactive sessions. ``True``/``False``
|
|
1885
|
+
force both on/off. Use ``"download"``, ``"read"``, ``"both"``,
|
|
1886
|
+
or ``"none"`` for one phase only. Suppressed by
|
|
1887
|
+
``EOLAS_NO_PROGRESS=1``. Cached snapshots skip the download bar
|
|
1888
|
+
and print an informative message instead.
|
|
1818
1889
|
|
|
1819
1890
|
Returns:
|
|
1820
1891
|
``pd.DataFrame`` (tabular) or ``geopandas.GeoDataFrame`` (geo +
|
|
@@ -1877,30 +1948,52 @@ class Client:
|
|
|
1877
1948
|
# ---- sync (download if needed, HEAD check if cached) -----------------
|
|
1878
1949
|
self.sync_bulk(name, path=file_path, format=fmt, freshness=freshness, progress=progress)
|
|
1879
1950
|
|
|
1951
|
+
show_read = self._resolve_show_progress(progress, "read")
|
|
1952
|
+
read_label = file_path.name
|
|
1953
|
+
|
|
1880
1954
|
# ---- read the local file into a DataFrame ----------------------------
|
|
1881
1955
|
if as_arrow:
|
|
1882
1956
|
import pyarrow.parquet as _pq
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1957
|
+
with self._with_read_progress(read_label, show_read):
|
|
1958
|
+
if fmt == "csv_gz":
|
|
1959
|
+
import pyarrow as _pa
|
|
1960
|
+
return _pa.Table.from_pandas(
|
|
1961
|
+
pd.read_csv(file_path), preserve_index=False,
|
|
1962
|
+
)
|
|
1963
|
+
return _pq.read_table(file_path)
|
|
1964
|
+
|
|
1965
|
+
def _read_bulk_file(path: pathlib.Path, bulk_fmt: str):
|
|
1966
|
+
with self._with_read_progress(read_label, show_read):
|
|
1967
|
+
if bulk_fmt == "csv_gz":
|
|
1968
|
+
return pd.read_csv(path)
|
|
1969
|
+
if bulk_fmt == "geoparquet" and resolved_as_geo:
|
|
1970
|
+
try:
|
|
1971
|
+
import geopandas as gpd
|
|
1972
|
+
return gpd.read_parquet(path)
|
|
1973
|
+
except ImportError:
|
|
1974
|
+
pass
|
|
1975
|
+
return pd.read_parquet(path)
|
|
1976
|
+
|
|
1977
|
+
try:
|
|
1978
|
+
result = _read_bulk_file(file_path, fmt)
|
|
1979
|
+
except OSError as exc:
|
|
1980
|
+
# Bulk snapshots are written with pyarrow 24+ (Parquet 2.6). Older
|
|
1981
|
+
# pyarrow builds fail with "Repetition level histogram size mismatch".
|
|
1982
|
+
# csv_gz is always generated alongside parquet — fall back to it.
|
|
1983
|
+
if fmt in ("parquet", "geoparquet") and "histogram" in str(exc).lower():
|
|
1984
|
+
_log.warning(
|
|
1985
|
+
"Parquet read failed for %s (%s) — falling back to csv_gz bulk",
|
|
1986
|
+
name, exc,
|
|
1987
|
+
)
|
|
1988
|
+
csv_path = cache_path / f"{name}{self._BULK_EXTENSIONS['csv_gz']}"
|
|
1989
|
+
self.sync_bulk(
|
|
1990
|
+
name, path=csv_path, format="csv_gz",
|
|
1991
|
+
freshness=freshness, progress=progress,
|
|
1992
|
+
)
|
|
1993
|
+
result = _read_bulk_file(csv_path, "csv_gz")
|
|
1994
|
+
else:
|
|
1995
|
+
raise
|
|
1996
|
+
return self._attach_dataset_meta(result, name, meta=meta)
|
|
1904
1997
|
|
|
1905
1998
|
# ------------------------------------------------------------------
|
|
1906
1999
|
# Core data fetch
|
|
@@ -1917,6 +2010,7 @@ class Client:
|
|
|
1917
2010
|
as_geo: Optional[bool] = None,
|
|
1918
2011
|
as_arrow: bool = False,
|
|
1919
2012
|
meta: bool = True,
|
|
2013
|
+
envelope: bool = False,
|
|
1920
2014
|
) -> Dataset:
|
|
1921
2015
|
"""Fetch dataset rows as a pandas (or polars / geopandas) DataFrame.
|
|
1922
2016
|
|
|
@@ -1954,6 +2048,9 @@ class Client:
|
|
|
1954
2048
|
meta: When ``True`` (default), attach table/column metadata from
|
|
1955
2049
|
:meth:`info` (session-cached on this client). Pass
|
|
1956
2050
|
``False`` to skip the extra round-trip.
|
|
2051
|
+
envelope: When ``True``, request ``?envelope=1`` (JSON only) and
|
|
2052
|
+
attach the ``data_sources`` licence block alongside rows.
|
|
2053
|
+
Response ``X-Eolas-*`` headers are merged into metadata.
|
|
1957
2054
|
|
|
1958
2055
|
Returns:
|
|
1959
2056
|
A :class:`Dataset` (pandas DataFrame subclass), a polars DataFrame
|
|
@@ -1976,6 +2073,45 @@ class Client:
|
|
|
1976
2073
|
"as_geo materialises geometry as shapely objects in a GeoDataFrame. "
|
|
1977
2074
|
"Choose one."
|
|
1978
2075
|
)
|
|
2076
|
+
if envelope and (format != "json" or as_arrow):
|
|
2077
|
+
raise ValueError(
|
|
2078
|
+
"envelope=True requires format='json' and as_arrow=False."
|
|
2079
|
+
)
|
|
2080
|
+
|
|
2081
|
+
from .rows import apply_row_limit, resolve_fetch_limit, sort_by_date
|
|
2082
|
+
|
|
2083
|
+
# ---- whole-dataset pull on large/geo tables → bulk cache -------------
|
|
2084
|
+
# Matches the API 413 guard: limit=0 with no start/end on >100k-row or
|
|
2085
|
+
# geometry datasets is refused. Transparently serve from get_local()
|
|
2086
|
+
# (CDN-backed Parquet/GeoParquet) so client.get("nz_addresses") works.
|
|
2087
|
+
if (
|
|
2088
|
+
limit is None
|
|
2089
|
+
and start is None
|
|
2090
|
+
and end is None
|
|
2091
|
+
and format == "json"
|
|
2092
|
+
and not envelope
|
|
2093
|
+
and not as_arrow
|
|
2094
|
+
):
|
|
2095
|
+
try:
|
|
2096
|
+
info_meta = self._info_cached(name)
|
|
2097
|
+
if self._bulk_export_allowed(info_meta) and self._live_pull_blocked(info_meta):
|
|
2098
|
+
result = self.get_local(
|
|
2099
|
+
name, as_geo=as_geo, as_arrow=False, meta=meta,
|
|
2100
|
+
)
|
|
2101
|
+
if engine == "polars":
|
|
2102
|
+
try:
|
|
2103
|
+
import polars as pl
|
|
2104
|
+
return pl.from_pandas(result)
|
|
2105
|
+
except ImportError:
|
|
2106
|
+
raise ImportError(
|
|
2107
|
+
"polars is required for engine='polars'. "
|
|
2108
|
+
"Install with: pip install eolas-data[polars]"
|
|
2109
|
+
)
|
|
2110
|
+
return result
|
|
2111
|
+
except (BulkLicenceRestricted, BulkUpgradeRequired, BulkNotYetAvailable):
|
|
2112
|
+
raise
|
|
2113
|
+
except Exception:
|
|
2114
|
+
pass # fall through to live path (e.g. metadata lookup failed)
|
|
1979
2115
|
|
|
1980
2116
|
# ---- early in-memory cache check -------------------------------------
|
|
1981
2117
|
_early_cache_key = f"{name}:{start}:{end}:{format}:{0 if limit is None else int(limit)}:{as_geo}"
|
|
@@ -1988,21 +2124,35 @@ class Client:
|
|
|
1988
2124
|
params["start"] = start
|
|
1989
2125
|
if end:
|
|
1990
2126
|
params["end"] = end
|
|
1991
|
-
from .rows import apply_row_limit, resolve_fetch_limit, sort_by_date
|
|
1992
2127
|
|
|
1993
2128
|
fetch_limit, user_limit = resolve_fetch_limit(limit)
|
|
2129
|
+
# Positive limits on large/geo datasets must be sent to the API — the
|
|
2130
|
+
# client normally uses limit=0 and trims client-side for dated series,
|
|
2131
|
+
# but limit=0 triggers the API 413 guard on geometry / >100k tables.
|
|
2132
|
+
if user_limit and int(user_limit) > 0 and start is None and end is None:
|
|
2133
|
+
try:
|
|
2134
|
+
info_meta = self._info_cached(name)
|
|
2135
|
+
if self._live_pull_blocked(info_meta):
|
|
2136
|
+
fetch_limit = int(user_limit)
|
|
2137
|
+
except Exception:
|
|
2138
|
+
pass
|
|
1994
2139
|
params["limit"] = fetch_limit
|
|
1995
2140
|
|
|
1996
2141
|
cache_key = f"{name}:{start}:{end}:{format}:{0 if limit is None else int(limit)}:{as_geo}"
|
|
1997
2142
|
if self._cache is not None and cache_key in self._cache:
|
|
1998
2143
|
return self._cache[cache_key]
|
|
1999
2144
|
|
|
2145
|
+
data_sources = None
|
|
2146
|
+
provenance = None
|
|
2000
2147
|
if format == "csv":
|
|
2001
2148
|
from io import StringIO
|
|
2002
2149
|
resp = self._raw_get(f"/v1/datasets/{name}/data", params={"format": "csv", **params})
|
|
2150
|
+
provenance = resp.headers
|
|
2003
2151
|
df = pd.read_csv(StringIO(resp.text))
|
|
2004
2152
|
else:
|
|
2005
|
-
df = self._fetch_dataframe(
|
|
2153
|
+
df, provenance, data_sources = self._fetch_dataframe(
|
|
2154
|
+
name, params, envelope=envelope,
|
|
2155
|
+
)
|
|
2006
2156
|
if "date" in df.columns:
|
|
2007
2157
|
df["date"] = pd.to_datetime(df["date"])
|
|
2008
2158
|
|
|
@@ -2020,7 +2170,10 @@ class Client:
|
|
|
2020
2170
|
return tbl
|
|
2021
2171
|
|
|
2022
2172
|
result = Dataset(df)
|
|
2023
|
-
result = self._attach_dataset_meta(
|
|
2173
|
+
result = self._attach_dataset_meta(
|
|
2174
|
+
result, name, meta=meta,
|
|
2175
|
+
provenance=provenance, data_sources=data_sources,
|
|
2176
|
+
)
|
|
2024
2177
|
|
|
2025
2178
|
if engine == "polars":
|
|
2026
2179
|
try:
|
|
@@ -2048,13 +2201,27 @@ class Client:
|
|
|
2048
2201
|
# HTTP helpers
|
|
2049
2202
|
# ------------------------------------------------------------------
|
|
2050
2203
|
|
|
2051
|
-
def _fetch_dataframe(
|
|
2204
|
+
def _fetch_dataframe(
|
|
2205
|
+
self, name, params: dict, *, envelope: bool = False,
|
|
2206
|
+
) -> tuple[pd.DataFrame, object, Optional[list]]:
|
|
2052
2207
|
"""Fetch dataset rows as a DataFrame, negotiating Arrow IPC over the
|
|
2053
2208
|
wire (≈5x faster end-to-end, ≈82x faster parse than JSON on large
|
|
2054
2209
|
pulls — benchmarked 2026-05-18). Transparently falls back to JSON for
|
|
2055
2210
|
older servers, unexpected content-types, or any pyarrow issue, so the
|
|
2056
2211
|
returned DataFrame is identical either way.
|
|
2212
|
+
|
|
2213
|
+
Returns ``(dataframe, response_headers, data_sources_or_none)``.
|
|
2057
2214
|
"""
|
|
2215
|
+
if envelope:
|
|
2216
|
+
resp = self._raw_get(
|
|
2217
|
+
f"/v1/datasets/{name}/data",
|
|
2218
|
+
params={**params, "envelope": 1},
|
|
2219
|
+
)
|
|
2220
|
+
payload = resp.json()
|
|
2221
|
+
records = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
2222
|
+
sources = payload.get("data_sources") if isinstance(payload, dict) else None
|
|
2223
|
+
return pd.DataFrame(records), resp.headers, sources
|
|
2224
|
+
|
|
2058
2225
|
if self._arrow_supported is not False:
|
|
2059
2226
|
try:
|
|
2060
2227
|
import io
|
|
@@ -2067,7 +2234,7 @@ class Client:
|
|
|
2067
2234
|
if "arrow" in resp.headers.get("content-type", ""):
|
|
2068
2235
|
self._arrow_supported = True
|
|
2069
2236
|
tbl = pa.ipc.open_stream(io.BytesIO(resp.content)).read_all()
|
|
2070
|
-
return tbl.to_pandas()
|
|
2237
|
+
return tbl.to_pandas(), resp.headers, None
|
|
2071
2238
|
# Old server ignored format=arrow and returned JSON. Remember
|
|
2072
2239
|
# so we don't pay the failed round-trip on every future call.
|
|
2073
2240
|
self._arrow_supported = False
|
|
@@ -2076,9 +2243,11 @@ class Client:
|
|
|
2076
2243
|
self._arrow_supported = False
|
|
2077
2244
|
nag_json_transport_once()
|
|
2078
2245
|
|
|
2079
|
-
|
|
2246
|
+
resp = self._raw_get(f"/v1/datasets/{name}/data", params=params)
|
|
2247
|
+
data = resp.json()
|
|
2080
2248
|
records = data.get("data", data) if isinstance(data, dict) else data
|
|
2081
|
-
|
|
2249
|
+
sources = data.get("data_sources") if isinstance(data, dict) else None
|
|
2250
|
+
return pd.DataFrame(records), resp.headers, sources
|
|
2082
2251
|
|
|
2083
2252
|
def _get(self, path: str, params: Optional[dict] = None) -> dict:
|
|
2084
2253
|
return self._raw_get(path, params=params).json()
|
|
@@ -6,6 +6,35 @@ from typing import Any, Optional
|
|
|
6
6
|
import pandas as pd
|
|
7
7
|
|
|
8
8
|
# Table-level fields we keep on Dataset.eolas_meta (exclude nested columns).
|
|
9
|
+
_PROVENANCE_HEADERS = {
|
|
10
|
+
"X-Eolas-Attribution": "attribution_text",
|
|
11
|
+
"X-Eolas-Licence": "licence",
|
|
12
|
+
"X-Eolas-Source": "source",
|
|
13
|
+
"X-Eolas-Source-URL": "source_url",
|
|
14
|
+
"X-Eolas-Namespace": "namespace",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def provenance_from_headers(headers) -> dict:
|
|
19
|
+
"""Extract response provenance from X-Eolas-* headers on /data responses."""
|
|
20
|
+
out: dict[str, str] = {}
|
|
21
|
+
get = headers.get if hasattr(headers, "get") else lambda k, d=None: headers.get(k, d)
|
|
22
|
+
for hdr, key in _PROVENANCE_HEADERS.items():
|
|
23
|
+
val = get(hdr)
|
|
24
|
+
if val:
|
|
25
|
+
out[key] = str(val)
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def merge_provenance(table_meta: Optional[dict], headers) -> dict:
|
|
30
|
+
"""Merge catalogue metadata with live response headers (headers win when set)."""
|
|
31
|
+
merged = dict(table_meta or {})
|
|
32
|
+
for key, val in provenance_from_headers(headers).items():
|
|
33
|
+
if val:
|
|
34
|
+
merged[key] = val
|
|
35
|
+
return merged
|
|
36
|
+
|
|
37
|
+
|
|
9
38
|
_TABLE_META_KEYS = (
|
|
10
39
|
"name",
|
|
11
40
|
"namespace",
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "eolas-data"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.5"
|
|
8
8
|
description = "Python client for the eolas.fyi statistical data API (NZ, Australia, OECD)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -13,7 +13,7 @@ requires-python = ">=3.10"
|
|
|
13
13
|
dependencies = [
|
|
14
14
|
"requests>=2.28",
|
|
15
15
|
"pandas>=1.5",
|
|
16
|
-
"pyarrow>=
|
|
16
|
+
"pyarrow>=24",
|
|
17
17
|
"tqdm>=4.60",
|
|
18
18
|
"rich>=13",
|
|
19
19
|
# typer powers the `eolas` console script, which is always registered in
|
|
@@ -44,7 +44,7 @@ def test_get_live_as_arrow_returns_arrow_table(client):
|
|
|
44
44
|
"""as_arrow=True on the live path converts the JSON-fetched DataFrame to pa.Table."""
|
|
45
45
|
fake_df = pd.DataFrame({"date": ["2023-01-01"], "value": [1100.5]})
|
|
46
46
|
|
|
47
|
-
with patch.object(client, "_fetch_dataframe", return_value=fake_df):
|
|
47
|
+
with patch.object(client, "_fetch_dataframe", return_value=(fake_df, {}, None)):
|
|
48
48
|
result = client.get("nz_cpi", as_arrow=True)
|
|
49
49
|
|
|
50
50
|
assert isinstance(result, pa.Table)
|
|
@@ -153,9 +153,13 @@ def test_get_limit_returns_most_recent(client):
|
|
|
153
153
|
|
|
154
154
|
@resp_lib.activate
|
|
155
155
|
def test_get_limit_requests_full_window_from_server(client):
|
|
156
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi",
|
|
157
|
+
json={"name": "nz_cpi", "source": "Stats NZ", "has_geometry": False,
|
|
158
|
+
"row_count_at_last_refresh": 500})
|
|
156
159
|
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi/data", json={"data": RECORDS})
|
|
157
160
|
client.get("nz_cpi", limit=2)
|
|
158
|
-
|
|
161
|
+
data_reqs = [c for c in resp_lib.calls if "/data?" in c.request.url or c.request.url.endswith("/data")]
|
|
162
|
+
assert any("limit=0" in c.request.url for c in data_reqs)
|
|
159
163
|
|
|
160
164
|
|
|
161
165
|
@resp_lib.activate
|
|
@@ -167,6 +171,38 @@ def test_get_passes_date_params(client):
|
|
|
167
171
|
assert "end=2023-06-30" in req.url
|
|
168
172
|
|
|
169
173
|
|
|
174
|
+
@resp_lib.activate
|
|
175
|
+
def test_get_merges_provenance_headers(client):
|
|
176
|
+
resp_lib.add(
|
|
177
|
+
resp_lib.GET,
|
|
178
|
+
f"{BASE}/v1/datasets/nz_cpi/data",
|
|
179
|
+
json={"data": RECORDS},
|
|
180
|
+
adding_headers={
|
|
181
|
+
"X-Eolas-Attribution": "Source: OECD, CC-BY 4.0.",
|
|
182
|
+
"X-Eolas-Licence": "CC-BY 4.0",
|
|
183
|
+
"X-Eolas-Source": "OECD",
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
df = client.get("nz_cpi", meta=False)
|
|
187
|
+
meta = df.attrs.get("eolas_meta", {})
|
|
188
|
+
assert meta.get("attribution_text") == "Source: OECD, CC-BY 4.0."
|
|
189
|
+
assert meta.get("licence") == "CC-BY 4.0"
|
|
190
|
+
assert meta.get("source") == "OECD"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@resp_lib.activate
|
|
194
|
+
def test_get_envelope_attaches_data_sources(client):
|
|
195
|
+
payload = {
|
|
196
|
+
"data_sources": [{"name": "nz_cpi", "licence": "CC-BY 4.0"}],
|
|
197
|
+
"data": RECORDS,
|
|
198
|
+
}
|
|
199
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi/data", json=payload)
|
|
200
|
+
df = client.get("nz_cpi", envelope=True, meta=False)
|
|
201
|
+
meta = df.attrs.get("eolas_meta", {})
|
|
202
|
+
assert meta.get("data_sources") == payload["data_sources"]
|
|
203
|
+
assert "envelope=1" in resp_lib.calls[0].request.url
|
|
204
|
+
|
|
205
|
+
|
|
170
206
|
@resp_lib.activate
|
|
171
207
|
def test_get_csv_returns_dataframe(client):
|
|
172
208
|
csv_body = "date,period,value\n2023-01-01,2023Q1,100.0\n"
|
|
@@ -213,6 +249,42 @@ def test_get_as_geo_false_keeps_wkt(client):
|
|
|
213
249
|
assert df["geometry_wkt"].iloc[0].startswith("POINT")
|
|
214
250
|
|
|
215
251
|
|
|
252
|
+
LARGE_GEO_META = {
|
|
253
|
+
"name": "nz_addresses",
|
|
254
|
+
"namespace": "linz",
|
|
255
|
+
"bulk_export_class": "materialised",
|
|
256
|
+
"has_geometry": True,
|
|
257
|
+
"geometry_type": "point",
|
|
258
|
+
"row_count_at_last_refresh": 2_418_264,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@resp_lib.activate
|
|
263
|
+
def test_get_whole_dataset_defers_to_get_local_for_large_geo(client):
|
|
264
|
+
"""Whole-dataset pulls on geometry tables route to get_local(), not /data."""
|
|
265
|
+
sentinel = pd.DataFrame({"address_id": [1], "geometry_wkt": ["POINT (0 0)"]})
|
|
266
|
+
with patch.object(client, "get_local", return_value=sentinel) as mock_local:
|
|
267
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_addresses",
|
|
268
|
+
json=LARGE_GEO_META, status=200)
|
|
269
|
+
result = client.get("nz_addresses")
|
|
270
|
+
mock_local.assert_called_once()
|
|
271
|
+
assert result is sentinel
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@resp_lib.activate
|
|
275
|
+
def test_get_limit_on_large_geo_sends_bounded_limit(client):
|
|
276
|
+
"""Positive limits on geometry tables must not send limit=0 (API 413)."""
|
|
277
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_addresses",
|
|
278
|
+
json=LARGE_GEO_META, status=200)
|
|
279
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_addresses/data",
|
|
280
|
+
json={"data": GEO_RECORDS})
|
|
281
|
+
client.get("nz_addresses", limit=2)
|
|
282
|
+
data_reqs = [c for c in resp_lib.calls if "/data?" in c.request.url or c.request.url.endswith("/data")]
|
|
283
|
+
assert data_reqs, "expected a /data request"
|
|
284
|
+
assert any("limit=2" in c.request.url for c in data_reqs)
|
|
285
|
+
assert not any("limit=0" in c.request.url for c in data_reqs)
|
|
286
|
+
|
|
287
|
+
|
|
216
288
|
@resp_lib.activate
|
|
217
289
|
def test_get_no_geometry_column_returns_dataset(client):
|
|
218
290
|
"""Datasets without a geometry_wkt column still return a Dataset even with as_geo=None."""
|
|
@@ -421,6 +421,25 @@ def test_eolas_no_progress_env_suppresses_bar(client, tmp_path, monkeypatch):
|
|
|
421
421
|
assert all(disabled_values), "EOLAS_NO_PROGRESS=1 must disable tqdm even when isatty=True"
|
|
422
422
|
|
|
423
423
|
|
|
424
|
+
def test_progress_phase_selectors():
|
|
425
|
+
from eolas_data.client import Client
|
|
426
|
+
|
|
427
|
+
phases = Client._resolve_progress_phases("download")
|
|
428
|
+
assert phases == {"download": True, "read": False}
|
|
429
|
+
|
|
430
|
+
phases = Client._resolve_progress_phases("read")
|
|
431
|
+
assert phases == {"download": False, "read": True}
|
|
432
|
+
|
|
433
|
+
phases = Client._resolve_progress_phases("both")
|
|
434
|
+
assert phases == {"download": True, "read": True}
|
|
435
|
+
|
|
436
|
+
phases = Client._resolve_progress_phases("none")
|
|
437
|
+
assert phases == {"download": False, "read": False}
|
|
438
|
+
|
|
439
|
+
assert Client._resolve_show_progress("read", "read") is True
|
|
440
|
+
assert Client._resolve_show_progress("download", "read") is False
|
|
441
|
+
|
|
442
|
+
|
|
424
443
|
def test_progress_resolver_detects_jupyter():
|
|
425
444
|
"""Jupyter wraps stdout so isatty() is False, but the user IS interactive
|
|
426
445
|
and tqdm.auto can render a widget. Resolver must return True when
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""Regenerate ``_dataset_names.py`` from the live API.
|
|
2
|
-
|
|
3
|
-
Run before each release:
|
|
4
|
-
|
|
5
|
-
python -m eolas_data._regen_names
|
|
6
|
-
|
|
7
|
-
Writes to the same file inside the package. Commit the result.
|
|
8
|
-
"""
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import datetime as _dt
|
|
12
|
-
import json as _json
|
|
13
|
-
import pathlib as _pathlib
|
|
14
|
-
import urllib.request as _req
|
|
15
|
-
|
|
16
|
-
API = "https://api.eolas.fyi/v1/datasets"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def regenerate() -> None:
|
|
20
|
-
with _req.urlopen(API, timeout=30) as r:
|
|
21
|
-
data = _json.load(r)
|
|
22
|
-
names = sorted({d["name"] for d in data})
|
|
23
|
-
today = _dt.date.today().isoformat()
|
|
24
|
-
|
|
25
|
-
out = _pathlib.Path(__file__).with_name("_dataset_names.py")
|
|
26
|
-
lines: list[str] = []
|
|
27
|
-
lines.append('"""')
|
|
28
|
-
lines.append('Type stubs for dataset names.')
|
|
29
|
-
lines.append('')
|
|
30
|
-
lines.append('Auto-generated from https://api.eolas.fyi/v1/datasets at release time.')
|
|
31
|
-
lines.append(f'Snapshot: {today} ({len(names)} datasets).')
|
|
32
|
-
lines.append('Regenerate before each release with `python -m eolas_data._regen_names`.')
|
|
33
|
-
lines.append('')
|
|
34
|
-
lines.append('At runtime this is just a string — `Literal[...]` only constrains static type')
|
|
35
|
-
lines.append("checkers like mypy/pyright, so passing a name not in this list still works,")
|
|
36
|
-
lines.append("it just doesn't autocomplete.")
|
|
37
|
-
lines.append('"""')
|
|
38
|
-
lines.append('from typing import Literal')
|
|
39
|
-
lines.append('')
|
|
40
|
-
lines.append(f'CATALOG_SNAPSHOT_DATE = "{today}"')
|
|
41
|
-
lines.append(f'CATALOG_SNAPSHOT_COUNT = {len(names)}')
|
|
42
|
-
lines.append('')
|
|
43
|
-
lines.append('DatasetName = Literal[')
|
|
44
|
-
for n in names:
|
|
45
|
-
lines.append(f' {n!r},')
|
|
46
|
-
lines.append(']')
|
|
47
|
-
lines.append('')
|
|
48
|
-
lines.append('ALL_NAMES: tuple[str, ...] = (')
|
|
49
|
-
for n in names:
|
|
50
|
-
lines.append(f' {n!r},')
|
|
51
|
-
lines.append(')')
|
|
52
|
-
out.write_text("\n".join(lines) + "\n")
|
|
53
|
-
print(f"wrote {len(names)} datasets to {out}")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if __name__ == "__main__":
|
|
57
|
-
regenerate()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|