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.
- {caeli_data-0.2.0 → caeli_data-0.2.2}/PKG-INFO +2 -2
- {caeli_data-0.2.0 → caeli_data-0.2.2}/README.md +1 -1
- {caeli_data-0.2.0 → caeli_data-0.2.2}/caeli_data/__init__.py +1 -1
- {caeli_data-0.2.0 → caeli_data-0.2.2}/caeli_data/loader.py +75 -13
- {caeli_data-0.2.0 → caeli_data-0.2.2}/caeli_data.egg-info/PKG-INFO +2 -2
- {caeli_data-0.2.0 → caeli_data-0.2.2}/tests/test_loader.py +59 -5
- {caeli_data-0.2.0 → caeli_data-0.2.2}/caeli_data/checks.py +0 -0
- {caeli_data-0.2.0 → caeli_data-0.2.2}/caeli_data.egg-info/SOURCES.txt +0 -0
- {caeli_data-0.2.0 → caeli_data-0.2.2}/caeli_data.egg-info/dependency_links.txt +0 -0
- {caeli_data-0.2.0 → caeli_data-0.2.2}/caeli_data.egg-info/requires.txt +0 -0
- {caeli_data-0.2.0 → caeli_data-0.2.2}/caeli_data.egg-info/top_level.txt +0 -0
- {caeli_data-0.2.0 → caeli_data-0.2.2}/pyproject.toml +0 -0
- {caeli_data-0.2.0 → caeli_data-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: caeli-data
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
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
|
|
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.
|
|
@@ -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.
|
|
283
|
-
|
|
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
|
-
|
|
294
|
-
if
|
|
295
|
-
raise TableRejected(f"{dataset}:
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|