caeli-data 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caeli-data
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python client of the Caeli data repository API (data.caeli.space): load tables, publish rasters, read the catalogue
5
5
  Author-email: "Caeli B.V." <tim@caeli.nl>
6
6
  License: Proprietary. Copyright Caeli B.V. The library is free to install; the data repository it talks to needs an API key from Caeli.
@@ -42,7 +42,7 @@ A complete month delivered again is a new version of that month: `mode="replace"
42
42
  A service takes its key from Secrets Manager: `Repository.from_secret("caeli/data-platform/api-key-inventory-platform")`.
43
43
 
44
44
  The rules a table file must meet are in Caeli_data_repository_conventions (section 6); the library checks them before
45
- uploading and says which rule a file breaks (`TableRejected`). `IngestFailed` means the platform failed; the same file
45
+ uploading and says which rule a file breaks (`TableRejected`; for an extra column the message carries the schema change to make). `IngestFailed` means the platform failed; the same file
46
46
  sent again is the same job, never a double load. Python 3.9 and later.
47
47
 
48
48
  Rasters, the read side (catalogue, versions, downloads) and the command line follow in the next versions.
@@ -22,7 +22,7 @@ A complete month delivered again is a new version of that month: `mode="replace"
22
22
  A service takes its key from Secrets Manager: `Repository.from_secret("caeli/data-platform/api-key-inventory-platform")`.
23
23
 
24
24
  The rules a table file must meet are in Caeli_data_repository_conventions (section 6); the library checks them before
25
- uploading and says which rule a file breaks (`TableRejected`). `IngestFailed` means the platform failed; the same file
25
+ uploading and says which rule a file breaks (`TableRejected`; for an extra column the message carries the schema change to make). `IngestFailed` means the platform failed; the same file
26
26
  sent again is the same job, never a double load. Python 3.9 and later.
27
27
 
28
28
  Rasters, the read side (catalogue, versions, downloads) and the command line follow in the next versions.
@@ -7,5 +7,5 @@
7
7
 
8
8
  from .loader import ApiError, IngestFailed, Part, Repository, TableRejected, sha256_of # noqa: F401
9
9
 
10
- __version__ = "0.2.0"
10
+ __version__ = "0.2.2"
11
11
  __all__ = ["Repository", "Part", "ApiError", "TableRejected", "IngestFailed", "sha256_of", "__version__"]
@@ -20,6 +20,7 @@ load. Needs httpx and pyarrow (boto3 for from_secret); runs on Python 3.9 and la
20
20
  from __future__ import annotations
21
21
 
22
22
  import hashlib
23
+ import logging
23
24
  import os
24
25
  import time
25
26
  import uuid
@@ -28,9 +29,14 @@ from pathlib import Path
28
29
 
29
30
  import httpx
30
31
 
32
+ log = logging.getLogger("caeli_data") # the caller's logging setup decides where it goes
33
+
31
34
  JOB_NAMESPACE = uuid.UUID("5b9c6b4e-2c1e-4a3f-9d8a-0ae110000001") # fixed: the same file always gives the same job id
32
35
  FINAL = ("done", "rejected", "failed")
33
36
  MAX_PART_BYTES = 900 * 1024 * 1024 # the API refuses files over 1 GiB; keep a margin
37
+ STANDARD_NAMES = ("pt_region", "pt_tile", "idx_utc", "idx_lon", "idx_lat", "row_hash") # must be in every DataFrame
38
+ ARROW_NAMES = {"string": "string", "int32": "int", "int64": "bigint", "float": "float", "double": "double", "bool": "boolean",
39
+ "timestamp[us]": "timestamp", "date32[day]": "date"} # Arrow type as a string -> platform type
34
40
 
35
41
 
36
42
  class _ArrowTypes(dict):
@@ -47,7 +53,11 @@ ARROW_TYPES = _ArrowTypes()
47
53
 
48
54
 
49
55
  class ApiError(Exception):
50
- """The API refused the request (4xx) or is unavailable."""
56
+ """The API refused the request (4xx, status holds the code) or is unavailable (status None)."""
57
+
58
+ def __init__(self, message: str, status: int | None = None):
59
+ super().__init__(message)
60
+ self.status = status
51
61
 
52
62
 
53
63
  class TableRejected(Exception):
