eolas-data 1.3.2__tar.gz → 1.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {eolas_data-1.3.2 → eolas_data-1.3.4}/PKG-INFO +2 -2
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/__init__.py +1 -1
- eolas_data-1.3.4/eolas_data/_regen_names.py +58 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/client.py +131 -21
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/meta.py +29 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/pyproject.toml +2 -2
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_as_arrow.py +1 -1
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_client.py +73 -1
- eolas_data-1.3.2/eolas_data/_regen_names.py +0 -57
- {eolas_data-1.3.2 → eolas_data-1.3.4}/.github/workflows/catalog-drift.yml +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/.github/workflows/publish.yml +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/.github/workflows/smoke.yml +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/.github/workflows/test.yml +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/.gitignore +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/README.md +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/_dataset_names.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/cdc.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/cli.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/console.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/dataset.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/exceptions.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/library.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/rows.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/schedule.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/search.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/scripts/preflight.sh +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_cdc_roundtrip.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_cli.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_keyring.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_library.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_meta.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_progress.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_rows.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_schedule.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_search.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_smoke_live.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_sync_bulk.py +0 -0
- {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_sync_changes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: eolas-data
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.4
|
|
4
4
|
Summary: Python client for the eolas.fyi statistical data API (NZ, Australia, OECD)
|
|
5
5
|
Project-URL: Homepage, https://eolas.fyi
|
|
6
6
|
Project-URL: Documentation, https://docs.eolas.fyi/
|
|
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Topic :: Scientific/Engineering
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Requires-Dist: pandas>=1.5
|
|
24
|
-
Requires-Dist: pyarrow>=
|
|
24
|
+
Requires-Dist: pyarrow>=24
|
|
25
25
|
Requires-Dist: requests>=2.28
|
|
26
26
|
Requires-Dist: rich>=13
|
|
27
27
|
Requires-Dist: tqdm>=4.60
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Regenerate ``_dataset_names.py`` from the live API.
|
|
2
|
+
|
|
3
|
+
Run before each release:
|
|
4
|
+
|
|
5
|
+
python -m eolas_data._regen_names
|
|
6
|
+
|
|
7
|
+
Uses the same API-key resolution as :class:`~eolas_data.client.Client`
|
|
8
|
+
(env var → keyring → ``~/.eolas/config.json``). The catalog endpoint
|
|
9
|
+
requires authentication.
|
|
10
|
+
|
|
11
|
+
Writes to the same file inside the package. Commit the result.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as _dt
|
|
16
|
+
import pathlib as _pathlib
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def regenerate() -> None:
|
|
20
|
+
from .client import Client
|
|
21
|
+
|
|
22
|
+
df = Client().list()
|
|
23
|
+
names = sorted(df["name"].tolist())
|
|
24
|
+
today = _dt.date.today().isoformat()
|
|
25
|
+
|
|
26
|
+
out = _pathlib.Path(__file__).with_name("_dataset_names.py")
|
|
27
|
+
lines: list[str] = []
|
|
28
|
+
lines.append('"""')
|
|
29
|
+
lines.append("Type stubs for dataset names.")
|
|
30
|
+
lines.append("")
|
|
31
|
+
lines.append("Auto-generated from https://api.eolas.fyi/v1/datasets at release time.")
|
|
32
|
+
lines.append(f"Snapshot: {today} ({len(names)} datasets).")
|
|
33
|
+
lines.append("Regenerate before each release with `python -m eolas_data._regen_names`.")
|
|
34
|
+
lines.append("")
|
|
35
|
+
lines.append("At runtime this is just a string — `Literal[...]` only constrains static type")
|
|
36
|
+
lines.append("checkers like mypy/pyright, so passing a name not in this list still works,")
|
|
37
|
+
lines.append("it just doesn't autocomplete.")
|
|
38
|
+
lines.append('"""')
|
|
39
|
+
lines.append("from typing import Literal")
|
|
40
|
+
lines.append("")
|
|
41
|
+
lines.append(f'CATALOG_SNAPSHOT_DATE = "{today}"')
|
|
42
|
+
lines.append(f"CATALOG_SNAPSHOT_COUNT = {len(names)}")
|
|
43
|
+
lines.append("")
|
|
44
|
+
lines.append("DatasetName = Literal[")
|
|
45
|
+
for n in names:
|
|
46
|
+
lines.append(f" {n!r},")
|
|
47
|
+
lines.append("]")
|
|
48
|
+
lines.append("")
|
|
49
|
+
lines.append("ALL_NAMES: tuple[str, ...] = (")
|
|
50
|
+
for n in names:
|
|
51
|
+
lines.append(f" {n!r},")
|
|
52
|
+
lines.append(")")
|
|
53
|
+
out.write_text("\n".join(lines) + "\n")
|
|
54
|
+
print(f"wrote {len(names)} datasets to {out}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
regenerate()
|
|
@@ -15,7 +15,7 @@ import requests
|
|
|
15
15
|
from .console import nag_json_transport_once
|
|
16
16
|
from .dataset import Dataset
|
|
17
17
|
from .library import resolve_library_dir
|
|
18
|
-
from .meta import attach_meta, split_meta
|
|
18
|
+
from .meta import attach_meta, merge_provenance, split_meta
|
|
19
19
|
from .exceptions import (
|
|
20
20
|
APIError,
|
|
21
21
|
AuthenticationError,
|
|
@@ -269,6 +269,8 @@ class Client:
|
|
|
269
269
|
*,
|
|
270
270
|
source: str = "",
|
|
271
271
|
meta: bool = True,
|
|
272
|
+
provenance: Optional[dict] = None,
|
|
273
|
+
data_sources: Optional[list] = None,
|
|
272
274
|
) -> "pd.DataFrame":
|
|
273
275
|
table_meta: dict = {}
|
|
274
276
|
column_meta = None
|
|
@@ -277,10 +279,14 @@ class Client:
|
|
|
277
279
|
table_meta, column_meta = split_meta(self._info_cached(name))
|
|
278
280
|
except Exception:
|
|
279
281
|
pass
|
|
282
|
+
if provenance:
|
|
283
|
+
table_meta = merge_provenance(table_meta, provenance)
|
|
284
|
+
if data_sources:
|
|
285
|
+
table_meta = {**table_meta, "data_sources": data_sources}
|
|
280
286
|
return attach_meta(
|
|
281
287
|
result,
|
|
282
288
|
name=str(name),
|
|
283
|
-
source=source,
|
|
289
|
+
source=source or (table_meta.get("source") or ""),
|
|
284
290
|
table_meta=table_meta,
|
|
285
291
|
column_meta=column_meta,
|
|
286
292
|
)
|
|
@@ -325,6 +331,19 @@ class Client:
|
|
|
325
331
|
"csv_gz": ".csv.gz",
|
|
326
332
|
"geoparquet": ".geo.parquet",
|
|
327
333
|
}
|
|
334
|
+
# Mirrors the API guard in datasets.py — unbounded live pulls on datasets
|
|
335
|
+
# above this row count (or with geometry) return HTTP 413.
|
|
336
|
+
_LARGE_DATASET_ROW_THRESHOLD = 100_000
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _bulk_export_allowed(meta: dict) -> bool:
|
|
340
|
+
return (meta.get("bulk_export_class") or "").lower() not in ("", "none")
|
|
341
|
+
|
|
342
|
+
@classmethod
|
|
343
|
+
def _live_pull_blocked(cls, meta: dict) -> bool:
|
|
344
|
+
"""True when limit=0 with no date bounds would hit the API 413 guard."""
|
|
345
|
+
row_count = int(meta.get("row_count_at_last_refresh") or 0)
|
|
346
|
+
return bool(meta.get("has_geometry")) or row_count > cls._LARGE_DATASET_ROW_THRESHOLD
|
|
328
347
|
|
|
329
348
|
@staticmethod
|
|
330
349
|
def _require_bulk_export(meta: dict, name: Union[str, "DatasetName"]) -> None:
|
|
@@ -1885,22 +1904,37 @@ class Client:
|
|
|
1885
1904
|
return _pa.Table.from_pandas(pd.read_csv(file_path), preserve_index=False)
|
|
1886
1905
|
return _pq.read_table(file_path)
|
|
1887
1906
|
|
|
1888
|
-
|
|
1889
|
-
if
|
|
1907
|
+
def _read_bulk_file(path: pathlib.Path, bulk_fmt: str):
|
|
1908
|
+
if bulk_fmt == "csv_gz":
|
|
1909
|
+
return pd.read_csv(path)
|
|
1910
|
+
if bulk_fmt == "geoparquet" and resolved_as_geo:
|
|
1890
1911
|
try:
|
|
1891
1912
|
import geopandas as gpd
|
|
1892
|
-
|
|
1893
|
-
return self._attach_dataset_meta(result, name, meta=meta)
|
|
1913
|
+
return gpd.read_parquet(path)
|
|
1894
1914
|
except ImportError:
|
|
1895
1915
|
pass
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
result =
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1916
|
+
return pd.read_parquet(path)
|
|
1917
|
+
|
|
1918
|
+
try:
|
|
1919
|
+
result = _read_bulk_file(file_path, fmt)
|
|
1920
|
+
except OSError as exc:
|
|
1921
|
+
# Bulk snapshots are written with pyarrow 24+ (Parquet 2.6). Older
|
|
1922
|
+
# pyarrow builds fail with "Repetition level histogram size mismatch".
|
|
1923
|
+
# csv_gz is always generated alongside parquet — fall back to it.
|
|
1924
|
+
if fmt in ("parquet", "geoparquet") and "histogram" in str(exc).lower():
|
|
1925
|
+
_log.warning(
|
|
1926
|
+
"Parquet read failed for %s (%s) — falling back to csv_gz bulk",
|
|
1927
|
+
name, exc,
|
|
1928
|
+
)
|
|
1929
|
+
csv_path = cache_path / f"{name}{self._BULK_EXTENSIONS['csv_gz']}"
|
|
1930
|
+
self.sync_bulk(
|
|
1931
|
+
name, path=csv_path, format="csv_gz",
|
|
1932
|
+
freshness=freshness, progress=progress,
|
|
1933
|
+
)
|
|
1934
|
+
result = _read_bulk_file(csv_path, "csv_gz")
|
|
1935
|
+
else:
|
|
1936
|
+
raise
|
|
1937
|
+
return self._attach_dataset_meta(result, name, meta=meta)
|
|
1904
1938
|
|
|
1905
1939
|
# ------------------------------------------------------------------
|
|
1906
1940
|
# Core data fetch
|
|
@@ -1917,6 +1951,7 @@ class Client:
|
|
|
1917
1951
|
as_geo: Optional[bool] = None,
|
|
1918
1952
|
as_arrow: bool = False,
|
|
1919
1953
|
meta: bool = True,
|
|
1954
|
+
envelope: bool = False,
|
|
1920
1955
|
) -> Dataset:
|
|
1921
1956
|
"""Fetch dataset rows as a pandas (or polars / geopandas) DataFrame.
|
|
1922
1957
|
|
|
@@ -1954,6 +1989,9 @@ class Client:
|
|
|
1954
1989
|
meta: When ``True`` (default), attach table/column metadata from
|
|
1955
1990
|
:meth:`info` (session-cached on this client). Pass
|
|
1956
1991
|
``False`` to skip the extra round-trip.
|
|
1992
|
+
envelope: When ``True``, request ``?envelope=1`` (JSON only) and
|
|
1993
|
+
attach the ``data_sources`` licence block alongside rows.
|
|
1994
|
+
Response ``X-Eolas-*`` headers are merged into metadata.
|
|
1957
1995
|
|
|
1958
1996
|
Returns:
|
|
1959
1997
|
A :class:`Dataset` (pandas DataFrame subclass), a polars DataFrame
|
|
@@ -1976,6 +2014,45 @@ class Client:
|
|
|
1976
2014
|
"as_geo materialises geometry as shapely objects in a GeoDataFrame. "
|
|
1977
2015
|
"Choose one."
|
|
1978
2016
|
)
|
|
2017
|
+
if envelope and (format != "json" or as_arrow):
|
|
2018
|
+
raise ValueError(
|
|
2019
|
+
"envelope=True requires format='json' and as_arrow=False."
|
|
2020
|
+
)
|
|
2021
|
+
|
|
2022
|
+
from .rows import apply_row_limit, resolve_fetch_limit, sort_by_date
|
|
2023
|
+
|
|
2024
|
+
# ---- whole-dataset pull on large/geo tables → bulk cache -------------
|
|
2025
|
+
# Matches the API 413 guard: limit=0 with no start/end on >100k-row or
|
|
2026
|
+
# geometry datasets is refused. Transparently serve from get_local()
|
|
2027
|
+
# (CDN-backed Parquet/GeoParquet) so client.get("nz_addresses") works.
|
|
2028
|
+
if (
|
|
2029
|
+
limit is None
|
|
2030
|
+
and start is None
|
|
2031
|
+
and end is None
|
|
2032
|
+
and format == "json"
|
|
2033
|
+
and not envelope
|
|
2034
|
+
and not as_arrow
|
|
2035
|
+
):
|
|
2036
|
+
try:
|
|
2037
|
+
info_meta = self._info_cached(name)
|
|
2038
|
+
if self._bulk_export_allowed(info_meta) and self._live_pull_blocked(info_meta):
|
|
2039
|
+
result = self.get_local(
|
|
2040
|
+
name, as_geo=as_geo, as_arrow=False, meta=meta,
|
|
2041
|
+
)
|
|
2042
|
+
if engine == "polars":
|
|
2043
|
+
try:
|
|
2044
|
+
import polars as pl
|
|
2045
|
+
return pl.from_pandas(result)
|
|
2046
|
+
except ImportError:
|
|
2047
|
+
raise ImportError(
|
|
2048
|
+
"polars is required for engine='polars'. "
|
|
2049
|
+
"Install with: pip install eolas-data[polars]"
|
|
2050
|
+
)
|
|
2051
|
+
return result
|
|
2052
|
+
except (BulkLicenceRestricted, BulkUpgradeRequired, BulkNotYetAvailable):
|
|
2053
|
+
raise
|
|
2054
|
+
except Exception:
|
|
2055
|
+
pass # fall through to live path (e.g. metadata lookup failed)
|
|
1979
2056
|
|
|
1980
2057
|
# ---- early in-memory cache check -------------------------------------
|
|
1981
2058
|
_early_cache_key = f"{name}:{start}:{end}:{format}:{0 if limit is None else int(limit)}:{as_geo}"
|
|
@@ -1988,21 +2065,35 @@ class Client:
|
|
|
1988
2065
|
params["start"] = start
|
|
1989
2066
|
if end:
|
|
1990
2067
|
params["end"] = end
|
|
1991
|
-
from .rows import apply_row_limit, resolve_fetch_limit, sort_by_date
|
|
1992
2068
|
|
|
1993
2069
|
fetch_limit, user_limit = resolve_fetch_limit(limit)
|
|
2070
|
+
# Positive limits on large/geo datasets must be sent to the API — the
|
|
2071
|
+
# client normally uses limit=0 and trims client-side for dated series,
|
|
2072
|
+
# but limit=0 triggers the API 413 guard on geometry / >100k tables.
|
|
2073
|
+
if user_limit and int(user_limit) > 0 and start is None and end is None:
|
|
2074
|
+
try:
|
|
2075
|
+
info_meta = self._info_cached(name)
|
|
2076
|
+
if self._live_pull_blocked(info_meta):
|
|
2077
|
+
fetch_limit = int(user_limit)
|
|
2078
|
+
except Exception:
|
|
2079
|
+
pass
|
|
1994
2080
|
params["limit"] = fetch_limit
|
|
1995
2081
|
|
|
1996
2082
|
cache_key = f"{name}:{start}:{end}:{format}:{0 if limit is None else int(limit)}:{as_geo}"
|
|
1997
2083
|
if self._cache is not None and cache_key in self._cache:
|
|
1998
2084
|
return self._cache[cache_key]
|
|
1999
2085
|
|
|
2086
|
+
data_sources = None
|
|
2087
|
+
provenance = None
|
|
2000
2088
|
if format == "csv":
|
|
2001
2089
|
from io import StringIO
|
|
2002
2090
|
resp = self._raw_get(f"/v1/datasets/{name}/data", params={"format": "csv", **params})
|
|
2091
|
+
provenance = resp.headers
|
|
2003
2092
|
df = pd.read_csv(StringIO(resp.text))
|
|
2004
2093
|
else:
|
|
2005
|
-
df = self._fetch_dataframe(
|
|
2094
|
+
df, provenance, data_sources = self._fetch_dataframe(
|
|
2095
|
+
name, params, envelope=envelope,
|
|
2096
|
+
)
|
|
2006
2097
|
if "date" in df.columns:
|
|
2007
2098
|
df["date"] = pd.to_datetime(df["date"])
|
|
2008
2099
|
|
|
@@ -2020,7 +2111,10 @@ class Client:
|
|
|
2020
2111
|
return tbl
|
|
2021
2112
|
|
|
2022
2113
|
result = Dataset(df)
|
|
2023
|
-
result = self._attach_dataset_meta(
|
|
2114
|
+
result = self._attach_dataset_meta(
|
|
2115
|
+
result, name, meta=meta,
|
|
2116
|
+
provenance=provenance, data_sources=data_sources,
|
|
2117
|
+
)
|
|
2024
2118
|
|
|
2025
2119
|
if engine == "polars":
|
|
2026
2120
|
try:
|
|
@@ -2048,13 +2142,27 @@ class Client:
|
|
|
2048
2142
|
# HTTP helpers
|
|
2049
2143
|
# ------------------------------------------------------------------
|
|
2050
2144
|
|
|
2051
|
-
def _fetch_dataframe(
|
|
2145
|
+
def _fetch_dataframe(
|
|
2146
|
+
self, name, params: dict, *, envelope: bool = False,
|
|
2147
|
+
) -> tuple[pd.DataFrame, object, Optional[list]]:
|
|
2052
2148
|
"""Fetch dataset rows as a DataFrame, negotiating Arrow IPC over the
|
|
2053
2149
|
wire (≈5x faster end-to-end, ≈82x faster parse than JSON on large
|
|
2054
2150
|
pulls — benchmarked 2026-05-18). Transparently falls back to JSON for
|
|
2055
2151
|
older servers, unexpected content-types, or any pyarrow issue, so the
|
|
2056
2152
|
returned DataFrame is identical either way.
|
|
2153
|
+
|
|
2154
|
+
Returns ``(dataframe, response_headers, data_sources_or_none)``.
|
|
2057
2155
|
"""
|
|
2156
|
+
if envelope:
|
|
2157
|
+
resp = self._raw_get(
|
|
2158
|
+
f"/v1/datasets/{name}/data",
|
|
2159
|
+
params={**params, "envelope": 1},
|
|
2160
|
+
)
|
|
2161
|
+
payload = resp.json()
|
|
2162
|
+
records = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
2163
|
+
sources = payload.get("data_sources") if isinstance(payload, dict) else None
|
|
2164
|
+
return pd.DataFrame(records), resp.headers, sources
|
|
2165
|
+
|
|
2058
2166
|
if self._arrow_supported is not False:
|
|
2059
2167
|
try:
|
|
2060
2168
|
import io
|
|
@@ -2067,7 +2175,7 @@ class Client:
|
|
|
2067
2175
|
if "arrow" in resp.headers.get("content-type", ""):
|
|
2068
2176
|
self._arrow_supported = True
|
|
2069
2177
|
tbl = pa.ipc.open_stream(io.BytesIO(resp.content)).read_all()
|
|
2070
|
-
return tbl.to_pandas()
|
|
2178
|
+
return tbl.to_pandas(), resp.headers, None
|
|
2071
2179
|
# Old server ignored format=arrow and returned JSON. Remember
|
|
2072
2180
|
# so we don't pay the failed round-trip on every future call.
|
|
2073
2181
|
self._arrow_supported = False
|
|
@@ -2076,9 +2184,11 @@ class Client:
|
|
|
2076
2184
|
self._arrow_supported = False
|
|
2077
2185
|
nag_json_transport_once()
|
|
2078
2186
|
|
|
2079
|
-
|
|
2187
|
+
resp = self._raw_get(f"/v1/datasets/{name}/data", params=params)
|
|
2188
|
+
data = resp.json()
|
|
2080
2189
|
records = data.get("data", data) if isinstance(data, dict) else data
|
|
2081
|
-
|
|
2190
|
+
sources = data.get("data_sources") if isinstance(data, dict) else None
|
|
2191
|
+
return pd.DataFrame(records), resp.headers, sources
|
|
2082
2192
|
|
|
2083
2193
|
def _get(self, path: str, params: Optional[dict] = None) -> dict:
|
|
2084
2194
|
return self._raw_get(path, params=params).json()
|
|
@@ -6,6 +6,35 @@ from typing import Any, Optional
|
|
|
6
6
|
import pandas as pd
|
|
7
7
|
|
|
8
8
|
# Table-level fields we keep on Dataset.eolas_meta (exclude nested columns).
|
|
9
|
+
_PROVENANCE_HEADERS = {
|
|
10
|
+
"X-Eolas-Attribution": "attribution_text",
|
|
11
|
+
"X-Eolas-Licence": "licence",
|
|
12
|
+
"X-Eolas-Source": "source",
|
|
13
|
+
"X-Eolas-Source-URL": "source_url",
|
|
14
|
+
"X-Eolas-Namespace": "namespace",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def provenance_from_headers(headers) -> dict:
|
|
19
|
+
"""Extract response provenance from X-Eolas-* headers on /data responses."""
|
|
20
|
+
out: dict[str, str] = {}
|
|
21
|
+
get = headers.get if hasattr(headers, "get") else lambda k, d=None: headers.get(k, d)
|
|
22
|
+
for hdr, key in _PROVENANCE_HEADERS.items():
|
|
23
|
+
val = get(hdr)
|
|
24
|
+
if val:
|
|
25
|
+
out[key] = str(val)
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def merge_provenance(table_meta: Optional[dict], headers) -> dict:
|
|
30
|
+
"""Merge catalogue metadata with live response headers (headers win when set)."""
|
|
31
|
+
merged = dict(table_meta or {})
|
|
32
|
+
for key, val in provenance_from_headers(headers).items():
|
|
33
|
+
if val:
|
|
34
|
+
merged[key] = val
|
|
35
|
+
return merged
|
|
36
|
+
|
|
37
|
+
|
|
9
38
|
_TABLE_META_KEYS = (
|
|
10
39
|
"name",
|
|
11
40
|
"namespace",
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "eolas-data"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.4"
|
|
8
8
|
description = "Python client for the eolas.fyi statistical data API (NZ, Australia, OECD)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -13,7 +13,7 @@ requires-python = ">=3.10"
|
|
|
13
13
|
dependencies = [
|
|
14
14
|
"requests>=2.28",
|
|
15
15
|
"pandas>=1.5",
|
|
16
|
-
"pyarrow>=
|
|
16
|
+
"pyarrow>=24",
|
|
17
17
|
"tqdm>=4.60",
|
|
18
18
|
"rich>=13",
|
|
19
19
|
# typer powers the `eolas` console script, which is always registered in
|
|
@@ -44,7 +44,7 @@ def test_get_live_as_arrow_returns_arrow_table(client):
|
|
|
44
44
|
"""as_arrow=True on the live path converts the JSON-fetched DataFrame to pa.Table."""
|
|
45
45
|
fake_df = pd.DataFrame({"date": ["2023-01-01"], "value": [1100.5]})
|
|
46
46
|
|
|
47
|
-
with patch.object(client, "_fetch_dataframe", return_value=fake_df):
|
|
47
|
+
with patch.object(client, "_fetch_dataframe", return_value=(fake_df, {}, None)):
|
|
48
48
|
result = client.get("nz_cpi", as_arrow=True)
|
|
49
49
|
|
|
50
50
|
assert isinstance(result, pa.Table)
|
|
@@ -153,9 +153,13 @@ def test_get_limit_returns_most_recent(client):
|
|
|
153
153
|
|
|
154
154
|
@resp_lib.activate
|
|
155
155
|
def test_get_limit_requests_full_window_from_server(client):
|
|
156
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi",
|
|
157
|
+
json={"name": "nz_cpi", "source": "Stats NZ", "has_geometry": False,
|
|
158
|
+
"row_count_at_last_refresh": 500})
|
|
156
159
|
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi/data", json={"data": RECORDS})
|
|
157
160
|
client.get("nz_cpi", limit=2)
|
|
158
|
-
|
|
161
|
+
data_reqs = [c for c in resp_lib.calls if "/data?" in c.request.url or c.request.url.endswith("/data")]
|
|
162
|
+
assert any("limit=0" in c.request.url for c in data_reqs)
|
|
159
163
|
|
|
160
164
|
|
|
161
165
|
@resp_lib.activate
|
|
@@ -167,6 +171,38 @@ def test_get_passes_date_params(client):
|
|
|
167
171
|
assert "end=2023-06-30" in req.url
|
|
168
172
|
|
|
169
173
|
|
|
174
|
+
@resp_lib.activate
|
|
175
|
+
def test_get_merges_provenance_headers(client):
|
|
176
|
+
resp_lib.add(
|
|
177
|
+
resp_lib.GET,
|
|
178
|
+
f"{BASE}/v1/datasets/nz_cpi/data",
|
|
179
|
+
json={"data": RECORDS},
|
|
180
|
+
adding_headers={
|
|
181
|
+
"X-Eolas-Attribution": "Source: OECD, CC-BY 4.0.",
|
|
182
|
+
"X-Eolas-Licence": "CC-BY 4.0",
|
|
183
|
+
"X-Eolas-Source": "OECD",
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
df = client.get("nz_cpi", meta=False)
|
|
187
|
+
meta = df.attrs.get("eolas_meta", {})
|
|
188
|
+
assert meta.get("attribution_text") == "Source: OECD, CC-BY 4.0."
|
|
189
|
+
assert meta.get("licence") == "CC-BY 4.0"
|
|
190
|
+
assert meta.get("source") == "OECD"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@resp_lib.activate
|
|
194
|
+
def test_get_envelope_attaches_data_sources(client):
|
|
195
|
+
payload = {
|
|
196
|
+
"data_sources": [{"name": "nz_cpi", "licence": "CC-BY 4.0"}],
|
|
197
|
+
"data": RECORDS,
|
|
198
|
+
}
|
|
199
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi/data", json=payload)
|
|
200
|
+
df = client.get("nz_cpi", envelope=True, meta=False)
|
|
201
|
+
meta = df.attrs.get("eolas_meta", {})
|
|
202
|
+
assert meta.get("data_sources") == payload["data_sources"]
|
|
203
|
+
assert "envelope=1" in resp_lib.calls[0].request.url
|
|
204
|
+
|
|
205
|
+
|
|
170
206
|
@resp_lib.activate
|
|
171
207
|
def test_get_csv_returns_dataframe(client):
|
|
172
208
|
csv_body = "date,period,value\n2023-01-01,2023Q1,100.0\n"
|
|
@@ -213,6 +249,42 @@ def test_get_as_geo_false_keeps_wkt(client):
|
|
|
213
249
|
assert df["geometry_wkt"].iloc[0].startswith("POINT")
|
|
214
250
|
|
|
215
251
|
|
|
252
|
+
LARGE_GEO_META = {
|
|
253
|
+
"name": "nz_addresses",
|
|
254
|
+
"namespace": "linz",
|
|
255
|
+
"bulk_export_class": "materialised",
|
|
256
|
+
"has_geometry": True,
|
|
257
|
+
"geometry_type": "point",
|
|
258
|
+
"row_count_at_last_refresh": 2_418_264,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@resp_lib.activate
|
|
263
|
+
def test_get_whole_dataset_defers_to_get_local_for_large_geo(client):
|
|
264
|
+
"""Whole-dataset pulls on geometry tables route to get_local(), not /data."""
|
|
265
|
+
sentinel = pd.DataFrame({"address_id": [1], "geometry_wkt": ["POINT (0 0)"]})
|
|
266
|
+
with patch.object(client, "get_local", return_value=sentinel) as mock_local:
|
|
267
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_addresses",
|
|
268
|
+
json=LARGE_GEO_META, status=200)
|
|
269
|
+
result = client.get("nz_addresses")
|
|
270
|
+
mock_local.assert_called_once()
|
|
271
|
+
assert result is sentinel
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@resp_lib.activate
|
|
275
|
+
def test_get_limit_on_large_geo_sends_bounded_limit(client):
|
|
276
|
+
"""Positive limits on geometry tables must not send limit=0 (API 413)."""
|
|
277
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_addresses",
|
|
278
|
+
json=LARGE_GEO_META, status=200)
|
|
279
|
+
resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_addresses/data",
|
|
280
|
+
json={"data": GEO_RECORDS})
|
|
281
|
+
client.get("nz_addresses", limit=2)
|
|
282
|
+
data_reqs = [c for c in resp_lib.calls if "/data?" in c.request.url or c.request.url.endswith("/data")]
|
|
283
|
+
assert data_reqs, "expected a /data request"
|
|
284
|
+
assert any("limit=2" in c.request.url for c in data_reqs)
|
|
285
|
+
assert not any("limit=0" in c.request.url for c in data_reqs)
|
|
286
|
+
|
|
287
|
+
|
|
216
288
|
@resp_lib.activate
|
|
217
289
|
def test_get_no_geometry_column_returns_dataset(client):
|
|
218
290
|
"""Datasets without a geometry_wkt column still return a Dataset even with as_geo=None."""
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""Regenerate ``_dataset_names.py`` from the live API.
|
|
2
|
-
|
|
3
|
-
Run before each release:
|
|
4
|
-
|
|
5
|
-
python -m eolas_data._regen_names
|
|
6
|
-
|
|
7
|
-
Writes to the same file inside the package. Commit the result.
|
|
8
|
-
"""
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import datetime as _dt
|
|
12
|
-
import json as _json
|
|
13
|
-
import pathlib as _pathlib
|
|
14
|
-
import urllib.request as _req
|
|
15
|
-
|
|
16
|
-
API = "https://api.eolas.fyi/v1/datasets"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def regenerate() -> None:
|
|
20
|
-
with _req.urlopen(API, timeout=30) as r:
|
|
21
|
-
data = _json.load(r)
|
|
22
|
-
names = sorted({d["name"] for d in data})
|
|
23
|
-
today = _dt.date.today().isoformat()
|
|
24
|
-
|
|
25
|
-
out = _pathlib.Path(__file__).with_name("_dataset_names.py")
|
|
26
|
-
lines: list[str] = []
|
|
27
|
-
lines.append('"""')
|
|
28
|
-
lines.append('Type stubs for dataset names.')
|
|
29
|
-
lines.append('')
|
|
30
|
-
lines.append('Auto-generated from https://api.eolas.fyi/v1/datasets at release time.')
|
|
31
|
-
lines.append(f'Snapshot: {today} ({len(names)} datasets).')
|
|
32
|
-
lines.append('Regenerate before each release with `python -m eolas_data._regen_names`.')
|
|
33
|
-
lines.append('')
|
|
34
|
-
lines.append('At runtime this is just a string — `Literal[...]` only constrains static type')
|
|
35
|
-
lines.append("checkers like mypy/pyright, so passing a name not in this list still works,")
|
|
36
|
-
lines.append("it just doesn't autocomplete.")
|
|
37
|
-
lines.append('"""')
|
|
38
|
-
lines.append('from typing import Literal')
|
|
39
|
-
lines.append('')
|
|
40
|
-
lines.append(f'CATALOG_SNAPSHOT_DATE = "{today}"')
|
|
41
|
-
lines.append(f'CATALOG_SNAPSHOT_COUNT = {len(names)}')
|
|
42
|
-
lines.append('')
|
|
43
|
-
lines.append('DatasetName = Literal[')
|
|
44
|
-
for n in names:
|
|
45
|
-
lines.append(f' {n!r},')
|
|
46
|
-
lines.append(']')
|
|
47
|
-
lines.append('')
|
|
48
|
-
lines.append('ALL_NAMES: tuple[str, ...] = (')
|
|
49
|
-
for n in names:
|
|
50
|
-
lines.append(f' {n!r},')
|
|
51
|
-
lines.append(')')
|
|
52
|
-
out.write_text("\n".join(lines) + "\n")
|
|
53
|
-
print(f"wrote {len(names)} datasets to {out}")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if __name__ == "__main__":
|
|
57
|
-
regenerate()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|