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.
- eolas_data-1.3.20/LICENSE +21 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/PKG-INFO +31 -5
- {eolas_data-1.3.17 → eolas_data-1.3.20}/README.md +28 -3
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/__init__.py +1 -1
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/cli.py +101 -34
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/client.py +120 -4
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/meta.py +51 -1
- {eolas_data-1.3.17 → eolas_data-1.3.20}/pyproject.toml +2 -2
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_cli.py +15 -9
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_client.py +56 -1
- eolas_data-1.3.20/tests/test_date_filter.py +81 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_progress.py +14 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/catalog-drift.yml +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/pre-release.yml +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/publish.yml +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/smoke.yml +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/.github/workflows/test.yml +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/.gitignore +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/_dataset_names.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/_regen_names.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/cdc.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/console.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/dataset.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/exceptions.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/library.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/rows.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/schedule.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/eolas_data/search.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/scripts/check_client_contract.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/scripts/preflight.sh +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/fixtures/source_helpers.json +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_as_arrow.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_cdc_roundtrip.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_changes_http.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_get_local.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_keyring.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_library.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_meta.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_rows.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_schedule.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_search.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_smoke_live.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_source_helpers.py +0 -0
- {eolas_data-1.3.17 → eolas_data-1.3.20}/tests/test_sync_bulk.py +0 -0
- {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.
|
|
4
|
-
Summary: Python client for the eolas.fyi statistical data API (NZ
|
|
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
|
[](https://pypi.org/project/eolas-data/)
|
|
55
56
|
[](https://opensource.org/licenses/MIT)
|
|
56
57
|
|
|
57
|
-
Python client for the [eolas.fyi](https://eolas.fyi) statistical data API — 1,
|
|
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
|
|
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
|
[](https://pypi.org/project/eolas-data/)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
|
|
9
|
-
Python client for the [eolas.fyi](https://eolas.fyi) statistical data API — 1,
|
|
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
|
|
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).
|
|
@@ -342,7 +342,7 @@ def get_cmd(
|
|
|
342
342
|
|
|
343
343
|
|
|
344
344
|
# ────────────────────────────────────────────────────────────────────────────
|
|
345
|
-
# download command — bulk
|
|
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
|
|
410
|
+
"""Download a dataset as a single file (Parquet, CSV, or GeoParquet).
|
|
392
411
|
|
|
393
|
-
Uses
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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":
|
|
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
|
-
|
|
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=
|
|
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(
|
|
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.
|
|
8
|
-
description = "Python client for the eolas.fyi statistical data API (NZ
|
|
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 =
|
|
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
|
|
621
|
-
"""
|
|
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/
|
|
627
|
-
|
|
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",
|
|
631
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|