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.
Files changed (38) hide show
  1. {eolas_data-1.3.2 → eolas_data-1.3.5}/PKG-INFO +3 -3
  2. {eolas_data-1.3.2 → eolas_data-1.3.5}/README.md +1 -1
  3. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/__init__.py +1 -1
  4. eolas_data-1.3.5/eolas_data/_regen_names.py +58 -0
  5. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/client.py +237 -68
  6. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/meta.py +29 -0
  7. {eolas_data-1.3.2 → eolas_data-1.3.5}/pyproject.toml +2 -2
  8. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_as_arrow.py +1 -1
  9. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_client.py +73 -1
  10. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_progress.py +19 -0
  11. eolas_data-1.3.2/eolas_data/_regen_names.py +0 -57
  12. {eolas_data-1.3.2 → eolas_data-1.3.5}/.github/workflows/catalog-drift.yml +0 -0
  13. {eolas_data-1.3.2 → eolas_data-1.3.5}/.github/workflows/publish.yml +0 -0
  14. {eolas_data-1.3.2 → eolas_data-1.3.5}/.github/workflows/smoke.yml +0 -0
  15. {eolas_data-1.3.2 → eolas_data-1.3.5}/.github/workflows/test.yml +0 -0
  16. {eolas_data-1.3.2 → eolas_data-1.3.5}/.gitignore +0 -0
  17. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/_dataset_names.py +0 -0
  18. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/cdc.py +0 -0
  19. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/cli.py +0 -0
  20. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/console.py +0 -0
  21. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/dataset.py +0 -0
  22. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/exceptions.py +0 -0
  23. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/library.py +0 -0
  24. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/rows.py +0 -0
  25. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/schedule.py +0 -0
  26. {eolas_data-1.3.2 → eolas_data-1.3.5}/eolas_data/search.py +0 -0
  27. {eolas_data-1.3.2 → eolas_data-1.3.5}/scripts/preflight.sh +0 -0
  28. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_cdc_roundtrip.py +0 -0
  29. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_cli.py +0 -0
  30. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_keyring.py +0 -0
  31. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_library.py +0 -0
  32. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_meta.py +0 -0
  33. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_rows.py +0 -0
  34. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_schedule.py +0 -0
  35. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_search.py +0 -0
  36. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_smoke_live.py +0 -0
  37. {eolas_data-1.3.2 → eolas_data-1.3.5}/tests/test_sync_bulk.py +0 -0
  38. {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.2
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>=14
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:** `download_bulk`, `sync_bulk`, and `get_local` all show a `tqdm` progress bar automatically in interactive terminals and VSCode notebooks, so 1+ GB files are never silent. Pass `progress=False` to suppress in scripts, or set `EOLAS_NO_PROGRESS=1` in the environment for a CI-wide escape hatch. The `--no-progress` flag does the same from the CLI.
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:** `download_bulk`, `sync_bulk`, and `get_local` all show a `tqdm` progress bar automatically in interactive terminals and VSCode notebooks, so 1+ GB files are never silent. Pass `progress=False` to suppress in scripts, or set `EOLAS_NO_PROGRESS=1` in the environment for a CI-wide escape hatch. The `--no-progress` flag does the same from the CLI.
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
 
@@ -15,7 +15,7 @@ from .exceptions import (
15
15
  WatermarkExpired,
16
16
  )
17
17
 
18
- __version__ = "1.3.2"
18
+ __version__ = "1.3.3"
19
19
 
20
20
  __all__ = [
21
21
  "Client",
@@ -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: Optional[bool] = None,
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
- ``None`` (default) auto-detects: bar shown when
373
- ``sys.stdout.isatty()`` is True (interactive terminal or
374
- notebook), hidden when piped or in CI.
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: Optional[bool] = None,
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
- ``None`` (default) auto-detects via ``sys.stdout.isatty()``.
500
- ``True`` forces the bar on; ``False`` forces it off.
501
- When ``status="unchanged"`` no data is transferred so no bar
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 _resolve_show_progress(progress: Optional[bool]) -> bool:
1144
- """Resolve the tri-state ``progress`` kwarg to a concrete bool.
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: Optional[bool] = None,
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: Forwarded to :meth:`sync_bulk` (``None`` auto-detects TTY).
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
- if fmt == "csv_gz":
1884
- import pyarrow as _pa
1885
- return _pa.Table.from_pandas(pd.read_csv(file_path), preserve_index=False)
1886
- return _pq.read_table(file_path)
1887
-
1888
- if fmt == "geoparquet":
1889
- if resolved_as_geo:
1890
- try:
1891
- import geopandas as gpd
1892
- result = gpd.read_parquet(file_path)
1893
- return self._attach_dataset_meta(result, name, meta=meta)
1894
- except ImportError:
1895
- pass
1896
- result = pd.read_parquet(file_path)
1897
- return self._attach_dataset_meta(result, name, meta=meta)
1898
- elif fmt == "csv_gz":
1899
- result = pd.read_csv(file_path)
1900
- return self._attach_dataset_meta(result, name, meta=meta)
1901
- else: # parquet
1902
- result = pd.read_parquet(file_path)
1903
- return self._attach_dataset_meta(result, name, meta=meta)
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(name, params)
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(result, name, meta=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(self, name, params: dict) -> pd.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
- data = self._get(f"/v1/datasets/{name}/data", params=params)
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
- return pd.DataFrame(records)
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.2"
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>=14",
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
- assert "limit=0" in resp_lib.calls[0].request.url
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