@@ -94,7 +104,7 @@ class Repository:
94
104
  detail = r.json().get("detail", r.text)
95
105
  except ValueError:
96
106
  detail = r.text
97
- raise ApiError(f"{method} {path} -> {r.status_code}: {detail}")
107
+ raise ApiError(f"{method} {path} -> {r.status_code}: {detail}", status=r.status_code)
98
108
  return r
99
109
 
100
110
  @classmethod
@@ -123,6 +133,20 @@ class Repository:
123
133
  def dataset(self, dataset: str) -> dict:
124
134
  return self._call("GET", f"/datasets/{dataset}").json()
125
135
 
136
+ def register_dataset(self, dataset: str, kind: str, title: str, cadence: str, columns: list | None = None, areas=(), **fields) -> dict:
137
+ """POST /datasets (a registrar or admin key): the dataset is created in the record and the catalogue, and a table
138
+ dataset gets its Iceberg table and schema version 1 from columns = [{"name", "type", "unit"?, "description"?}].
139
+ areas = ["N_EU", ...] (rasters: [{"area_code", "grid"}]). fields: description, access (internal), owner_email
140
+ (default: the owner of the key), steward_email, licence_note, platform_key, collection_key, product_key, extra."""
141
+ body = {"dataset_key": dataset, "kind": kind, "title": title, "cadence": cadence, "columns": columns or [],
142
+ "areas": [a if isinstance(a, dict) else {"area_code": a} for a in areas], **fields}
143
+ self._schemas.pop(dataset, None)
144
+ return self._call("POST", "/datasets", json=body).json()
145
+
146
+ def add_areas(self, dataset: str, areas) -> dict:
147
+ """POST /datasets/{key}/areas (registrar): more areas for a dataset, ["N_AM", ...] or [{"area_code", "grid"}]."""
148
+ return self._call("POST", f"/datasets/{dataset}/areas", json=[a if isinstance(a, dict) else {"area_code": a} for a in areas]).json()
149
+
126
150
  def schema(self, dataset: str, refresh: bool = False) -> dict:
127
151
  """The current schema version of a table dataset: {"version": n, "columns": {name: type}}. Cached for the life
128
152
  of the Repository; refresh=True fetches it again. The file and DataFrame checks refresh it by themselves once
@@ -279,8 +303,10 @@ class Repository:
279
303
  def table_from_dataframe(self, df, dataset: str, drop_columns: tuple[str, ...] = ("idx_month",)):
280
304
  """A pandas DataFrame as an Arrow table in exactly the dataset's current schema: columns in order, types cast
281
305
  (int64 to int for int columns, float64 to float for float columns, timestamps to UTC without time zone in
282
- microseconds), drop_columns left out. Raises TableRejected naming missing or extra columns; an extra column
283
- means a schema change first (POST /datasets/<key>/schema, an admin)."""
306
+ microseconds), drop_columns left out. A value column the DataFrame lacks (an older product version) is filled
307
+ with nulls and logged; the six standard columns must be there. Raises TableRejected for a missing standard
308
+ column, for a value that cannot be cast (text in a number column) and for an extra column, which means a
309
+ schema change first (an admin, change_schema); the message then carries the API's proposal for that change."""
284
310
  import pyarrow as pa
285
311
  import pyarrow.compute as pc
286
312
  present = [c for c in df.columns if c not in drop_columns]
@@ -290,23 +316,57 @@ class Repository:
290
316
  if (missing or extra) and cached: # the schema may have changed since it was cached: look once more
291
317
  schema = self.arrow_schema(dataset, refresh=True)
292
318
  missing, extra = _column_mismatch(schema, present)
293
- expected = [f.name for f in schema]
294
- if missing or extra:
295
- raise TableRejected(f"{dataset}: " + "; ".join(([f"missing column(s) {', '.join(missing)}"] if missing else [])
296
- + ([f"extra column(s) {', '.join(extra)}: a schema change first"] if extra else [])))
297
- out = df[expected].copy()
319
+ missing_standard = [n for n in missing if n in STANDARD_NAMES]
320
+ if missing_standard:
321
+ raise TableRejected(f"{dataset}: missing standard column(s) {', '.join(missing_standard)}")
322
+ if extra:
323
+ raise TableRejected(f"{dataset}: extra column(s) {', '.join(extra)}: a schema change first; " + self._proposal(dataset, df, present, extra))
324
+ if missing:
325
+ log.warning("%s: value column(s) %s not in the DataFrame, filled with nulls", dataset, ", ".join(missing))
326
+ out = df[[n for n in (f.name for f in schema) if n not in missing]].copy()
298
327
  for f in schema:
