eolas-data 1.3.17__tar.gz → 1.3.19__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 (45) hide show
  1. eolas_data-1.3.19/LICENSE +21 -0
  2. {eolas_data-1.3.17 → eolas_data-1.3.19}/PKG-INFO +31 -5
  3. {eolas_data-1.3.17 → eolas_data-1.3.19}/README.md +28 -3
  4. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/__init__.py +1 -1
  5. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/cli.py +88 -34
  6. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/client.py +114 -2
  7. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/meta.py +51 -1
  8. {eolas_data-1.3.17 → eolas_data-1.3.19}/pyproject.toml +2 -2
  9. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_cli.py +15 -9
  10. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_client.py +56 -1
  11. eolas_data-1.3.19/tests/test_date_filter.py +81 -0
  12. {eolas_data-1.3.17 → eolas_data-1.3.19}/.github/workflows/catalog-drift.yml +0 -0
  13. {eolas_data-1.3.17 → eolas_data-1.3.19}/.github/workflows/pre-release.yml +0 -0
  14. {eolas_data-1.3.17 → eolas_data-1.3.19}/.github/workflows/publish.yml +0 -0
  15. {eolas_data-1.3.17 → eolas_data-1.3.19}/.github/workflows/smoke.yml +0 -0
  16. {eolas_data-1.3.17 → eolas_data-1.3.19}/.github/workflows/test.yml +0 -0
  17. {eolas_data-1.3.17 → eolas_data-1.3.19}/.gitignore +0 -0
  18. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/_dataset_names.py +0 -0
  19. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/_regen_names.py +0 -0
  20. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/cdc.py +0 -0
  21. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/console.py +0 -0
  22. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/dataset.py +0 -0
  23. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/exceptions.py +0 -0
  24. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/library.py +0 -0
  25. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/rows.py +0 -0
  26. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/schedule.py +0 -0
  27. {eolas_data-1.3.17 → eolas_data-1.3.19}/eolas_data/search.py +0 -0
  28. {eolas_data-1.3.17 → eolas_data-1.3.19}/scripts/check_client_contract.py +0 -0
  29. {eolas_data-1.3.17 → eolas_data-1.3.19}/scripts/preflight.sh +0 -0
  30. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/fixtures/source_helpers.json +0 -0
  31. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_as_arrow.py +0 -0
  32. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_cdc_roundtrip.py +0 -0
  33. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_changes_http.py +0 -0
  34. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_get_local.py +0 -0
  35. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_keyring.py +0 -0
  36. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_library.py +0 -0
  37. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_meta.py +0 -0
  38. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_progress.py +0 -0
  39. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_rows.py +0 -0
  40. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_schedule.py +0 -0
  41. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_search.py +0 -0
  42. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_smoke_live.py +0 -0
  43. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_source_helpers.py +0 -0
  44. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_sync_bulk.py +0 -0
  45. {eolas_data-1.3.17 → eolas_data-1.3.19}/tests/test_sync_changes.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Virtus Solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,13 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eolas-data
3
- Version: 1.3.17
4
- Summary: Python client for the eolas.fyi statistical data API (NZ, Australia, OECD)
3
+ Version: 1.3.19
4
+ Summary: Python client for the eolas.fyi statistical data API (NZ + OECD; Australia on roadmap)
5
5
  Project-URL: Homepage, https://eolas.fyi
6
6
  Project-URL: Documentation, https://docs.eolas.fyi/
7
7
  Project-URL: Repository, https://github.com/phildonovan/eolas-data
8
8
  Project-URL: Bug Tracker, https://github.com/phildonovan/eolas-data/issues
9
9
  Author-email: Virtus Solutions <phil@virtus-solutions.io>
10
10
  License: MIT
11
+ License-File: LICENSE
11
12
  Keywords: api,australia,economics,eolas,new-zealand,statistics
12
13
  Classifier: Development Status :: 4 - Beta
13
14
  Classifier: Intended Audience :: Developers
