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.
Files changed (38) hide show
  1. {eolas_data-1.3.2 → eolas_data-1.3.4}/PKG-INFO +2 -2
  2. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/__init__.py +1 -1
  3. eolas_data-1.3.4/eolas_data/_regen_names.py +58 -0
  4. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/client.py +131 -21
  5. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/meta.py +29 -0
  6. {eolas_data-1.3.2 → eolas_data-1.3.4}/pyproject.toml +2 -2
  7. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_as_arrow.py +1 -1
  8. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_client.py +73 -1
  9. eolas_data-1.3.2/eolas_data/_regen_names.py +0 -57
  10. {eolas_data-1.3.2 → eolas_data-1.3.4}/.github/workflows/catalog-drift.yml +0 -0
  11. {eolas_data-1.3.2 → eolas_data-1.3.4}/.github/workflows/publish.yml +0 -0
  12. {eolas_data-1.3.2 → eolas_data-1.3.4}/.github/workflows/smoke.yml +0 -0
  13. {eolas_data-1.3.2 → eolas_data-1.3.4}/.github/workflows/test.yml +0 -0
  14. {eolas_data-1.3.2 → eolas_data-1.3.4}/.gitignore +0 -0
  15. {eolas_data-1.3.2 → eolas_data-1.3.4}/README.md +0 -0
  16. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/_dataset_names.py +0 -0
  17. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/cdc.py +0 -0
  18. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/cli.py +0 -0
  19. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/console.py +0 -0
  20. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/dataset.py +0 -0
  21. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/exceptions.py +0 -0
  22. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/library.py +0 -0
  23. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/rows.py +0 -0
  24. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/schedule.py +0 -0
  25. {eolas_data-1.3.2 → eolas_data-1.3.4}/eolas_data/search.py +0 -0
  26. {eolas_data-1.3.2 → eolas_data-1.3.4}/scripts/preflight.sh +0 -0
  27. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_cdc_roundtrip.py +0 -0
  28. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_cli.py +0 -0
  29. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_keyring.py +0 -0
  30. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_library.py +0 -0
  31. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_meta.py +0 -0
  32. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_progress.py +0 -0
  33. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_rows.py +0 -0
  34. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_schedule.py +0 -0
  35. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_search.py +0 -0
  36. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_smoke_live.py +0 -0
  37. {eolas_data-1.3.2 → eolas_data-1.3.4}/tests/test_sync_bulk.py +0 -0
  38. {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.2
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>=14
24
+ Requires-Dist: pyarrow>=24
25
25
  Requires-Dist: requests>=2.28
26
26
  Requires-Dist: rich>=13
27
27
  Requires-Dist: tqdm>=4.60
@@ -15,7 +15,7 @@ from .exceptions import (
15
15
  WatermarkExpired,
16
16
  )
17
17
 
18
- __version__ = "1.3.2"
18
+ __version__ = "1.3.3"
19
19
 
20
20
  __all__ = [
21
21
  "Client",
@@ -0,0 +1,58 @@
1
+ """Regenerate ``_dataset_names.py`` from the live API.
2
+
3
+ Run before each release:
4
+
5
+ python -m eolas_data._regen_names
6
+
7
+ Uses the same API-key resolution as :class:`~eolas_data.client.Client`
8
+ (env var → keyring → ``~/.eolas/config.json``). The catalog endpoint
9
+ requires authentication.
10
+
11
+ Writes to the same file inside the package. Commit the result.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import datetime as _dt
16
+ import pathlib as _pathlib
17
+
18
+
19
+ def regenerate() -> None:
20
+ from .client import Client
21
+
22
+ df = Client().list()
23
+ names = sorted(df["name"].tolist())
24
+ today = _dt.date.today().isoformat()
25
+
26
+ out = _pathlib.Path(__file__).with_name("_dataset_names.py")
27
+ lines: list[str] = []
28
+ lines.append('"""')
29
+ lines.append("Type stubs for dataset names.")
30
+ lines.append("")
31
+ lines.append("Auto-generated from https://api.eolas.fyi/v1/datasets at release time.")
32
+ lines.append(f"Snapshot: {today} ({len(names)} datasets).")
33
+ lines.append("Regenerate before each release with `python -m eolas_data._regen_names`.")
34
+ lines.append("")
35
+ lines.append("At runtime this is just a string — `Literal[...]` only constrains static type")
36
+ lines.append("checkers like mypy/pyright, so passing a name not in this list still works,")
37
+ lines.append("it just doesn't autocomplete.")
38
+ lines.append('"""')
39
+ lines.append("from typing import Literal")
40
+ lines.append("")
41
+ lines.append(f'CATALOG_SNAPSHOT_DATE = "{today}"')
42
+ lines.append(f"CATALOG_SNAPSHOT_COUNT = {len(names)}")
43
+ lines.append("")
44
+ lines.append("DatasetName = Literal[")
45
+ for n in names:
46
+ lines.append(f" {n!r},")
47
+ lines.append("]")
48
+ lines.append("")
49
+ lines.append("ALL_NAMES: tuple[str, ...] = (")
50
+ for n in names:
51
+ lines.append(f" {n!r},")
52
+ lines.append(")")
53
+ out.write_text("\n".join(lines) + "\n")
54
+ print(f"wrote {len(names)} datasets to {out}")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ regenerate()
@@ -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
- if fmt == "geoparquet":
1889
- if resolved_as_geo:
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
- result = gpd.read_parquet(file_path)
1893
- return self._attach_dataset_meta(result, name, meta=meta)
1913
+ return gpd.read_parquet(path)
1894
1914
  except ImportError:
1895
1915
  pass
1896
- result = pd.read_parquet(file_path)
1897
- return self._attach_dataset_meta(result, name, meta=meta)
1898
- elif fmt == "csv_gz":
1899
- result = pd.read_csv(file_path)
1900
- return self._attach_dataset_meta(result, name, meta=meta)
1901
- else: # parquet
1902
- result = pd.read_parquet(file_path)
1903
- return self._attach_dataset_meta(result, name, meta=meta)
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(name, params)
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(result, name, meta=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(self, name, params: dict) -> pd.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
- data = self._get(f"/v1/datasets/{name}/data", params=params)
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
- return pd.DataFrame(records)
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.2"
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>=14",
16
+ "pyarrow>=24",
17
17
  "tqdm>=4.60",
18
18
  "rich>=13",
19
19
  # typer powers the `eolas` console script, which is always registered in
@@ -44,7 +44,7 @@ def test_get_live_as_arrow_returns_arrow_table(client):
44
44
  """as_arrow=True on the live path converts the JSON-fetched DataFrame to pa.Table."""
45
45
  fake_df = pd.DataFrame({"date": ["2023-01-01"], "value": [1100.5]})
46
46
 
47
- with patch.object(client, "_fetch_dataframe", return_value=fake_df):
47
+ with patch.object(client, "_fetch_dataframe", return_value=(fake_df, {}, None)):
48
48
  result = client.get("nz_cpi", as_arrow=True)
49
49
 
50
50
  assert isinstance(result, pa.Table)
@@ -153,9 +153,13 @@ def test_get_limit_returns_most_recent(client):
153
153
 
154
154
  @resp_lib.activate
155
155
  def test_get_limit_requests_full_window_from_server(client):
156
+ resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi",
157
+ json={"name": "nz_cpi", "source": "Stats NZ", "has_geometry": False,
158
+ "row_count_at_last_refresh": 500})
156
159
  resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi/data", json={"data": RECORDS})
157
160
  client.get("nz_cpi", limit=2)
158
- assert "limit=0" in resp_lib.calls[0].request.url
161
+ data_reqs = [c for c in resp_lib.calls if "/data?" in c.request.url or c.request.url.endswith("/data")]
162
+ assert any("limit=0" in c.request.url for c in data_reqs)
159
163
 
160
164
 
161
165
  @resp_lib.activate
@@ -167,6 +171,38 @@ def test_get_passes_date_params(client):
167
171
  assert "end=2023-06-30" in req.url
168
172
 
169
173
 
174
+ @resp_lib.activate
175
+ def test_get_merges_provenance_headers(client):
176
+ resp_lib.add(
177
+ resp_lib.GET,
178
+ f"{BASE}/v1/datasets/nz_cpi/data",
179
+ json={"data": RECORDS},
180
+ adding_headers={
181
+ "X-Eolas-Attribution": "Source: OECD, CC-BY 4.0.",
182
+ "X-Eolas-Licence": "CC-BY 4.0",
183
+ "X-Eolas-Source": "OECD",
184
+ },
185
+ )
186
+ df = client.get("nz_cpi", meta=False)
187
+ meta = df.attrs.get("eolas_meta", {})
188
+ assert meta.get("attribution_text") == "Source: OECD, CC-BY 4.0."
189
+ assert meta.get("licence") == "CC-BY 4.0"
190
+ assert meta.get("source") == "OECD"
191
+
192
+
193
+ @resp_lib.activate
194
+ def test_get_envelope_attaches_data_sources(client):
195
+ payload = {
196
+ "data_sources": [{"name": "nz_cpi", "licence": "CC-BY 4.0"}],
197
+ "data": RECORDS,
198
+ }
199
+ resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_cpi/data", json=payload)
200
+ df = client.get("nz_cpi", envelope=True, meta=False)
201
+ meta = df.attrs.get("eolas_meta", {})
202
+ assert meta.get("data_sources") == payload["data_sources"]
203
+ assert "envelope=1" in resp_lib.calls[0].request.url
204
+
205
+
170
206
  @resp_lib.activate
171
207
  def test_get_csv_returns_dataframe(client):
172
208
  csv_body = "date,period,value\n2023-01-01,2023Q1,100.0\n"
@@ -213,6 +249,42 @@ def test_get_as_geo_false_keeps_wkt(client):
213
249
  assert df["geometry_wkt"].iloc[0].startswith("POINT")
214
250
 
215
251
 
252
+ LARGE_GEO_META = {
253
+ "name": "nz_addresses",
254
+ "namespace": "linz",
255
+ "bulk_export_class": "materialised",
256
+ "has_geometry": True,
257
+ "geometry_type": "point",
258
+ "row_count_at_last_refresh": 2_418_264,
259
+ }
260
+
261
+
262
+ @resp_lib.activate
263
+ def test_get_whole_dataset_defers_to_get_local_for_large_geo(client):
264
+ """Whole-dataset pulls on geometry tables route to get_local(), not /data."""
265
+ sentinel = pd.DataFrame({"address_id": [1], "geometry_wkt": ["POINT (0 0)"]})
266
+ with patch.object(client, "get_local", return_value=sentinel) as mock_local:
267
+ resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_addresses",
268
+ json=LARGE_GEO_META, status=200)
269
+ result = client.get("nz_addresses")
270
+ mock_local.assert_called_once()
271
+ assert result is sentinel
272
+
273
+
274
+ @resp_lib.activate
275
+ def test_get_limit_on_large_geo_sends_bounded_limit(client):
276
+ """Positive limits on geometry tables must not send limit=0 (API 413)."""
277
+ resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_addresses",
278
+ json=LARGE_GEO_META, status=200)
279
+ resp_lib.add(resp_lib.GET, f"{BASE}/v1/datasets/nz_addresses/data",
280
+ json={"data": GEO_RECORDS})
281
+ client.get("nz_addresses", limit=2)
282
+ data_reqs = [c for c in resp_lib.calls if "/data?" in c.request.url or c.request.url.endswith("/data")]
283
+ assert data_reqs, "expected a /data request"
284
+ assert any("limit=2" in c.request.url for c in data_reqs)
285
+ assert not any("limit=0" in c.request.url for c in data_reqs)
286
+
287
+
216
288
  @resp_lib.activate
217
289
  def test_get_no_geometry_column_returns_dataset(client):
218
290
  """Datasets without a geometry_wkt column still return a Dataset even with as_geo=None."""
@@ -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