299
- if pa.types.is_timestamp(f.type) and getattr(getattr(out[f.name], "dt", None), "tz", None) is not None:
328
+ if f.name in out and pa.types.is_timestamp(f.type) and getattr(getattr(out[f.name], "dt", None), "tz", None) is not None:
300
329
  out[f.name] = out[f.name].dt.tz_convert("UTC").dt.tz_localize(None)
301
- table = pa.Table.from_pandas(out, preserve_index=False)
330
+ try:
331
+ table = pa.Table.from_pandas(out, preserve_index=False)
332
+ except (pa.ArrowInvalid, pa.ArrowTypeError) as exc:
333
+ raise TableRejected(f"{dataset}: a column holds values Arrow cannot read ({exc})") from exc
302
334
  arrays = []
303
335
  for f in schema:
336
+ if f.name in missing:
337
+ arrays.append(pa.nulls(table.num_rows, f.type))
338
+ continue
304
339
  col = table[f.name]
305
340
  if col.type != f.type:
306
- col = pc.cast(col, f.type, safe=not (pa.types.is_floating(f.type) or pa.types.is_timestamp(f.type)))
341
+ try:
342
+ col = pc.cast(col, f.type, safe=not (pa.types.is_floating(f.type) or pa.types.is_timestamp(f.type)))
343
+ except (pa.ArrowInvalid, pa.ArrowNotImplementedError, pa.ArrowTypeError) as exc:
344
+ raise TableRejected(f"{dataset}: {f.name} is {col.type} in the DataFrame and cannot become {ARROW_NAMES.get(str(f.type), f.type)} "
345
+ f"as registered ({exc})") from exc
307
346
  arrays.append(col)
308
347
  return pa.Table.from_arrays(arrays, schema=schema)
309
348
 
349
+ def _proposal(self, dataset: str, df, present: list, extra: list) -> str:
350
+ """The schema change an admin would make for the extra columns, as the API proposes it (types read from the
351
+ DataFrame); built locally when the API cannot be asked."""
352
+ import pyarrow as pa
353
+ try:
354
+ from . import checks
355
+ except ImportError:
356
+ import checks
357
+ types = {}
358
+ for name in present:
359
+ try:
360
+ t = checks.arrow_type_name(pa.Table.from_pandas(df[[name]], preserve_index=False).schema[0].type)
361
+ except Exception: # noqa: BLE001 (mixed values; the admin decides)
362
+ t = "string"
363
+ types[name] = "timestamp" if t.startswith("timestamp") else (t if t in ARROW_NAMES.values() else "string")
364
+ try:
365
+ proposal = (self.check_schema(dataset, types).get("proposal") or {}).get("add") or []
366
+ except ApiError:
367
+ proposal = [{"name": n, "type": types[n]} for n in extra]
368
+ return f"proposal for an admin: change_schema({dataset!r}, note='why', add={proposal})"
369
+
310
370
  def write_table_file(self, df, dataset: str, path: str | Path, drop_columns: tuple[str, ...] = ("idx_month",),
311
371
  row_group_size: int = 100_000) -> Path:
312
372
  """Writes a DataFrame as a Parquet file that meets the dataset's schema (zstd); the file to hand to load_file."""
@@ -341,7 +401,9 @@ class Repository:
341
401
  widen: list[dict] | None = None) -> dict:
342
402
  """The next schema version (admin): add = [{"name", "type", "unit"?, "description"?}], drop = [names],
343
403
  widen = [{"name", "type"}] (int to bigint, float to double). Deliberate and recorded, never automatic."""
344
- return self._call("POST", f"/datasets/{dataset}/schema", json={"add": add or [], "drop": drop or [], "widen": widen or [], "note": note}).json()
404
+ out = self._call("POST", f"/datasets/{dataset}/schema", json={"add": add or [], "drop": drop or [], "widen": widen or [], "note": note}).json()
405
+ self._schemas.pop(dataset, None) # the next load reads the new version
406
+ return out
345
407
 
346
408
 