@@ -54,7 +55,7 @@ Description-Content-Type: text/markdown
54
55
  [![Python](https://img.shields.io/pypi/pyversions/eolas-data)](https://pypi.org/project/eolas-data/)
55
56
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
56
57
 
57
- Python client for the [eolas.fyi](https://eolas.fyi) statistical data API — 1,400+ official New Zealand statistical & geospatial datasets, plus OECD data for international comparisons, served as tidy `pandas` DataFrames (or `polars` / `geopandas` if you prefer).
58
+ Python client for the [eolas.fyi](https://eolas.fyi) statistical data API — 1,500+ official New Zealand statistical & geospatial datasets, plus OECD data for international comparisons, served as tidy `pandas` DataFrames (or `polars` / `geopandas` if you prefer).
58
59
 
59
60
  _Coverage is New Zealand + OECD today. Australian sources are on the roadmap — not yet available; OECD data already includes Australia (and other OECD members) for cross-country comparisons._
60
61
 
@@ -121,7 +122,7 @@ The keyring slot and config file are shared with the R `eolas` client — a key
121
122
 
122
123
  ## Command-line interface
123
124
 
124
- `pip install eolas-data[cli]` adds an `eolas` command for browsing, fetching, and
125
+ `pip install eolas-data` includes the `eolas` CLI for browsing, fetching, and
125
126
  scheduling — useful for shell scripts, cron jobs, and AI-agent workflows. Rich
126
127
  tables by default; pass ``--json`` for newline-delimited JSON in scripts.
127
128
 
@@ -241,6 +242,30 @@ path = client.download_bulk("treasury_fiscal_spending", path="t.parquet")
241
242
 
242
243
  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/).
243
244
 
245
+ ## Sync — always-fresh local copy
246
+
247
+ `client.sync(name, path)` keeps a local file current, automatically choosing *how* based on the dataset's CDC serving tier — you make the same call either way:
248
+
249
+ - **snapshot-tier** datasets → full-snapshot download, re-fetched only when the server snapshot changes (`sync_bulk()`).
250
+ - **changelog-tier** datasets (e.g. the LINZ SCD2 layers) → incremental: the first call downloads a baseline, then later calls fetch *only what changed* from the `/changes` feed and pk-merge it into your file (`sync_changes()`).
251
+
252
+ ```python
253
+ # Same call regardless of tier:
254
+ r = client.sync("nz_building_outlines", path="buildings.parquet")
255
+ r.status # "downloaded" (baseline) | "updated" | "unchanged"
256
+ r.sync_mode # "changelog" for changelog-tier datasets
257
+ r.ops_applied # number of change rows applied this run
258
+ r.current_seq # feed watermark after this sync
259
+
260
+ # First call baselines; subsequent calls apply only new changes:
261
+ r = client.sync("nz_building_outlines", path="buildings.parquet")
262
+ r.ops_applied # e.g. 1240
263
+ ```
264
+
265
+ A sidecar at `str(path) + ".eolas-meta.json"` records the snapshot id / feed watermark so the next call fetches only new data. For SCD2 datasets the merge keeps only the current rows (`is_current = true`), so `buildings.parquet` is always a clean current-state snapshot — the SCD2 history is handled for you. A `410` (watermark expired) self-heals by re-baselining.
266
+
267
+ The changelog sidecar (`schema_version` 2) is byte-compatible with the R `eolas` client: a file synced from Python can be resumed from R and vice versa.
268
+
244
269
  ## Geospatial
245
270
 
246
271
  Datasets with a `geometry_wkt` column auto-convert to `geopandas.GeoDataFrame` if `geopandas` is installed:
@@ -332,4 +357,5 @@ Before each release: `python -m eolas_data._regen_names` to refresh the dataset
332
357
 
333
358
  ## License
334
359
 
335
- MIT
360
+ MIT — applies to this client software only. Dataset use is subject to each
361
+ source's licence and your [eolas API plan](https://eolas.fyi/#pricing).
@@ -6,7 +6,7 @@
6
6
  [![Python](https://img.shields.io/pypi/pyversions/eolas-data)](https://pypi.org/project/eolas-data/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
 
9
- Python client for the [eolas.fyi](https://eolas.fyi) statistical data API — 1,400+ official New Zealand statistical & geospatial datasets, plus OECD data for international comparisons, served as tidy `pandas` DataFrames (or `polars` / `geopandas` if you prefer).
9
+ Python client for the [eolas.fyi](https://eolas.fyi) statistical data API — 1,500+ official New Zealand statistical & geospatial datasets, plus OECD data for international comparisons, served as tidy `pandas` DataFrames (or `polars` / `geopandas` if you prefer).
10
10
 
11
11
  _Coverage is New Zealand + OECD today. Australian sources are on the roadmap — not yet available; OECD data already includes Australia (and other OECD members) for cross-country comparisons._
12
12
 
@@ -73,7 +73,7 @@ The keyring slot and config file are shared with the R `eolas` client — a key
73
73
 
74
74
  ## Command-line interface
75
75
 
76
- `pip install eolas-data[cli]` adds an `eolas` command for browsing, fetching, and
76
+ `pip install eolas-data` includes the `eolas` CLI for browsing, fetching, and
77
77
  scheduling — useful for shell scripts, cron jobs, and AI-agent workflows. Rich
78
78
  tables by default; pass ``--json`` for newline-delimited JSON in scripts.
79
79
 
@@ -193,6 +193,30 @@ path = client.download_bulk("treasury_fiscal_spending", path="t.parquet")
193
193
 
194
194
  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/).
195
195
 
196
+ ## Sync — always-fresh local copy
197
+
198
+ `client.sync(name, path)` keeps a local file current, automatically choosing *how* based on the dataset's CDC serving tier — you make the same call either way:
199
+
200
+ - **snapshot-tier** datasets → full-snapshot download, re-fetched only when the server snapshot changes (`sync_bulk()`).
201
+ - **changelog-tier** datasets (e.g. the LINZ SCD2 layers) → incremental: the first call downloads a baseline, then later calls fetch *only what changed* from the `/changes` feed and pk-merge it into your file (`sync_changes()`).
202
+
203
+ ```python
204
+ # Same call regardless of tier:
205
+ r = client.sync("nz_building_outlines", path="buildings.parquet")
206
+ r.status # "downloaded" (baseline) | "updated" | "unchanged"
207
+ r.sync_mode # "changelog" for changelog-tier datasets
208
+ r.ops_applied # number of change rows applied this run
209
+ r.current_seq # feed watermark after this sync
210
+
211
+ # First call baselines; subsequent calls apply only new changes:
212
+ r = client.sync("nz_building_outlines", path="buildings.parquet")
213
+ r.ops_applied # e.g. 1240
214
+ ```
215
+
216
+ A sidecar at `str(path) + ".eolas-meta.json"` records the snapshot id / feed watermark so the next call fetches only new data. For SCD2 datasets the merge keeps only the current rows (`is_current = true`), so `buildings.parquet` is always a clean current-state snapshot — the SCD2 history is handled for you. A `410` (watermark expired) self-heals by re-baselining.
217
+
218
+ The changelog sidecar (`schema_version` 2) is byte-compatible with the R `eolas` client: a file synced from Python can be resumed from R and vice versa.
219
+
196
220
  ## Geospatial
197
221
 
198
222
  Datasets with a `geometry_wkt` column auto-convert to `geopandas.GeoDataFrame` if `geopandas` is installed:
@@ -284,4 +308,5 @@ Before each release: `python -m eolas_data._regen_names` to refresh the dataset
284
308
 
285
309
  ## License
286
310
 
287
- MIT
311
+ MIT — applies to this client software only. Dataset use is subject to each
312
+ source's licence and your [eolas API plan](https://eolas.fyi/#pricing).
@@ -15,7 +15,7 @@ from .exceptions import (
15
15
  WatermarkExpired,
16
16
  )
17
17
 
18
- __version__ = "1.3.17"
18
+ __version__ = "1.3.19"
19
19
 
20
20
  __all__ = [
21
21
  "Client",
@@ -342,7 +342,7 @@ def get_cmd(
342
342
 
343
343
 
344
344
  # ────────────────────────────────────────────────────────────────────────────
345
- # download command — bulk single-file downloads via /v1/bulk/{namespace}/{table}
345
+ # download command — bulk snapshot when available; live API otherwise (OECD/CPI)
346
346
  # ────────────────────────────────────────────────────────────────────────────
347
347
 
348
348
  # Map CLI --format aliases to the values the server (and client.download_bulk) accept.
@@ -353,6 +353,14 @@ _DOWNLOAD_FORMAT_MAP = {
353
353
  "geoparquet": "geoparquet",
354
354
  }
355
355
 
356
+ # Live /v1/datasets/{name}/data formats when bulk export is unavailable.
357
+ _LIVE_DOWNLOAD_FORMAT_MAP = {
358
+ "parquet": "parquet",
359
+ "csv": "csv",
360
+ "csv_gz": "csv",
361
+ "geoparquet": "parquet",
362
+ }
363
+
356
364
  # Default output-file extensions for each format (used when --out is not set).
357
365
  _DOWNLOAD_EXT = {
358
366
  "parquet": ".parquet",
@@ -360,6 +368,13 @@ _DOWNLOAD_EXT = {
360
368
  "geoparquet": ".geo.parquet",
361
369
  }
362
370
 
371
+ _LIVE_DOWNLOAD_EXT = {
372
+ "parquet": ".parquet",
373
+ "csv": ".csv",
374
+ "csv_gz": ".csv",
375
+ "geoparquet": ".parquet",
376
+ }
377
+
363
378
 
364
379
  @app.command(name="download")
365
380
  def download_cmd(
@@ -388,16 +403,16 @@ def download_cmd(
388
403
  ),
389
404
  api_key: Optional[str] = typer.Option(None, "--api-key"),
390
405
  ) -> None:
391
- """Download a complete dataset as a single file (Parquet, CSV.gz, or GeoParquet).
406
+ """Download a dataset as a single file (Parquet, CSV, or GeoParquet).
392
407
 
393
- Uses the /v1/bulk/{namespace}/{table} endpoint. Monthly snapshots for Free
394
- accounts are served from Cloudflare's edge cache. Pro accounts get the
395
- current Iceberg snapshot on demand.
408
+ Uses /v1/bulk when the dataset permits bulk export. OECD and other
409
+ licence-restricted datasets (e.g. nz_cpi) are downloaded via the live
410
+ /v1/datasets/{name}/data endpoint instead.
396
411
 
397
412
  Examples
398
413
  --------
399
414
  eolas download nz_cpi
400
- eolas download nz_cpi --format csv --out cpi.csv.gz
415
+ eolas download nz_cpi --format csv --out cpi.csv
401
416
  eolas download nz_cpi --freshness monthly
402
417
  eolas download territorial_authority_2023 --format geoparquet
403
418
  """
@@ -413,37 +428,76 @@ def download_cmd(
413
428
  EXIT_USAGE,
414
429
  )
415
430
 
416
- server_fmt = _DOWNLOAD_FORMAT_MAP[fmt_lower]
417
- if out is None:
418
- ext = _DOWNLOAD_EXT[server_fmt]
419
- out = Path.cwd() / f"{name}{ext}"
420
- else:
421
- out = out.expanduser().resolve()
422
-
431
+ client = _client(api_key)
432
+ use_live = False
423
433
  try:
424
- result_path = _client(api_key).download_bulk(
425
- name,
426
- freshness=freshness,
427
- format=server_fmt,
428
- path=out,
429
- progress=False if no_progress else None,
430
- )
431
- except BulkUpgradeRequired as e:
432
- err_console.print(f"[red]error:[/red] {e}")
433
- err_console.print("[dim]→ https://eolas.fyi/pricing[/dim]")
434
- raise typer.Exit(code=EXIT_AUTH)
435
- except BulkLicenceRestricted as e:
436
- err_console.print(f"[red]error:[/red] {e}")
437
- err_console.print(
438
- "[dim]Use `eolas get` to query this dataset via the live API instead.[/dim]"
439
- )
440
- raise typer.Exit(code=EXIT_AUTH)
441
- except BulkNotYetAvailable as e:
442
- err_console.print(f"[yellow]unavailable:[/yellow] {e}")
443
- raise typer.Exit(code=EXIT_API)
434
+ meta = client.info(name)
435
+ # Only OECD/licence-blocked datasets (bulk_export_class=none) use the
436
+ # live /data path by default. Missing field → bulk (same as download_bulk).
437
+ use_live = (meta.get("bulk_export_class") or "").lower() == "none"
444
438
  except EolasError as e:
445
439
  _bail(str(e), _exit_for(e))
446
440
 
441
+ if use_live:
442
+ if freshness != "auto":
443
+ err_console.print(
444
+ "[yellow]note:[/yellow] --freshness is ignored for live API downloads "
445
+ "(bulk snapshots are not available for this dataset)."
446
+ )
447
+ live_fmt = _LIVE_DOWNLOAD_FORMAT_MAP[fmt_lower]
448
+ reported_fmt = live_fmt
449
+ if out is None:
450
+ out = Path.cwd() / f"{name}{_LIVE_DOWNLOAD_EXT[fmt_lower]}"
451
+ else:
452
+ out = out.expanduser().resolve()
453
+ try:
454
+ result_path = client.download(
455
+ name,
456
+ path=out,
457
+ format=live_fmt,
458
+ progress=False if no_progress else None,
459
+ )
460
+ except EolasError as e:
461
+ _bail(str(e), _exit_for(e))
462
+ else:
463
+ server_fmt = _DOWNLOAD_FORMAT_MAP[fmt_lower]
464
+ reported_fmt = server_fmt
465
+ if out is None:
466
+ ext = _DOWNLOAD_EXT[server_fmt]
467
+ out = Path.cwd() / f"{name}{ext}"
468
+ else:
469
+ out = out.expanduser().resolve()
470
+
471
+ try:
472
+ result_path = client.download_bulk(
473
+ name,
474
+ freshness=freshness,
475
+ format=server_fmt,
476
+ path=out,
477
+ progress=False if no_progress else None,
478
+ )
479
+ except BulkUpgradeRequired as e:
480
+ err_console.print(f"[red]error:[/red] {e}")
481
+ err_console.print("[dim]→ https://eolas.fyi/pricing[/dim]")
482
+ raise typer.Exit(code=EXIT_AUTH)
483
+ except BulkLicenceRestricted:
484
+ live_fmt = _LIVE_DOWNLOAD_FORMAT_MAP[fmt_lower]
485
+ reported_fmt = live_fmt
486
+ try:
487
+ result_path = client.download(
488
+ name,
489
+ path=out,
490
+ format=live_fmt,
491
+ progress=False if no_progress else None,
492
+ )
493
+ except EolasError as e:
494
+ _bail(str(e), _exit_for(e))
495
+ except BulkNotYetAvailable as e:
496
+ err_console.print(f"[yellow]unavailable:[/yellow] {e}")
497
+ raise typer.Exit(code=EXIT_API)
498
+ except EolasError as e:
499
+ _bail(str(e), _exit_for(e))
500
+
447
501
  size_bytes = result_path.stat().st_size
448
502
  if size_bytes >= 1_048_576:
449
503
  size_str = f"{size_bytes / 1_048_576:.1f} MB"
@@ -465,7 +519,7 @@ def download_cmd(
465
519
  json.dumps({
466
520
  "path": str(result_path),
467
521
  "bytes": size_bytes,
468
- "format": server_fmt,
522
+ "format": reported_fmt,
469
523
  "freshness": freshness,
470
524
  })
471
525
  )
@@ -365,6 +365,13 @@ class Client:
365
365
  "csv_gz": ".csv.gz",
366
366
  "geoparquet": ".geo.parquet",
367
367
  }
368
+ _LIVE_DOWNLOAD_FORMATS = {"csv", "parquet", "arrow", "json"}
369
+ _LIVE_DOWNLOAD_EXTENSIONS = {
370
+ "csv": ".csv",
371
+ "parquet": ".parquet",
372
+ "arrow": ".arrow",
373
+ "json": ".json",
374
+ }
368
375
  # Mirrors the API guard in datasets.py — unbounded live pulls on datasets
369
376
  # above this row count (or with geometry) return HTTP 413.
370
377
  _LARGE_DATASET_ROW_THRESHOLD = 100_000
@@ -508,6 +515,86 @@ class Client:
508
515
  )
509
516
  return out
510
517
 
518
+ def download(
519
+ self,
520
+ name: Union[str, "DatasetName"],
521
+ *,
522
+ path: Optional[Union[str, "pathlib.Path"]] = None,
523
+ format: str = "csv",
524
+ start: Optional[str] = None,
525
+ end: Optional[str] = None,
526
+ limit: Optional[int] = None,
527
+ progress: ProgressControl = None,
528
+ ) -> "Union[pathlib.Path, bytes]":
529
+ """Download a dataset via the live ``/v1/datasets/{name}/data`` endpoint.
530
+
531
+ Works for **all** datasets — including OECD and other licence-restricted
532
+ tables where bulk export is unavailable (e.g. ``nz_cpi``). For whole-dataset
533
+ pulls on very large or geospatial tables, prefer :meth:`download_bulk` when
534
+ bulk export is permitted.
535
+
536
+ Args:
537
+ name: Dataset identifier, e.g. ``"nz_cpi"``.
538
+ path: Where to write the file. ``None`` (default) returns raw bytes.
539
+ format: ``"csv"`` (default), ``"parquet"``, ``"arrow"``, or ``"json"``.
540
+ start: ISO date lower bound.
541
+ end: ISO date upper bound.
542
+ limit: Max rows. ``None`` (default) requests the full dataset (subject
543
+ to plan caps). Pass an integer to cap rows.
544
+ progress: Download progress bar control (``"download"`` phase only).
545
+
546
+ Returns:
547
+ ``pathlib.Path`` when ``path`` is set; ``bytes`` when ``path`` is ``None``.
548
+
549
+ Examples::
550
+
551
+ client.download("nz_cpi", path="nz_cpi.csv")
552
+ client.download("nz_cpi", format="parquet", path="nz_cpi.parquet")
553
+ raw = client.download("nz_cpi", format="csv")
554
+ """
555
+ fmt = format.lower()
556
+ if fmt not in self._LIVE_DOWNLOAD_FORMATS:
557
+ raise ValueError(
558
+ f"Unknown format {format!r}. Expected one of: "
559
+ f"{', '.join(sorted(self._LIVE_DOWNLOAD_FORMATS))}."
560
+ )
561
+
562
+ params: dict = {"format": fmt}
563
+ if start:
564
+ params["start"] = start
565
+ if end:
566
+ params["end"] = end
567
+ if limit is not None:
568
+ from .rows import resolve_fetch_limit
569
+ fetch_limit, _ = resolve_fetch_limit(limit)
570
+ params["limit"] = fetch_limit
571
+ elif start is None and end is None:
572
+ params["limit"] = 0
573
+ else:
574
+ params["limit"] = 0
575
+
576
+ if path is None:
577
+ resp = self._raw_get(f"/v1/datasets/{name}/data", params=params)
578
+ return resp.content
579
+
580
+ out = pathlib.Path(path).expanduser().resolve()
581
+ if not out.suffix and fmt in self._LIVE_DOWNLOAD_EXTENSIONS:
582
+ out = out.with_suffix(self._LIVE_DOWNLOAD_EXTENSIONS[fmt])
583
+ out.parent.mkdir(parents=True, exist_ok=True)
584
+
585
+ show = self._resolve_show_progress(progress, "download")
586
+ resp = self._raw_get(
587
+ f"/v1/datasets/{name}/data", params=params, stream=True,
588
+ )
589
+ total = int(resp.headers.get("Content-Length", 0)) or None
590
+ self._stream_to_file_with_progress(
591
+ resp, out,
592
+ total_bytes=total,
593
+ label=f"Downloading {out.name}",
594
+ show_progress=show,
595
+ )
596
+ return out
597
+
511
598
  def sync_bulk(
512
599
  self,
513
600
  name: Union[str, "DatasetName"],
@@ -2136,8 +2223,27 @@ class Client:
2136
2223
  "envelope=True requires format='json' and as_arrow=False."
2137
2224
  )
2138
2225
 
2226
+ from .meta import resolve_date_bounds
2139
2227
  from .rows import apply_row_limit, resolve_fetch_limit, sort_by_date
2140
2228
 
2229
+ # ---- start/end only apply when the dataset has a date filter column ---
2230
+ _date_bounds_info: Optional[dict] = None
2231
+ if start is not None or end is not None:
2232
+ try:
2233
+ _date_bounds_info = self._info_cached(name)
2234
+ except Exception:
2235
+ pass
2236
+ start, end, _stripped = resolve_date_bounds(_date_bounds_info, start, end)
2237
+ if _stripped:
2238
+ import warnings
2239
+ warnings.warn(
2240
+ f"start=/end= ignored for {name!r}: this dataset has no date "
2241
+ "filter column (not a time-series table). Use limit= for row "
2242
+ "caps or get_local() for the full table.",
2243
+ UserWarning,
2244
+ stacklevel=2,
2245
+ )
2246
+
2141
2247
  # ---- whole-dataset pull on large/geo tables → bulk cache -------------
2142
2248
  # Matches the API 413 guard: limit=0 with no start/end on >100k-row or
2143
2249
  # geometry datasets is refused. Transparently serve from get_local()
@@ -2315,9 +2421,15 @@ class Client:
2315
2421
  def _get(self, path: str, params: Optional[dict] = None) -> dict:
2316
2422
  return self._raw_get(path, params=params).json()
2317
2423
 
2318
- def _raw_get(self, path: str, params: Optional[dict] = None) -> requests.Response:
2424
+ def _raw_get(
2425
+ self,
2426
+ path: str,
2427
+ params: Optional[dict] = None,
2428
+ *,
2429
+ stream: bool = False,
2430
+ ) -> requests.Response:
2319
2431
  url = f"{self._base}{path}"
2320
- resp = self._session.get(url, params=params)
2432
+ resp = self._session.get(url, params=params, stream=stream)
2321
2433
  self._raise_for_status(resp)
2322
2434
  return resp
2323
2435
 
@@ -55,6 +55,16 @@ _TABLE_META_KEYS = (
55
55
  "refresh_cadence",
56
56
  "last_refreshed_at",
57
57
  "previous_snapshots",
58
+ "date_filter_column",
59
+ )
60
+
61
+ # Mirrors app/streaming.py::detect_date_col — columns that /data?start=&end= filter on.
62
+ DATE_FILTER_CANDIDATES = (
63
+ "date",
64
+ "time_frame",
65
+ "open_date",
66
+ "awarded_date",
67
+ "start_date",
58
68
  )
59
69
 
60
70
 
@@ -172,4 +182,44 @@ def column_label(
172
182
  if val is None or (isinstance(val, float) and pd.isna(val)):
173
183
  return None
174
184
  text = str(val).strip()
175
- return text or None
185
+ return text or None
186
+
187
+
188
+ def date_filter_column_from_info(info: dict) -> Optional[str]:
189
+ """Return the column name used for start/end filtering, or None if unsupported."""
190
+ if not info:
191
+ return None
192
+ if "date_filter_column" in info:
193
+ col = info.get("date_filter_column")
194
+ return col if col else None
195
+ raw_cols = info.get("columns")
196
+ if not raw_cols:
197
+ return None
198
+ if isinstance(raw_cols, pd.DataFrame):
199
+ names = set(raw_cols["name"].astype(str)) if "name" in raw_cols.columns else set()
200
+ else:
201
+ names = {
202
+ str(c["name"])
203
+ for c in raw_cols
204
+ if isinstance(c, dict) and c.get("name")
205
+ }
206
+ return next((c for c in DATE_FILTER_CANDIDATES if c in names), None)
207
+
208
+
209
+ def resolve_date_bounds(
210
+ info: Optional[dict],
211
+ start: Optional[str],
212
+ end: Optional[str],
213
+ ) -> tuple[Optional[str], Optional[str], bool]:
214
+ """Return (start, end, stripped) — clear bounds when the dataset has no date axis.
215
+
216
+ When stripped is True, callers should warn that start/end were ignored.
217
+ If metadata is unavailable, bounds are passed through unchanged (safe default).
218
+ """
219
+ if start is None and end is None:
220
+ return None, None, False
221
+ if info is None:
222
+ return start, end, False
223
+ if date_filter_column_from_info(info):
224
+ return start, end, False
225
+ return None, None, True
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "eolas-data"
7
- version = "1.3.17"
8
- description = "Python client for the eolas.fyi statistical data API (NZ, Australia, OECD)"
7
+ version = "1.3.19"
8
+ description = "Python client for the eolas.fyi statistical data API (NZ + OECD; Australia on roadmap)"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  authors = [{ name = "Virtus Solutions", email = "phil@virtus-solutions.io" }]
@@ -597,7 +597,10 @@ def test_download_csv_format(tmp_path, monkeypatch):
597
597
  "--format", "csv", "--api-key", "k"])
598
598
  assert result.exit_code == 0
599
599
  assert (tmp_path / "nz_cpi.csv.gz").exists()
600
- bulk_req = resp_lib.calls[1].request
600
+ bulk_req = next(
601
+ c.request for c in resp_lib.calls
602
+ if "/v1/bulk/" in c.request.url
603
+ )
601
604
  assert "format=csv_gz" in bulk_req.url
602
605
 
603
606
 
@@ -617,18 +620,21 @@ def test_download_402_exits_auth_code(tmp_path, monkeypatch):
617
620
 
618
621
 
619
622
  @resp_lib.activate
620
- def test_download_403_licence_exits_auth_code(tmp_path, monkeypatch):
621
- """HTTP 403 licence restriction should exit with EXIT_AUTH."""
623
+ def test_download_403_licence_falls_back_to_live_api(tmp_path, monkeypatch):
624
+ """bulk_export_class=none uses live /data; bulk 403 also falls back."""
625
+ live_csv = b"date,period,value\n2023-01-01,2023Q1,100.0\n"
622
626
  resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/oecd_gdp",
623
627
  json={**BULK_DATASET_META, "name": "oecd_gdp",
624
- "namespace": "oecd", "table": "oecd_gdp"},
628
+ "namespace": "oecd", "table": "oecd_gdp",
629
+ "bulk_export_class": "none"},
625
630
  status=200)
626
- resp_lib.add(resp_lib.GET, f"{BASE}/v1/bulk/oecd/oecd_gdp",
627
- json={"detail": "This dataset is not available as a bulk download (licence: OECD)."},
628
- status=403)
631
+ resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/oecd_gdp/data",
632
+ body=live_csv, content_type="text/csv", status=200)
629
633
  monkeypatch.chdir(tmp_path)
630
- result = runner.invoke(app, ["download", "oecd_gdp", "--api-key", "k"])
631
- assert result.exit_code == cli_module.EXIT_AUTH
634
+ result = runner.invoke(app, ["download", "oecd_gdp",
635
+ "--format", "csv", "--api-key", "k"])
636
+ assert result.exit_code == 0
637
+ assert (tmp_path / "oecd_gdp.csv").read_bytes() == live_csv
632
638
 
633
639
 
634
640
  @resp_lib.activate
@@ -164,9 +164,16 @@ def test_get_limit_requests_full_window_from_server(client):
164
164
 
165
165
  @resp_lib.activate
166
166
  def test_get_passes_date_params(client):
167
+ resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi",
168
+ json={"name": "nz_cpi", "date_filter_column": "date"})
167
169
  resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi/data", json={"data": RECORDS})
168
170
  client.get("nz_cpi", start="2023-01-01", end="2023-06-30")
169
- req = resp_lib.calls[0].request
171
+ data_reqs = [
172
+ c for c in resp_lib.calls
173
+ if c.request.url.endswith("/data") or "/data?" in c.request.url
174
+ ]
175
+ assert data_reqs, "expected a /data request"
176
+ req = data_reqs[0].request
170
177
  assert "start=2023-01-01" in req.url
171
178
  assert "end=2023-06-30" in req.url
172
179
 
@@ -447,6 +454,54 @@ def test_integration_403_raises_authentication_error_with_server_detail(client):
447
454
  assert "Enterprise" in str(e.value)
448
455
 
449
456
 
457
+ # ---------------------------------------------------------------------------
458
+ # Client.download() — live /v1/datasets/{name}/data
459
+ # ---------------------------------------------------------------------------
460
+
461
+ FAKE_CSV = b"date,period,value\n2023-01-01,2023Q1,100.0\n"
462
+
463
+
464
+ @resp_lib.activate
465
+ def test_download_live_writes_csv(client, tmp_path):
466
+ resp_lib.add(
467
+ resp_lib.GET,
468
+ f"{BASE}/v1/datasets/nz_cpi/data",
469
+ body=FAKE_CSV,
470
+ content_type="text/csv",
471
+ status=200,
472
+ )
473
+ dest = tmp_path / "nz_cpi.csv"
474
+ result = client.download("nz_cpi", path=dest)
475
+ import pathlib
476
+ assert isinstance(result, pathlib.Path)
477
+ assert result.read_bytes() == FAKE_CSV
478
+
479
+
480
+ @resp_lib.activate
481
+ def test_download_live_sends_limit_zero_for_full_dataset(client, tmp_path):
482
+ resp_lib.add(
483
+ resp_lib.GET,
484
+ f"{BASE}/v1/datasets/nz_cpi/data",
485
+ body=FAKE_CSV,
486
+ content_type="text/csv",
487
+ status=200,
488
+ )
489
+ client.download("nz_cpi", path=tmp_path / "nz_cpi.csv")
490
+ assert "limit=0" in resp_lib.calls[0].request.url
491
+
492
+
493
+ @resp_lib.activate
494
+ def test_download_live_returns_bytes_when_no_path(client):
495
+ resp_lib.add(
496
+ resp_lib.GET,
497
+ f"{BASE}/v1/datasets/nz_cpi/data",
498
+ body=FAKE_CSV,
499
+ content_type="text/csv",
500
+ status=200,
501
+ )
502
+ assert client.download("nz_cpi") == FAKE_CSV
503
+
504
+
450
505
  # ---------------------------------------------------------------------------
451
506
  # Client.download_bulk() — /v1/bulk/{namespace}/{table}
452
507
  # ---------------------------------------------------------------------------
@@ -0,0 +1,81 @@
1
+ """Tests for start/end date-bound resolution on non-temporal datasets."""
2
+ from __future__ import annotations
3
+
4
+ import warnings
5
+
6
+ import pandas as pd
7
+ import pytest
8
+ import responses as resp_lib
9
+ from unittest.mock import patch
10
+
11
+ from eolas_data import Client
12
+ from eolas_data.meta import (
13
+ date_filter_column_from_info,
14
+ resolve_date_bounds,
15
+ )
16
+
17
+ BASE = "https://api.eolas.fyi"
18
+
19
+ TEMPORAL_INFO = {
20
+ "name": "nz_cpi",
21
+ "date_filter_column": "date",
22
+ "columns": [{"name": "date"}, {"name": "value"}],
23
+ }
24
+
25
+ NON_TEMPORAL_INFO = {
26
+ "name": "nz_addresses",
27
+ "date_filter_column": None,
28
+ "has_geometry": True,
29
+ "bulk_export_class": "materialised",
30
+ "row_count_at_last_refresh": 2_418_264,
31
+ "columns": [{"name": "address_id"}, {"name": "geometry_wkt"}],
32
+ }
33
+
34
+
35
+ @pytest.fixture()
36
+ def client():
37
+ return Client("eolas_testkey123", base_url=BASE)
38
+
39
+
40
+ def test_date_filter_column_from_explicit_field():
41
+ assert date_filter_column_from_info(TEMPORAL_INFO) == "date"
42
+ assert date_filter_column_from_info(NON_TEMPORAL_INFO) is None
43
+
44
+
45
+ def test_date_filter_column_inferred_from_columns():
46
+ info = {"columns": [{"name": "awarded_date"}, {"name": "amount"}]}
47
+ assert date_filter_column_from_info(info) == "awarded_date"
48
+
49
+
50
+ def test_resolve_date_bounds_keeps_temporal():
51
+ start, end, stripped = resolve_date_bounds(TEMPORAL_INFO, "2020-01-01", "2024-12-31")
52
+ assert start == "2020-01-01"
53
+ assert end == "2024-12-31"
54
+ assert stripped is False
55
+
56
+
57
+ def test_resolve_date_bounds_strips_non_temporal():
58
+ start, end, stripped = resolve_date_bounds(NON_TEMPORAL_INFO, "2020-01-01", None)
59
+ assert start is None
60
+ assert end is None
61
+ assert stripped is True
62
+
63
+
64
+ def test_resolve_date_bounds_unknown_metadata_passthrough():
65
+ start, end, stripped = resolve_date_bounds(None, "2020-01-01", None)
66
+ assert start == "2020-01-01"
67
+ assert stripped is False
68
+
69
+
70
+ @resp_lib.activate
71
+ def test_get_start_on_non_temporal_warns_and_routes_to_get_local(client):
72
+ """start= on a geo table must not block smart-routing to get_local()."""
73
+ sentinel = pd.DataFrame({"address_id": [1], "geometry_wkt": ["POINT (0 0)"]})
74
+ with (
75
+ patch.object(client, "_info_cached", return_value=NON_TEMPORAL_INFO),
76
+ patch.object(client, "get_local", return_value=sentinel) as mock_local,
77
+ pytest.warns(UserWarning, match="start=/end= ignored"),
78
+ ):
79
+ result = client.get("nz_addresses", start="2020-01-01")
80
+ mock_local.assert_called_once()
81
+ assert result is sentinel
File without changes