eolas-data 1.3.17__tar.gz → 1.3.20__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.20/LICENSE +21 -0
  2. {eolas_data-1.3.17 → eolas_data-1.3.20}/PKG-INFO +31 -5
  3. {eolas_data-1.3.17 → eolas_data-1.3.20}/README.md +28 -3
  4. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/__init__.py +1 -1
  5. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/cli.py +101 -34
  6. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/client.py +120 -4
  7. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/meta.py +51 -1
  8. {eolas_data-1.3.17 → eolas_data-1.3.20}/pyproject.toml +2 -2
  9. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_cli.py +15 -9
  10. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_client.py +56 -1
  11. eolas_data-1.3.20/tests/test_date_filter.py +81 -0
  12. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_progress.py +14 -0
  13. {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/catalog-drift.yml +0 -0
  14. {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/pre-release.yml +0 -0
  15. {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/publish.yml +0 -0
  16. {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/smoke.yml +0 -0
  17. {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/test.yml +0 -0
  18. {eolas_data-1.3.17 → eolas_data-1.3.20}/.gitignore +0 -0
  19. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/_dataset_names.py +0 -0
  20. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/_regen_names.py +0 -0
  21. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/cdc.py +0 -0
  22. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/console.py +0 -0
  23. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/dataset.py +0 -0
  24. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/exceptions.py +0 -0
  25. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/library.py +0 -0
  26. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/rows.py +0 -0
  27. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/schedule.py +0 -0
  28. {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/search.py +0 -0
  29. {eolas_data-1.3.17 → eolas_data-1.3.20}/scripts/check_client_contract.py +0 -0
  30. {eolas_data-1.3.17 → eolas_data-1.3.20}/scripts/preflight.sh +0 -0
  31. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/fixtures/source_helpers.json +0 -0
  32. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_as_arrow.py +0 -0
  33. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_cdc_roundtrip.py +0 -0
  34. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_changes_http.py +0 -0
  35. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_get_local.py +0 -0
  36. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_keyring.py +0 -0
  37. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_library.py +0 -0
  38. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_meta.py +0 -0
  39. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_rows.py +0 -0
  40. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_schedule.py +0 -0
  41. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_search.py +0 -0
  42. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_smoke_live.py +0 -0
  43. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_source_helpers.py +0 -0
  44. {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_sync_bulk.py +0 -0
  45. {eolas_data-1.3.17 → eolas_data-1.3.20}/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.20
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.20"
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(
@@ -386,18 +401,22 @@ def download_cmd(
386
401
  False, "--no-progress",
387
402
  help="Disable the download progress bar (useful when output is captured by a log collector).",
388
403
  ),
404
+ show_progress: bool = typer.Option(
405
+ False, "--progress",
406
+ help="Always show the download progress bar (even when stdout/stderr are not a TTY).",
407
+ ),
389
408
  api_key: Optional[str] = typer.Option(None, "--api-key"),
390
409
  ) -> None:
391
- """Download a complete dataset as a single file (Parquet, CSV.gz, or GeoParquet).
410
+ """Download a dataset as a single file (Parquet, CSV, or GeoParquet).
392
411
 
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.
412
+ Uses /v1/bulk when the dataset permits bulk export. OECD and other
413
+ licence-restricted datasets (e.g. nz_cpi) are downloaded via the live
414
+ /v1/datasets/{name}/data endpoint instead.
396
415
 
397
416
  Examples
398
417
  --------
399
418
  eolas download nz_cpi
400
- eolas download nz_cpi --format csv --out cpi.csv.gz
419
+ eolas download nz_cpi --format csv --out cpi.csv
401
420
  eolas download nz_cpi --freshness monthly
402
421
  eolas download territorial_authority_2023 --format geoparquet
403
422
  """
@@ -413,37 +432,85 @@ def download_cmd(
413
432
  EXIT_USAGE,
414
433
  )
415
434
 
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
-
435
+ client = _client(api_key)
436
+ use_live = False
423
437
  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)
438
+ meta = client.info(name)
439
+ # Only OECD/licence-blocked datasets (bulk_export_class=none) use the
440
+ # live /data path by default. Missing field → bulk (same as download_bulk).
441
+ use_live = (meta.get("bulk_export_class") or "").lower() == "none"
444
442
  except EolasError as e:
445
443
  _bail(str(e), _exit_for(e))
446
444
 
445
+ if show_progress and no_progress:
446
+ _bail("--progress and --no-progress are mutually exclusive", EXIT_USAGE)
447
+ if show_progress:
448
+ _progress: Optional[bool] = True
449
+ elif no_progress:
450
+ _progress = False
451
+ else:
452
+ _progress = None
453
+
454
+ if use_live:
455
+ if freshness != "auto":
456
+ err_console.print(
457
+ "[yellow]note:[/yellow] --freshness is ignored for live API downloads "
458
+ "(bulk snapshots are not available for this dataset)."
459
+ )
460
+ live_fmt = _LIVE_DOWNLOAD_FORMAT_MAP[fmt_lower]
461
+ reported_fmt = live_fmt
462
+ if out is None:
463
+ out = Path.cwd() / f"{name}{_LIVE_DOWNLOAD_EXT[fmt_lower]}"
464
+ else:
465
+ out = out.expanduser().resolve()
466
+ try:
467
+ result_path = client.download(
468
+ name,
469
+ path=out,
470
+ format=live_fmt,
471
+ progress=_progress,
472
+ )
473
+ except EolasError as e:
474
+ _bail(str(e), _exit_for(e))
475
+ else:
476
+ server_fmt = _DOWNLOAD_FORMAT_MAP[fmt_lower]
477
+ reported_fmt = server_fmt
478
+ if out is None:
479
+ ext = _DOWNLOAD_EXT[server_fmt]
480
+ out = Path.cwd() / f"{name}{ext}"
481
+ else:
482
+ out = out.expanduser().resolve()
483
+
484
+ try:
485
+ result_path = client.download_bulk(
486
+ name,
487
+ freshness=freshness,
488
+ format=server_fmt,
489
+ path=out,
490
+ progress=_progress,
491
+ )
492
+ except BulkUpgradeRequired as e:
493
+ err_console.print(f"[red]error:[/red] {e}")
494
+ err_console.print("[dim]→ https://eolas.fyi/pricing[/dim]")
495
+ raise typer.Exit(code=EXIT_AUTH)
496
+ except BulkLicenceRestricted:
497
+ live_fmt = _LIVE_DOWNLOAD_FORMAT_MAP[fmt_lower]
498
+ reported_fmt = live_fmt
499
+ try:
500
+ result_path = client.download(
501
+ name,
502
+ path=out,
503
+ format=live_fmt,
504
+ progress=_progress,
505
+ )
506
+ except EolasError as e:
507
+ _bail(str(e), _exit_for(e))
508
+ except BulkNotYetAvailable as e:
509
+ err_console.print(f"[yellow]unavailable:[/yellow] {e}")
510
+ raise typer.Exit(code=EXIT_API)
511
+ except EolasError as e:
512
+ _bail(str(e), _exit_for(e))
513
+
447
514
  size_bytes = result_path.stat().st_size
448
515
  if size_bytes >= 1_048_576:
449
516
  size_str = f"{size_bytes / 1_048_576:.1f} MB"
@@ -465,7 +532,7 @@ def download_cmd(
465
532
  json.dumps({
466
533
  "path": str(result_path),
467
534
  "bytes": size_bytes,
468
- "format": server_fmt,
535
+ "format": reported_fmt,
469
536
  "freshness": freshness,
470
537
  })
471
538
  )
@@ -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"],
@@ -1209,7 +1296,9 @@ class Client:
1209
1296
  return False
1210
1297
  if "ipykernel" in sys.modules:
1211
1298
  return True
1212
- return sys.stdout.isatty()
1299
+ # tqdm writes to stderr; many terminals/IDEs pipe stdout but keep stderr
1300
+ # on the controlling TTY (Cursor, VS Code, script wrappers).
1301
+ return sys.stderr.isatty() or sys.stdout.isatty()
1213
1302
 
1214
1303
  @staticmethod
1215
1304
  def _resolve_progress_phases(progress: ProgressControl) -> dict[str, bool]:
@@ -1314,8 +1403,10 @@ class Client:
1314
1403
  unit_scale=True,
1315
1404
  unit_divisor=1024,
1316
1405
  desc=label,
1406
+ file=sys.stderr,
1407
+ dynamic_ncols=True,
1317
1408
  disable=not show_progress,
1318
- leave=False,
1409
+ leave=True,
1319
1410
  ) as bar:
1320
1411
  with dest.open("wb") as fh:
1321
1412
  # 1 MiB chunks: responsive bar updates (bar refreshes ~once per
@@ -2136,8 +2227,27 @@ class Client:
2136
2227
  "envelope=True requires format='json' and as_arrow=False."
2137
2228
  )
2138
2229
 
2230
+ from .meta import resolve_date_bounds
2139
2231
  from .rows import apply_row_limit, resolve_fetch_limit, sort_by_date
2140
2232
 
2233
+ # ---- start/end only apply when the dataset has a date filter column ---
2234
+ _date_bounds_info: Optional[dict] = None
2235
+ if start is not None or end is not None:
2236
+ try:
2237
+ _date_bounds_info = self._info_cached(name)
2238
+ except Exception:
2239
+ pass
2240
+ start, end, _stripped = resolve_date_bounds(_date_bounds_info, start, end)
2241
+ if _stripped:
2242
+ import warnings
2243
+ warnings.warn(
2244
+ f"start=/end= ignored for {name!r}: this dataset has no date "
2245
+ "filter column (not a time-series table). Use limit= for row "
2246
+ "caps or get_local() for the full table.",
2247
+ UserWarning,
2248
+ stacklevel=2,
2249
+ )
2250
+
2141
2251
  # ---- whole-dataset pull on large/geo tables → bulk cache -------------
2142
2252
  # Matches the API 413 guard: limit=0 with no start/end on >100k-row or
2143
2253
  # geometry datasets is refused. Transparently serve from get_local()
@@ -2315,9 +2425,15 @@ class Client:
2315
2425
  def _get(self, path: str, params: Optional[dict] = None) -> dict:
2316
2426
  return self._raw_get(path, params=params).json()
2317
2427
 
2318
- def _raw_get(self, path: str, params: Optional[dict] = None) -> requests.Response:
2428
+ def _raw_get(
2429
+ self,
2430
+ path: str,
2431
+ params: Optional[dict] = None,
2432
+ *,
2433
+ stream: bool = False,
2434
+ ) -> requests.Response:
2319
2435
  url = f"{self._base}{path}"
2320
- resp = self._session.get(url, params=params)
2436
+ resp = self._session.get(url, params=params, stream=stream)
2321
2437
  self._raise_for_status(resp)
2322
2438
  return resp
2323
2439
 
@@ -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.20"
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
@@ -478,6 +478,20 @@ def test_progress_phase_selectors():
478
478
  assert Client._resolve_show_progress("download", "read") is False
479
479
 
480
480
 
481
+ def test_progress_auto_detect_uses_stderr_tty():
482
+ """tqdm writes to stderr; progress should show when stderr is a TTY even if stdout is piped."""
483
+ from unittest.mock import patch
484
+ from eolas_data.client import Client
485
+
486
+ with patch("sys.stdout.isatty", return_value=False), \
487
+ patch("sys.stderr.isatty", return_value=True):
488
+ assert Client._progress_auto_detect() is True
489
+
490
+ with patch("sys.stdout.isatty", return_value=False), \
491
+ patch("sys.stderr.isatty", return_value=False):
492
+ assert Client._progress_auto_detect() is False
493
+
494
+
481
495
  def test_progress_resolver_detects_jupyter():
482
496
  """Jupyter wraps stdout so isatty() is False, but the user IS interactive
483
497
  and tqdm.auto can render a widget. Resolver must return True when
File without changes