347
409
  def _version() -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caeli-data
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python client of the Caeli data repository API (data.caeli.space): load tables, publish rasters, read the catalogue
5
5
  Author-email: "Caeli B.V." <tim@caeli.nl>
6
6
  License: Proprietary. Copyright Caeli B.V. The library is free to install; the data repository it talks to needs an API key from Caeli.
@@ -42,7 +42,7 @@ A complete month delivered again is a new version of that month: `mode="replace"
42
42
  A service takes its key from Secrets Manager: `Repository.from_secret("caeli/data-platform/api-key-inventory-platform")`.
43
43
 
44
44
  The rules a table file must meet are in Caeli_data_repository_conventions (section 6); the library checks them before
45
- uploading and says which rule a file breaks (`TableRejected`). `IngestFailed` means the platform failed; the same file
45
+ uploading and says which rule a file breaks (`TableRejected`; for an extra column the message carries the schema change to make). `IngestFailed` means the platform failed; the same file
46
46
  sent again is the same job, never a double load. Python 3.9 and later.
47
47
 
48
48
  Rasters, the read side (catalogue, versions, downloads) and the command line follow in the next versions.
@@ -27,10 +27,22 @@ class FakeApi:
27
27
  p, m = request.url.path, request.method
28
28
  if p == "/whoami":
29
29
  return httpx.Response(200, json={"name": "ingest", "role": "publisher"})
30
- if p == "/datasets/zz-test-load/schema":
30
+ if p == "/datasets/zz-test-load/schema/check" and m == "POST": # the API's proposal for extra columns
31
+ given = {c["name"]: c["type"] for c in json.loads(request.content)["columns"]}
32
+ expected = dict(STANDARD + [(c["name"], c["type"]) for c in self.columns])
33
+ extra = sorted(n for n in given if n not in expected)
34
+ return httpx.Response(200, json={"ok": not extra, "extra": extra, "proposal": {"add": [{"name": n, "type": given[n]} for n in extra]} if extra else None})
35
+ if p == "/datasets/zz-test-load/schema" and m == "GET":
31
36
  self.schema_calls += 1
32
37
  return httpx.Response(200, json={"current": self.version, "standard_columns": [{"name": n, "type": t} for n, t in STANDARD],
33
38
  "versions": [{"schema_version": 1, "columns": []}, {"schema_version": self.version, "columns": self.columns}]})
39
+ if p == "/datasets" and m == "POST":
40
+ self.registered = json.loads(request.content)
41
+ return httpx.Response(201, json={"dataset_key": self.registered["dataset_key"], "kind": self.registered["kind"]})
42
+ if p == "/datasets/zz-test-load/schema" and m == "POST":
43
+ self.version += 1
44
+ self.columns = self.columns + json.loads(request.content)["add"]
45
+ return httpx.Response(201, json={"dataset_key": "zz-test-load", "schema_version": self.version})
34
46
  if p == "/loads" and m == "POST":
35
47
  man = json.loads(request.content)
36
48
  if man["dataset"] == "nope":
@@ -121,9 +133,33 @@ def test_dataframe_follows_a_schema_change_and_still_refuses_a_real_extra_column
121
133
  table = repo.table_from_dataframe(df, "zz-test-load")
122
134
  assert table.column_names == [n for n, _ in STANDARD] + ["no2", "o3"] and api.schema_calls == 2
123
135
  assert table["pt_tile"].type == pa.int32() and table["idx_utc"].type == pa.timestamp("us") and table["idx_utc"][0].as_py().hour == 12
124
- with pytest.raises(TableRejected, match="extra column\\(s\\) o4"):
125
- repo.table_from_dataframe(df.assign(o4=[1.0]), "zz-test-load")
136
+ with pytest.raises(TableRejected, match="extra column\\(s\\) o4") as e:
137
+ repo.table_from_dataframe(df.assign(o4=[1.0], o5=["x"]), "zz-test-load")
126
138
  assert api.schema_calls == 3 # looked once more, the column is really not there
139
+ assert "change_schema('zz-test-load', note='why', add=[{'name': 'o4', 'type': 'double'}, {'name': 'o5', 'type': 'string'}])" in str(e.value)
140
+
141
+
142
+ def test_dataframe_missing_value_column_is_filled_with_nulls_missing_standard_column_refused(repo, api, caplog):
143
+ pa = pytest.importorskip("pyarrow")
144
+ pd = pytest.importorskip("pandas")
145
+ api.version, api.columns = 3, [{"name": "no2", "type": "double"}, {"name": "o3", "type": "double"}]
146
+ df = pd.DataFrame({"pt_region": ["N_EU"], "pt_tile": [2053], "idx_utc": [pd.Timestamp("2026-09-15 12:00")], "idx_lon": [5.0], "idx_lat": [52.0],
147
+ "row_hash": [1], "no2": [1.5]}) # an older product version: no o3
148
+ with caplog.at_level("INFO", logger="caeli_data"):
149
+ table = repo.table_from_dataframe(df, "zz-test-load")
150
+ assert table.column_names[-2:] == ["no2", "o3"] and table["o3"].null_count == 1 and table["o3"].type == pa.float64()
151
+ assert "o3 not in the DataFrame, filled with nulls" in caplog.text
152
+ with pytest.raises(TableRejected, match="missing standard column\\(s\\) row_hash"):
153
+ repo.table_from_dataframe(df.drop(columns=["row_hash"]), "zz-test-load")
154
+
155
+
156
+ def test_dataframe_value_that_cannot_be_cast_is_refused_with_the_column_name(repo, api):
157
+ pytest.importorskip("pyarrow")
158
+ pd = pytest.importorskip("pandas")
159
+ df = pd.DataFrame({"pt_region": ["N_EU"], "pt_tile": [2053], "idx_utc": [pd.Timestamp("2026-09-15 12:00")], "idx_lon": [5.0], "idx_lat": [52.0],
160
+ "row_hash": [1], "no2": ["twelve"]})
161
+ with pytest.raises(TableRejected, match="no2 is (large_)?string in the DataFrame and cannot become double"):
162
+ repo.table_from_dataframe(df, "zz-test-load")
127
163
 
128
164
 
129
165
  def test_load_table_announces_uploads_and_waits(repo, api, parquet):
@@ -152,9 +188,27 @@ def test_failed_raises_ingest_failed(repo, api, parquet):
152
188
  repo.load_table(parquet, "zz-test-load", "N_EU", "2026-09", check=False, row_count=10, timeout=5, poll=0.01)
153
189
 
154
190
 
155
- def test_api_refusal_raises_api_error_with_detail(repo, parquet):
156
- with pytest.raises(ApiError, match="unknown dataset nope"):
191
+ def test_api_refusal_raises_api_error_with_detail_and_status(repo, parquet):
192
+ with pytest.raises(ApiError, match="unknown dataset nope") as e:
157
193
  repo.load_table(parquet, "nope", "N_EU", "2026-09", check=False, row_count=10, schema_version=1)
194
+ assert e.value.status == 422
195
+ with pytest.raises(ApiError) as e:
196
+ repo.dataset("nope")
197
+ assert e.value.status == 404
198
+
199
+
200
+ def test_register_dataset_posts_the_registration_and_change_schema_forgets_the_cache(repo, api):
201
+ out = repo.register_dataset("zz-test-new", "table", "A test", "orbital", columns=[{"name": "no2", "type": "double", "unit": "mol m-2"}],
202
+ areas=["N_EU", {"area_code": "N_AM"}], description="d", platform_key="zz", collection_key="test", product_key="new",
203
+ licence_note="none")
204
+ assert out["dataset_key"] == "zz-test-new"
205
+ assert api.registered == {"dataset_key": "zz-test-new", "kind": "table", "title": "A test", "cadence": "orbital",
206
+ "columns": [{"name": "no2", "type": "double", "unit": "mol m-2"}], "areas": [{"area_code": "N_EU"}, {"area_code": "N_AM"}],
207
+ "description": "d", "platform_key": "zz", "collection_key": "test", "product_key": "new", "licence_note": "none"}
208
+ assert "owner_email" not in api.registered # the key's owner, unless named
209
+ assert repo.schema("zz-test-load")["version"] == 2
210
+ repo.change_schema("zz-test-load", "o3 added", add=[{"name": "o3", "type": "double"}])
211
+ assert repo.schema("zz-test-load")["version"] == 3 and "o3" in repo.schema("zz-test-load")["columns"]
158
212
 
159
213
 
160
214
  def test_wait_false_returns_the_queued_job_with_its_link(repo, api, parquet):
File without changes
File without changes