envlib 0.1.0__py3-none-any.whl
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.
- envlib/__init__.py +9 -0
- envlib/catalogue.py +970 -0
- envlib/metadata.py +572 -0
- envlib/vocabularies/__init__.py +336 -0
- envlib/vocabularies/aggregation_statistic.json +20 -0
- envlib/vocabularies/feature.json +19 -0
- envlib/vocabularies/frequency_interval.json +21 -0
- envlib/vocabularies/license.json +63 -0
- envlib/vocabularies/method.json +37 -0
- envlib/vocabularies/processing_level.json +21 -0
- envlib/vocabularies/standard_name.json +5683 -0
- envlib/vocabularies/variable.json +4391 -0
- envlib-0.1.0.dist-info/METADATA +75 -0
- envlib-0.1.0.dist-info/RECORD +16 -0
- envlib-0.1.0.dist-info/WHEEL +4 -0
- envlib-0.1.0.dist-info/licenses/LICENSE +16 -0
envlib/catalogue.py
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
"""envlib Catalogue: RCG-backed dataset discovery, validation, publish/register/deregister.
|
|
2
|
+
|
|
3
|
+
The Catalogue wraps one or more ebooklet RemoteConnGroups (RCGs). Each RCG entry
|
|
4
|
+
is keyed by ``dataset_version_id`` and carries envlib's Identity/General/State/Provenance
|
|
5
|
+
metadata in ``user_meta`` (plain, unprefixed keys) plus the credential-free
|
|
6
|
+
member connection details. The cfdb file's ``ds.attrs`` (``envlib_``-prefixed)
|
|
7
|
+
is the authoritative metadata store; RCG entries are a derived index.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import pathlib
|
|
16
|
+
import re
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import warnings
|
|
19
|
+
from hashlib import blake2b
|
|
20
|
+
|
|
21
|
+
import booklet
|
|
22
|
+
import cfdb
|
|
23
|
+
import ebooklet
|
|
24
|
+
import pyproj
|
|
25
|
+
import shapely
|
|
26
|
+
import urllib3
|
|
27
|
+
|
|
28
|
+
from envlib import vocabularies
|
|
29
|
+
from envlib.metadata import (
|
|
30
|
+
GENERAL_FIELDS,
|
|
31
|
+
IDENTITY_FIELDS,
|
|
32
|
+
Metadata,
|
|
33
|
+
ValidationError,
|
|
34
|
+
compute_station_id,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# The hardcoded public RCG URL. None until the public commons is hosted —
|
|
38
|
+
# overridable (and currently only usable) via the env var below.
|
|
39
|
+
PUBLIC_RCG_URL: str | None = None
|
|
40
|
+
PUBLIC_RCG_ENV_VAR = 'ENVLIB_PUBLIC_RCG_URL'
|
|
41
|
+
DEFAULT_CACHE_DIR = '~/.envlib/cache'
|
|
42
|
+
|
|
43
|
+
STATION_ID_VAR = 'station_id'
|
|
44
|
+
|
|
45
|
+
_HEX24_RE = re.compile(r'[0-9a-f]{24}')
|
|
46
|
+
_EPSG_4326 = pyproj.CRS.from_epsg(4326)
|
|
47
|
+
_FULL_CIRCLE_DEG = 360.0
|
|
48
|
+
|
|
49
|
+
_QUERYABLE_FIELDS = frozenset(IDENTITY_FIELDS) | {'license', 'dataset_type', 'dataset_version_id', 'dataset_id'}
|
|
50
|
+
|
|
51
|
+
# Errors that indicate the remote endpoint is unreachable (offline), as opposed
|
|
52
|
+
# to a well-formed remote answer like 404: urllib3 raises MaxRetryError and
|
|
53
|
+
# friends (all HTTPError subclasses) on DNS/connect/timeout failures.
|
|
54
|
+
_OFFLINE_ERRORS = (urllib3.exceptions.HTTPError, ConnectionError, TimeoutError)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _public_rcg_url() -> str | None:
|
|
58
|
+
return os.environ.get(PUBLIC_RCG_ENV_VAR) or PUBLIC_RCG_URL
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _utc_now_iso() -> str:
|
|
62
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _dt64_to_iso(value) -> str:
|
|
66
|
+
"""numpy datetime64 (UTC by convention) -> ISO8601 Z string.
|
|
67
|
+
|
|
68
|
+
Whole-second when there is no sub-second component, otherwise a fractional
|
|
69
|
+
suffix with trailing zeros stripped. Sub-microsecond precision truncates.
|
|
70
|
+
"""
|
|
71
|
+
micros = int(value.astype('datetime64[us]').astype('int64'))
|
|
72
|
+
dt = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(microseconds=micros)
|
|
73
|
+
if dt.microsecond == 0:
|
|
74
|
+
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
75
|
+
frac = f'{dt.microsecond:06d}'.rstrip('0')
|
|
76
|
+
return dt.strftime('%Y-%m-%dT%H:%M:%S') + f'.{frac}Z'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_iso(value) -> datetime.datetime:
|
|
80
|
+
"""Parse an ISO8601 string (Z-suffixed or naive-as-UTC) or pass a datetime through."""
|
|
81
|
+
if isinstance(value, datetime.datetime):
|
|
82
|
+
dt = value
|
|
83
|
+
else:
|
|
84
|
+
dt = datetime.datetime.fromisoformat(str(value).replace('Z', '+00:00'))
|
|
85
|
+
if dt.tzinfo is None:
|
|
86
|
+
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
87
|
+
return dt
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _as_connection(remote_conn) -> ebooklet.S3Connection:
|
|
91
|
+
if isinstance(remote_conn, ebooklet.S3Connection):
|
|
92
|
+
return remote_conn
|
|
93
|
+
if isinstance(remote_conn, dict):
|
|
94
|
+
return ebooklet.S3Connection(**remote_conn)
|
|
95
|
+
if isinstance(remote_conn, str):
|
|
96
|
+
return ebooklet.S3Connection(db_url=remote_conn)
|
|
97
|
+
msg = f'remote_conn must be an ebooklet.S3Connection, dict, or URL str, not {type(remote_conn).__name__}.'
|
|
98
|
+
raise TypeError(msg)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _conn_cache_key(remote_conn) -> str:
|
|
102
|
+
if isinstance(remote_conn, str):
|
|
103
|
+
key = remote_conn
|
|
104
|
+
else:
|
|
105
|
+
conn = _as_connection(remote_conn)
|
|
106
|
+
key = f'{conn.endpoint_url}|{conn.bucket}|{conn.db_key}|{conn.db_url}'
|
|
107
|
+
return blake2b(key.encode('utf-8'), digest_size=8).hexdigest()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validate_data_url(db_url) -> str | None:
|
|
111
|
+
"""Validate a member db_url as a plain public http(s) URL for the catalogue entry.
|
|
112
|
+
|
|
113
|
+
No userinfo (user:pass@) and no query string — a presigned URL's signature
|
|
114
|
+
must never ride into the public catalogue. Returns None when db_url is None.
|
|
115
|
+
"""
|
|
116
|
+
if db_url is None:
|
|
117
|
+
return None
|
|
118
|
+
parsed = urllib.parse.urlsplit(db_url)
|
|
119
|
+
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
|
|
120
|
+
msg = f'data_url must be a plain http(s) URL; got {db_url!r}.'
|
|
121
|
+
raise ValidationError(msg)
|
|
122
|
+
if parsed.username is not None or parsed.password is not None:
|
|
123
|
+
msg = f'data_url must not carry userinfo (user:pass@); got {db_url!r}.'
|
|
124
|
+
raise ValidationError(msg)
|
|
125
|
+
if parsed.query:
|
|
126
|
+
msg = f'data_url must not carry a query string (presigned URLs are not public URLs); got {db_url!r}.'
|
|
127
|
+
raise ValidationError(msg)
|
|
128
|
+
return db_url
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
###################################################
|
|
132
|
+
# bbox helpers (EPSG:4326, GeoJSON antimeridian convention: min_lon > max_lon
|
|
133
|
+
# means the box crosses 180°)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _wrap_lon(lon: float) -> float:
|
|
137
|
+
half_circle = _FULL_CIRCLE_DEG / 2
|
|
138
|
+
wrapped = ((lon + half_circle) % _FULL_CIRCLE_DEG) - half_circle
|
|
139
|
+
# keep the east edge at +180 rather than wrapping it to -180
|
|
140
|
+
if wrapped == -half_circle and lon > 0:
|
|
141
|
+
return half_circle
|
|
142
|
+
return wrapped
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _wrap_lon_extent(west: float, east: float, x_step: float | None) -> tuple:
|
|
146
|
+
"""Wrap a native-geographic lon extent into [-180, 180] preserving crossing structure.
|
|
147
|
+
|
|
148
|
+
A full-circle source (e.g. ERA5 cell centers 0..359.75 with 0.25 step)
|
|
149
|
+
stores [-180, 180]; a regional 170..190 grid stores (170, -170) — the
|
|
150
|
+
crossing convention — NOT the naive min/max of the wrapped corners.
|
|
151
|
+
"""
|
|
152
|
+
span = east - west + (x_step or 0.0)
|
|
153
|
+
if span >= _FULL_CIRCLE_DEG - 1e-9:
|
|
154
|
+
return -180.0, 180.0
|
|
155
|
+
w = _wrap_lon(west)
|
|
156
|
+
e = _wrap_lon(east)
|
|
157
|
+
return w, e
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _split_bbox(bbox) -> list:
|
|
161
|
+
"""Split an antimeridian-crossing bbox into 1-2 conventional boxes."""
|
|
162
|
+
min_lon, min_lat, max_lon, max_lat = bbox
|
|
163
|
+
if min_lon > max_lon:
|
|
164
|
+
return [(min_lon, min_lat, 180.0, max_lat), (-180.0, min_lat, max_lon, max_lat)]
|
|
165
|
+
return [(min_lon, min_lat, max_lon, max_lat)]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _boxes_intersect(a, b) -> bool:
|
|
169
|
+
return a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _bbox_intersects(stored, query) -> bool:
|
|
173
|
+
return any(_boxes_intersect(s, q) for s in _split_bbox(stored) for q in _split_bbox(query))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _geometry_intersects(stored, geometry) -> bool:
|
|
177
|
+
return any(shapely.box(*part).intersects(geometry) for part in _split_bbox(stored))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_GEOD = pyproj.Geod(ellps='WGS84')
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _bbox_within_radius(stored, lon: float, lat: float, radius_km: float) -> bool:
|
|
184
|
+
"""Great-circle distance from a point to the nearest edge/corner of the bbox.
|
|
185
|
+
|
|
186
|
+
Longitude candidates at ±360 handle proximity across the antimeridian
|
|
187
|
+
(clamping in linear lon space alone would pick the wrong side).
|
|
188
|
+
"""
|
|
189
|
+
for min_lon, min_lat, max_lon, max_lat in _split_bbox(stored):
|
|
190
|
+
near_lat = min(max(lat, min_lat), max_lat)
|
|
191
|
+
for cand in (lon, lon - 360.0, lon + 360.0):
|
|
192
|
+
near_lon = min(max(cand, min_lon), max_lon)
|
|
193
|
+
_, _, dist_m = _GEOD.inv(cand, lat, near_lon, near_lat)
|
|
194
|
+
if dist_m <= radius_km * 1000.0:
|
|
195
|
+
return True
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
###################################################
|
|
200
|
+
# Dataset validation + State Metadata extraction
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _sys_dataset_type(ds) -> str:
|
|
204
|
+
# cfdb 0.9.0 has no public dataset_type property (and open_edataset always
|
|
205
|
+
# returns the EGrid class), so the sys-metadata slot is the reliable source.
|
|
206
|
+
return ds._sys_meta.dataset_type.value
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _coord_by_axis(ds, axis: str):
|
|
210
|
+
for coord in ds.coords:
|
|
211
|
+
ax = coord.axis
|
|
212
|
+
if ax is not None and ax.value == axis:
|
|
213
|
+
return coord
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _reproject_points(points, crs) -> list:
|
|
218
|
+
if crs.equals(_EPSG_4326):
|
|
219
|
+
return list(points)
|
|
220
|
+
transformer = pyproj.Transformer.from_crs(crs, _EPSG_4326, always_xy=True)
|
|
221
|
+
out = []
|
|
222
|
+
for p in points:
|
|
223
|
+
x, y = transformer.transform(p.x, p.y)
|
|
224
|
+
out.append(shapely.Point(x, y))
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _extract_state(ds, meta: Metadata) -> dict:
|
|
229
|
+
"""Extract State Metadata (bbox, time range, steps, dataset_type) from an open dataset."""
|
|
230
|
+
dataset_type = _sys_dataset_type(ds)
|
|
231
|
+
crs = ds.crs
|
|
232
|
+
|
|
233
|
+
time_coord = ds.get('time') if 'time' in ds.coord_names else None
|
|
234
|
+
if time_coord is None or len(time_coord.data) == 0:
|
|
235
|
+
msg = 'every envlib dataset must have a time coordinate with at least one value.'
|
|
236
|
+
raise ValidationError(msg)
|
|
237
|
+
if time_coord.dtype.kind != 'M':
|
|
238
|
+
msg = f'the time coordinate must be datetime64, not {time_coord.dtype.name!r}.'
|
|
239
|
+
raise ValidationError(msg)
|
|
240
|
+
time_values = time_coord.data
|
|
241
|
+
state: dict = {
|
|
242
|
+
'dataset_type': dataset_type,
|
|
243
|
+
'time_start': _dt64_to_iso(time_values[0]),
|
|
244
|
+
'time_end': _dt64_to_iso(time_values[-1]),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
x_step = y_step = None
|
|
248
|
+
if dataset_type == 'grid':
|
|
249
|
+
x_coord = _coord_by_axis(ds, 'x')
|
|
250
|
+
y_coord = _coord_by_axis(ds, 'y')
|
|
251
|
+
if x_coord is None or y_coord is None:
|
|
252
|
+
msg = 'grid datasets must have x and y axis coordinates (set via create.crs.from_user_input).'
|
|
253
|
+
raise ValidationError(msg)
|
|
254
|
+
x_data = x_coord.data
|
|
255
|
+
y_data = y_coord.data
|
|
256
|
+
if len(x_data) == 0 or len(y_data) == 0:
|
|
257
|
+
msg = 'grid x/y coordinates must be non-empty.'
|
|
258
|
+
raise ValidationError(msg)
|
|
259
|
+
west, east = float(x_data.min()), float(x_data.max())
|
|
260
|
+
south, north = float(y_data.min()), float(y_data.max())
|
|
261
|
+
if x_coord.step is not None:
|
|
262
|
+
x_step = float(x_coord.step)
|
|
263
|
+
if y_coord.step is not None:
|
|
264
|
+
y_step = float(y_coord.step)
|
|
265
|
+
else:
|
|
266
|
+
geom_coord = _geometry_coord(ds)
|
|
267
|
+
points = geom_coord.data
|
|
268
|
+
if len(points) == 0:
|
|
269
|
+
msg = 'ts_ortho datasets must have at least one station point.'
|
|
270
|
+
raise ValidationError(msg)
|
|
271
|
+
xs = [p.x for p in points]
|
|
272
|
+
ys = [p.y for p in points]
|
|
273
|
+
west, east = float(min(xs)), float(max(xs))
|
|
274
|
+
south, north = float(min(ys)), float(max(ys))
|
|
275
|
+
|
|
276
|
+
if crs.is_geographic:
|
|
277
|
+
# native-geographic source: transform_bounds is an identity no-op for
|
|
278
|
+
# longitude conventions, so apply envlib's own structure-preserving
|
|
279
|
+
# wrap (0-360 grids -> [-180, 180] / the crossing convention).
|
|
280
|
+
if not crs.equals(_EPSG_4326):
|
|
281
|
+
transformer = pyproj.Transformer.from_crs(crs, _EPSG_4326, always_xy=True)
|
|
282
|
+
west, south, east, north = transformer.transform_bounds(west, south, east, north, densify_pts=21)
|
|
283
|
+
min_lon, max_lon = _wrap_lon_extent(west, east, x_step)
|
|
284
|
+
min_lat, max_lat = south, north
|
|
285
|
+
else:
|
|
286
|
+
transformer = pyproj.Transformer.from_crs(crs, _EPSG_4326, always_xy=True)
|
|
287
|
+
min_lon, min_lat, max_lon, max_lat = transformer.transform_bounds(west, south, east, north, densify_pts=21)
|
|
288
|
+
|
|
289
|
+
state['bbox'] = [min_lon, min_lat, max_lon, max_lat]
|
|
290
|
+
if x_step is not None:
|
|
291
|
+
state['x_step'] = x_step
|
|
292
|
+
if y_step is not None:
|
|
293
|
+
state['y_step'] = y_step
|
|
294
|
+
|
|
295
|
+
# identity cross-checks that involve state
|
|
296
|
+
if dataset_type == 'ts_ortho' and meta.spatial_resolution != 'point':
|
|
297
|
+
msg = "ts_ortho datasets must use spatial_resolution='point'."
|
|
298
|
+
raise ValidationError(msg)
|
|
299
|
+
return state
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _geometry_coord(ds):
|
|
303
|
+
coord = _coord_by_axis(ds, 'xy')
|
|
304
|
+
if coord is None:
|
|
305
|
+
msg = (
|
|
306
|
+
"ts_ortho datasets must have a point geometry coordinate with the 'xy' axis "
|
|
307
|
+
'(create.coord.point + create.crs.from_user_input(xy_coord=...)).'
|
|
308
|
+
)
|
|
309
|
+
raise ValidationError(msg)
|
|
310
|
+
if coord.dtype.kind != 'G':
|
|
311
|
+
msg = f'the xy coordinate {coord.name!r} is not a geometry coordinate.'
|
|
312
|
+
raise ValidationError(msg)
|
|
313
|
+
return coord
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _check_stations(ds):
|
|
317
|
+
"""ts_ortho: station_id attribute variable must exist and match recomputation."""
|
|
318
|
+
geom_coord = _geometry_coord(ds)
|
|
319
|
+
if STATION_ID_VAR not in ds.data_var_names:
|
|
320
|
+
msg = f'ts_ortho datasets must carry a {STATION_ID_VAR!r} station attribute variable (shape (geometry,)).'
|
|
321
|
+
raise ValidationError(msg)
|
|
322
|
+
sid_var = ds[STATION_ID_VAR]
|
|
323
|
+
if tuple(sid_var.coord_names) != (geom_coord.name,):
|
|
324
|
+
msg = (
|
|
325
|
+
f'{STATION_ID_VAR!r} must be aligned to the geometry coordinate '
|
|
326
|
+
f'({geom_coord.name!r},); got {tuple(sid_var.coord_names)}.'
|
|
327
|
+
)
|
|
328
|
+
raise ValidationError(msg)
|
|
329
|
+
points = _reproject_points(geom_coord.data, ds.crs)
|
|
330
|
+
expected = [compute_station_id(p) for p in points]
|
|
331
|
+
stored = [str(v) for v in sid_var.data]
|
|
332
|
+
if stored != expected:
|
|
333
|
+
first_bad = next(i for i, (s, e) in enumerate(zip(stored, expected, strict=True)) if s != e)
|
|
334
|
+
msg = (
|
|
335
|
+
f'{STATION_ID_VAR!r} values do not match the envlib derivation (first mismatch at index '
|
|
336
|
+
f'{first_bad}: stored {stored[first_bad]!r}, derived {expected[first_bad]!r}). '
|
|
337
|
+
f'Use envlib.compute_station_id on the EPSG:4326 station points.'
|
|
338
|
+
)
|
|
339
|
+
raise ValidationError(msg)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _decide_standard_name(ds, meta: Metadata) -> dict:
|
|
343
|
+
"""Decide the standard_name action for the primary variable (never writes).
|
|
344
|
+
|
|
345
|
+
Returns {'action': 'keep'|'populate'|'absent'|'uncurated', 'value': str|None}.
|
|
346
|
+
"""
|
|
347
|
+
dv = ds[meta.variable]
|
|
348
|
+
current = dv.attrs.get('standard_name')
|
|
349
|
+
if current is not None:
|
|
350
|
+
if not vocabularies.is_valid('standard_name', current):
|
|
351
|
+
msg = f'standard_name {current!r} on {meta.variable!r} is not a valid CF standard name.'
|
|
352
|
+
raise ValidationError(msg)
|
|
353
|
+
return {'action': 'keep', 'value': current}
|
|
354
|
+
try:
|
|
355
|
+
candidates = vocabularies.get_cf_standard_names(meta.variable, meta.feature)
|
|
356
|
+
except ValueError:
|
|
357
|
+
candidates = None # variable/feature not in the current vocabulary (drift) -> treat as uncurated
|
|
358
|
+
if candidates is None:
|
|
359
|
+
warnings.warn(
|
|
360
|
+
f'({meta.variable!r}, {meta.feature!r}) has no curated CF standard_name mapping yet; '
|
|
361
|
+
f'standard_name left unset. Set it explicitly if a CF name applies.',
|
|
362
|
+
stacklevel=3,
|
|
363
|
+
)
|
|
364
|
+
return {'action': 'uncurated', 'value': None}
|
|
365
|
+
if not candidates:
|
|
366
|
+
return {'action': 'absent', 'value': None} # curated: no applicable standard name
|
|
367
|
+
return {'action': 'populate', 'value': candidates[0]}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _validate_dataset(ds, *, validate_cv: bool) -> dict:
|
|
371
|
+
"""Validate an open cfdb dataset against envlib requirements; extract everything.
|
|
372
|
+
|
|
373
|
+
Returns {'metadata', 'dataset_version_id', 'dataset_id', 'state', 'standard_name'}.
|
|
374
|
+
Raises ValidationError on any failure. Never modifies the dataset.
|
|
375
|
+
"""
|
|
376
|
+
meta = Metadata.from_attrs(ds.attrs, validate_cv=validate_cv)
|
|
377
|
+
missing = meta.missing_fields()
|
|
378
|
+
if missing:
|
|
379
|
+
msg = f'dataset attrs are missing required envlib metadata fields: {missing}.'
|
|
380
|
+
raise ValidationError(msg)
|
|
381
|
+
|
|
382
|
+
if ds.crs is None:
|
|
383
|
+
msg = 'every envlib dataset must have a CRS (ds.create.crs.from_user_input(...)).'
|
|
384
|
+
raise ValidationError(msg)
|
|
385
|
+
|
|
386
|
+
if meta.variable not in ds.data_var_names:
|
|
387
|
+
msg = (
|
|
388
|
+
f'the primary data variable must be named after Metadata.variable '
|
|
389
|
+
f'({meta.variable!r}); data variables present: {list(ds.data_var_names)}.'
|
|
390
|
+
)
|
|
391
|
+
raise ValidationError(msg)
|
|
392
|
+
dv = ds[meta.variable]
|
|
393
|
+
units = dv.attrs.get('units') or dv.units
|
|
394
|
+
if not units:
|
|
395
|
+
msg = f'the primary data variable {meta.variable!r} must carry a units attribute.'
|
|
396
|
+
raise ValidationError(msg)
|
|
397
|
+
|
|
398
|
+
ancillary = dv.attrs.get('ancillary_variables')
|
|
399
|
+
if ancillary:
|
|
400
|
+
for name in str(ancillary).split():
|
|
401
|
+
if name not in ds.data_var_names:
|
|
402
|
+
msg = f'declared ancillary variable {name!r} is not a data variable in the dataset.'
|
|
403
|
+
raise ValidationError(msg)
|
|
404
|
+
|
|
405
|
+
if _sys_dataset_type(ds) == 'ts_ortho':
|
|
406
|
+
_check_stations(ds)
|
|
407
|
+
|
|
408
|
+
state = _extract_state(ds, meta)
|
|
409
|
+
standard_name = _decide_standard_name(ds, meta)
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
'metadata': meta,
|
|
413
|
+
'dataset_version_id': meta.dataset_version_id,
|
|
414
|
+
'dataset_id': meta.dataset_id,
|
|
415
|
+
'state': state,
|
|
416
|
+
'standard_name': standard_name,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _apply_derived_attrs(ds, result: dict) -> bool:
|
|
421
|
+
"""Write self-identification ids + the auto-populated standard_name. Returns True if anything changed."""
|
|
422
|
+
changed = False
|
|
423
|
+
meta = result['metadata']
|
|
424
|
+
id_attrs = (
|
|
425
|
+
('envlib_dataset_version_id', result['dataset_version_id']),
|
|
426
|
+
('envlib_dataset_id', result['dataset_id']),
|
|
427
|
+
)
|
|
428
|
+
for key, value in id_attrs:
|
|
429
|
+
if ds.attrs.get(key) != value:
|
|
430
|
+
ds.attrs[key] = value
|
|
431
|
+
changed = True
|
|
432
|
+
sn = result['standard_name']
|
|
433
|
+
if sn['action'] == 'populate':
|
|
434
|
+
dv = ds[meta.variable]
|
|
435
|
+
if dv.attrs.get('standard_name') != sn['value']:
|
|
436
|
+
dv.attrs['standard_name'] = sn['value']
|
|
437
|
+
changed = True
|
|
438
|
+
return changed
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
###################################################
|
|
442
|
+
# DatasetRef
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class DatasetRef:
|
|
446
|
+
"""A catalogue entry: metadata plus how to open the dataset."""
|
|
447
|
+
|
|
448
|
+
def __init__(self, dataset_version_id: str, entry: dict, cache_dir: pathlib.Path):
|
|
449
|
+
self._dataset_id = dataset_version_id
|
|
450
|
+
self._entry = entry
|
|
451
|
+
self._cache_dir = cache_dir
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def metadata(self) -> dict:
|
|
455
|
+
"""The full envlib metadata dict stored in the catalogue entry."""
|
|
456
|
+
return dict(self._entry.get('user_meta') or {})
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def entry(self) -> dict:
|
|
460
|
+
"""The raw RCG entry (entry schema v1: remote_conn, remote_meta, user_meta, ...)."""
|
|
461
|
+
return self._entry
|
|
462
|
+
|
|
463
|
+
def __getattr__(self, name):
|
|
464
|
+
if name.startswith('_'):
|
|
465
|
+
raise AttributeError(name)
|
|
466
|
+
user_meta = self._entry.get('user_meta') or {}
|
|
467
|
+
if name in user_meta:
|
|
468
|
+
return user_meta[name]
|
|
469
|
+
msg = f'{type(self).__name__} has no attribute or metadata field {name!r}.'
|
|
470
|
+
raise AttributeError(msg)
|
|
471
|
+
|
|
472
|
+
def __repr__(self):
|
|
473
|
+
return repr(self.metadata)
|
|
474
|
+
|
|
475
|
+
def open(self, file_path=None, access_key_id=None, access_key=None):
|
|
476
|
+
"""Open the dataset as a cfdb EDataset (read-only).
|
|
477
|
+
|
|
478
|
+
Entries never store credentials. Public-HTTPS datasets open via their
|
|
479
|
+
``data_url`` with no credentials; for private buckets inject
|
|
480
|
+
``access_key_id``/``access_key``. Only inject credentials for entries
|
|
481
|
+
whose endpoint/bucket you trust — injected keys sign requests against
|
|
482
|
+
the entry's stored endpoint.
|
|
483
|
+
"""
|
|
484
|
+
conn_dict = dict(self._entry.get('remote_conn') or {})
|
|
485
|
+
if access_key_id is not None or access_key is not None:
|
|
486
|
+
conn_dict['access_key_id'] = access_key_id
|
|
487
|
+
conn_dict['access_key'] = access_key
|
|
488
|
+
elif not conn_dict.get('db_url'):
|
|
489
|
+
msg = (
|
|
490
|
+
'this entry has no public db_url; the dataset lives on a private bucket — '
|
|
491
|
+
'inject access_key_id/access_key to open it.'
|
|
492
|
+
)
|
|
493
|
+
raise ValidationError(msg)
|
|
494
|
+
else:
|
|
495
|
+
# url-only read session; drop the S3 fields so no signing is attempted
|
|
496
|
+
conn_dict = {'db_url': conn_dict['db_url']}
|
|
497
|
+
conn = ebooklet.S3Connection(**conn_dict)
|
|
498
|
+
if file_path is None:
|
|
499
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
500
|
+
file_path = self._cache_dir / f'{self._dataset_id}.cfdb'
|
|
501
|
+
return cfdb.open_edataset(conn, file_path, flag='r')
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
###################################################
|
|
505
|
+
# Catalogue
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class Catalogue:
|
|
509
|
+
"""RCG-backed catalogue of envlib datasets.
|
|
510
|
+
|
|
511
|
+
``Catalogue()`` connects to the public envlib RCG (read-only). Pass
|
|
512
|
+
``remotes=[...]`` (S3Connection | dict | URL str) to use your own RCGs —
|
|
513
|
+
this replaces the public default unless ``include_public=True``.
|
|
514
|
+
|
|
515
|
+
The catalogue snapshots all entries at construction; call :meth:`refresh`
|
|
516
|
+
to re-pull after new registrations. When a remote is unreachable and a
|
|
517
|
+
previously pulled local index exists, the catalogue degrades to the cached
|
|
518
|
+
copy (with a warning) instead of failing.
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
def __init__(self, remotes=None, cache: str = DEFAULT_CACHE_DIR, *, include_public: bool = False):
|
|
522
|
+
self._cache_dir = pathlib.Path(cache).expanduser()
|
|
523
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
524
|
+
|
|
525
|
+
sources = []
|
|
526
|
+
if remotes is None:
|
|
527
|
+
public = _public_rcg_url()
|
|
528
|
+
if public is None:
|
|
529
|
+
msg = (
|
|
530
|
+
'the public envlib RCG is not hosted yet — pass remotes=[...] '
|
|
531
|
+
f'or set the {PUBLIC_RCG_ENV_VAR} environment variable.'
|
|
532
|
+
)
|
|
533
|
+
raise ValueError(msg)
|
|
534
|
+
sources.append(public)
|
|
535
|
+
else:
|
|
536
|
+
if isinstance(remotes, (ebooklet.S3Connection, str, dict)):
|
|
537
|
+
remotes = [remotes]
|
|
538
|
+
sources.extend(remotes)
|
|
539
|
+
if include_public:
|
|
540
|
+
public = _public_rcg_url()
|
|
541
|
+
if public is None:
|
|
542
|
+
msg = f'include_public=True but no public RCG is configured ({PUBLIC_RCG_ENV_VAR}).'
|
|
543
|
+
raise ValueError(msg)
|
|
544
|
+
sources.append(public)
|
|
545
|
+
self._sources = sources
|
|
546
|
+
self._entries: dict = {}
|
|
547
|
+
self.refresh()
|
|
548
|
+
|
|
549
|
+
# -- index management ----------------------------------------------------
|
|
550
|
+
|
|
551
|
+
def _rcg_cache_path(self, source) -> pathlib.Path:
|
|
552
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
553
|
+
return self._cache_dir / f'{_conn_cache_key(source)}.rcg'
|
|
554
|
+
|
|
555
|
+
def refresh(self):
|
|
556
|
+
"""Re-pull the RCG index from all configured remotes."""
|
|
557
|
+
entries: dict = {}
|
|
558
|
+
for source in self._sources:
|
|
559
|
+
path = self._rcg_cache_path(source)
|
|
560
|
+
try:
|
|
561
|
+
with ebooklet.open_rcg(source, path, flag='r') as rcg:
|
|
562
|
+
source_entries = {k: v for k, v in rcg.items() if _HEX24_RE.fullmatch(str(k))}
|
|
563
|
+
except ValueError as err:
|
|
564
|
+
# ebooklet raises ValueError when the RCG does not exist on the
|
|
565
|
+
# remote yet (and no local copy exists) — the bootstrap case: a
|
|
566
|
+
# producer constructs the Catalogue before the first publish
|
|
567
|
+
# creates the RCG. Treat as an empty source, loudly.
|
|
568
|
+
warnings.warn(f'RCG source not readable yet ({err}); treating as empty.', stacklevel=2)
|
|
569
|
+
source_entries = {}
|
|
570
|
+
except _OFFLINE_ERRORS as err:
|
|
571
|
+
if not path.exists():
|
|
572
|
+
raise
|
|
573
|
+
warnings.warn(
|
|
574
|
+
f'RCG remote unreachable ({err!r}); operating offline from the cached index at {path}.',
|
|
575
|
+
stacklevel=2,
|
|
576
|
+
)
|
|
577
|
+
source_entries = _read_cached_index(path)
|
|
578
|
+
for key, entry in source_entries.items():
|
|
579
|
+
entries.setdefault(key, entry) # first configured source wins on duplicates
|
|
580
|
+
self._entries = entries
|
|
581
|
+
|
|
582
|
+
@property
|
|
583
|
+
def datasets(self) -> list:
|
|
584
|
+
"""All catalogue entries as DatasetRef objects."""
|
|
585
|
+
return [DatasetRef(key, entry, self._cache_dir) for key, entry in self._entries.items()]
|
|
586
|
+
|
|
587
|
+
# -- value discovery -------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
def distinct(self, field: str, *, counts: bool = False):
|
|
590
|
+
"""Distinct stored values of a queryable field across the catalogue.
|
|
591
|
+
|
|
592
|
+
The browse companion to :meth:`query`: ``cat.distinct('variable')``
|
|
593
|
+
tells you what is actually *in* this catalogue (the vocabularies module
|
|
594
|
+
lists what is *valid*, and can't help at all for the free-form fields
|
|
595
|
+
``owner``/``product_code``/``version``).
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
field: Any queryable field (identity fields, ``license``,
|
|
599
|
+
``dataset_type``, ...).
|
|
600
|
+
counts: When False (default), return a sorted list of the distinct
|
|
601
|
+
values, excluding None. When True, return a ``{value: count}``
|
|
602
|
+
dict that DOES include a None key when some entries lack the
|
|
603
|
+
field (e.g. datasets with no ``product_code``).
|
|
604
|
+
"""
|
|
605
|
+
if field not in _QUERYABLE_FIELDS:
|
|
606
|
+
msg = f'unknown field {field!r}; queryable fields are {sorted(_QUERYABLE_FIELDS)}.'
|
|
607
|
+
raise ValueError(msg)
|
|
608
|
+
tally: dict = {}
|
|
609
|
+
for entry in self._entries.values():
|
|
610
|
+
value = (entry.get('user_meta') or {}).get(field)
|
|
611
|
+
tally[value] = tally.get(value, 0) + 1
|
|
612
|
+
if counts:
|
|
613
|
+
return dict(sorted(tally.items(), key=lambda kv: (kv[0] is None, str(kv[0]))))
|
|
614
|
+
return sorted(v for v in tally if v is not None)
|
|
615
|
+
|
|
616
|
+
@property
|
|
617
|
+
def features(self) -> list:
|
|
618
|
+
"""Distinct feature values present in the catalogue (sorted)."""
|
|
619
|
+
return self.distinct('feature')
|
|
620
|
+
|
|
621
|
+
@property
|
|
622
|
+
def variables(self) -> list:
|
|
623
|
+
"""Distinct variable values present in the catalogue (sorted)."""
|
|
624
|
+
return self.distinct('variable')
|
|
625
|
+
|
|
626
|
+
@property
|
|
627
|
+
def methods(self) -> list:
|
|
628
|
+
"""Distinct method values present in the catalogue (sorted)."""
|
|
629
|
+
return self.distinct('method')
|
|
630
|
+
|
|
631
|
+
@property
|
|
632
|
+
def product_codes(self) -> list:
|
|
633
|
+
"""Distinct product_code values present in the catalogue (sorted; None excluded — see distinct())."""
|
|
634
|
+
return self.distinct('product_code')
|
|
635
|
+
|
|
636
|
+
@property
|
|
637
|
+
def processing_levels(self) -> list:
|
|
638
|
+
"""Distinct processing_level values present in the catalogue (sorted)."""
|
|
639
|
+
return self.distinct('processing_level')
|
|
640
|
+
|
|
641
|
+
@property
|
|
642
|
+
def owners(self) -> list:
|
|
643
|
+
"""Distinct owner values present in the catalogue (sorted)."""
|
|
644
|
+
return self.distinct('owner')
|
|
645
|
+
|
|
646
|
+
@property
|
|
647
|
+
def aggregation_statistics(self) -> list:
|
|
648
|
+
"""Distinct aggregation_statistic values present in the catalogue (sorted)."""
|
|
649
|
+
return self.distinct('aggregation_statistic')
|
|
650
|
+
|
|
651
|
+
@property
|
|
652
|
+
def frequency_intervals(self) -> list:
|
|
653
|
+
"""Distinct frequency_interval codes present in the catalogue (sorted; None excluded)."""
|
|
654
|
+
return self.distinct('frequency_interval')
|
|
655
|
+
|
|
656
|
+
@property
|
|
657
|
+
def utc_offsets(self) -> list:
|
|
658
|
+
"""Distinct utc_offset values present in the catalogue (sorted)."""
|
|
659
|
+
return self.distinct('utc_offset')
|
|
660
|
+
|
|
661
|
+
@property
|
|
662
|
+
def spatial_resolutions(self) -> list:
|
|
663
|
+
"""Distinct spatial_resolution values present in the catalogue (sorted; None excluded)."""
|
|
664
|
+
return self.distinct('spatial_resolution')
|
|
665
|
+
|
|
666
|
+
@property
|
|
667
|
+
def versions(self) -> list:
|
|
668
|
+
"""Distinct version strings present in the catalogue (sorted).
|
|
669
|
+
|
|
670
|
+
Version spellings are per-dataset conventions, so this global list
|
|
671
|
+
mixes unrelated series — mostly useful after narrowing with query().
|
|
672
|
+
"""
|
|
673
|
+
return self.distinct('version')
|
|
674
|
+
|
|
675
|
+
@property
|
|
676
|
+
def licenses(self) -> list:
|
|
677
|
+
"""Distinct license values present in the catalogue (sorted)."""
|
|
678
|
+
return self.distinct('license')
|
|
679
|
+
|
|
680
|
+
@property
|
|
681
|
+
def dataset_types(self) -> list:
|
|
682
|
+
"""Distinct dataset_type values present in the catalogue (sorted)."""
|
|
683
|
+
return self.distinct('dataset_type')
|
|
684
|
+
|
|
685
|
+
# -- query ----------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
def query(
|
|
688
|
+
self,
|
|
689
|
+
*,
|
|
690
|
+
bbox=None,
|
|
691
|
+
within_radius=None,
|
|
692
|
+
geometry=None,
|
|
693
|
+
start_date=None,
|
|
694
|
+
end_date=None,
|
|
695
|
+
**fields,
|
|
696
|
+
) -> list:
|
|
697
|
+
"""Filter the catalogue; kwargs are AND'd, a list value means any-of.
|
|
698
|
+
|
|
699
|
+
Spatial filters (mutually exclusive, EPSG:4326): ``bbox`` (intersects),
|
|
700
|
+
``within_radius`` (((lon, lat), km) great-circle), ``geometry``
|
|
701
|
+
(shapely, intersects). Temporal: ``start_date``/``end_date`` overlap
|
|
702
|
+
the dataset's time range. Without an explicit ``version=`` kwarg the
|
|
703
|
+
latest version (greatest created_at) of each matching dataset is
|
|
704
|
+
returned.
|
|
705
|
+
"""
|
|
706
|
+
unknown = set(fields) - _QUERYABLE_FIELDS
|
|
707
|
+
if unknown:
|
|
708
|
+
msg = f'unknown query fields: {sorted(unknown)}; queryable fields are {sorted(_QUERYABLE_FIELDS)}.'
|
|
709
|
+
raise ValueError(msg)
|
|
710
|
+
spatial_kwargs = (('bbox', bbox), ('within_radius', within_radius), ('geometry', geometry))
|
|
711
|
+
spatial = [kw for kw, v in spatial_kwargs if v is not None]
|
|
712
|
+
if len(spatial) > 1:
|
|
713
|
+
msg = f'spatial filters are mutually exclusive; got {spatial}.'
|
|
714
|
+
raise ValueError(msg)
|
|
715
|
+
|
|
716
|
+
results = []
|
|
717
|
+
for ref in self.datasets:
|
|
718
|
+
user_meta = ref.metadata
|
|
719
|
+
if not all(_field_matches(user_meta.get(name), wanted) for name, wanted in fields.items()):
|
|
720
|
+
continue
|
|
721
|
+
stored_bbox = user_meta.get('bbox')
|
|
722
|
+
if bbox is not None and (stored_bbox is None or not _bbox_intersects(stored_bbox, bbox)):
|
|
723
|
+
continue
|
|
724
|
+
if geometry is not None and (stored_bbox is None or not _geometry_intersects(stored_bbox, geometry)):
|
|
725
|
+
continue
|
|
726
|
+
if within_radius is not None:
|
|
727
|
+
(lon, lat), radius_km = within_radius
|
|
728
|
+
if stored_bbox is None or not _bbox_within_radius(stored_bbox, lon, lat, radius_km):
|
|
729
|
+
continue
|
|
730
|
+
if (start_date is not None or end_date is not None) and not _time_overlaps(
|
|
731
|
+
user_meta.get('time_start'), user_meta.get('time_end'), start_date, end_date
|
|
732
|
+
):
|
|
733
|
+
continue
|
|
734
|
+
results.append(ref)
|
|
735
|
+
|
|
736
|
+
if 'version' not in fields:
|
|
737
|
+
results = _latest_per_dataset(results)
|
|
738
|
+
return results
|
|
739
|
+
|
|
740
|
+
# -- validate / publish / register / deregister ---------------------------
|
|
741
|
+
|
|
742
|
+
def validate(self, local_cfdb_path) -> dict:
|
|
743
|
+
"""Validate a local cfdb file against envlib's requirements (no RCG or S3 changes).
|
|
744
|
+
|
|
745
|
+
Returns a summary dict ({'metadata', 'dataset_version_id', 'dataset_id', 'state',
|
|
746
|
+
'standard_name'}); raises ValidationError on invalid input.
|
|
747
|
+
"""
|
|
748
|
+
with cfdb.open_dataset(local_cfdb_path) as ds:
|
|
749
|
+
return _validate_dataset(ds, validate_cv=True)
|
|
750
|
+
|
|
751
|
+
def publish(self, local_cfdb_path, remote_conn, rcg_remote_conn, num_groups=None, **open_kwargs) -> dict:
|
|
752
|
+
"""Validate, push the cfdb data to its S3 remote, then register it in the RCG.
|
|
753
|
+
|
|
754
|
+
The cfdb data is pushed BEFORE the RCG entry so the catalogue never
|
|
755
|
+
references incomplete remote data. Re-running after a partial failure
|
|
756
|
+
is safe (the push is idempotent; the entry write is an upsert).
|
|
757
|
+
"""
|
|
758
|
+
member_conn = _as_connection(remote_conn)
|
|
759
|
+
edataset_kwargs = dict(open_kwargs)
|
|
760
|
+
if num_groups is not None:
|
|
761
|
+
edataset_kwargs['num_groups'] = num_groups
|
|
762
|
+
# validate INSIDE the edataset session: for a re-publish of an
|
|
763
|
+
# already-pushed (possibly partially materialized) local file, plain
|
|
764
|
+
# open_dataset would read local chunks only and could extract wrong
|
|
765
|
+
# extents; the EDataset pulls transparently. A ValidationError aborts
|
|
766
|
+
# before anything is pushed.
|
|
767
|
+
with cfdb.open_edataset(member_conn, local_cfdb_path, flag='w', **edataset_kwargs) as eds:
|
|
768
|
+
# attrs carrying a dataset_version_id mean this dataset was validated/registered
|
|
769
|
+
# before: skip CV re-validation (validation on change only).
|
|
770
|
+
first_time = eds.attrs.get('envlib_dataset_version_id') is None
|
|
771
|
+
result = _validate_dataset(eds, validate_cv=first_time)
|
|
772
|
+
_apply_derived_attrs(eds, result)
|
|
773
|
+
eds.push()
|
|
774
|
+
|
|
775
|
+
self._upsert_entry(rcg_remote_conn, member_conn, result)
|
|
776
|
+
return result
|
|
777
|
+
|
|
778
|
+
def register(self, remote_conn, rcg_remote_conn, **open_kwargs) -> dict:
|
|
779
|
+
"""Register an already-remote cfdb dataset in the catalogue (no data push).
|
|
780
|
+
|
|
781
|
+
``remote_conn`` must be writable (credentials): first registration
|
|
782
|
+
writes the self-identification attrs (and any auto-populated
|
|
783
|
+
standard_name) into the dataset, pushing that metadata-only change.
|
|
784
|
+
"""
|
|
785
|
+
member_conn = _as_connection(remote_conn)
|
|
786
|
+
local_path = self._cache_dir / f'{_conn_cache_key(member_conn)}.cfdb'
|
|
787
|
+
with cfdb.open_edataset(member_conn, local_path, flag='w', **open_kwargs) as eds:
|
|
788
|
+
first_time = eds.attrs.get('envlib_dataset_version_id') is None
|
|
789
|
+
result = _validate_dataset(eds, validate_cv=first_time)
|
|
790
|
+
if _apply_derived_attrs(eds, result):
|
|
791
|
+
eds.push()
|
|
792
|
+
|
|
793
|
+
self._upsert_entry(rcg_remote_conn, member_conn, result)
|
|
794
|
+
return result
|
|
795
|
+
|
|
796
|
+
def deregister(
|
|
797
|
+
self,
|
|
798
|
+
dataset_version_id: str,
|
|
799
|
+
rcg_remote_conn,
|
|
800
|
+
*,
|
|
801
|
+
delete_data: bool = False,
|
|
802
|
+
access_key_id=None,
|
|
803
|
+
access_key=None,
|
|
804
|
+
):
|
|
805
|
+
"""Remove a dataset's catalogue entry; optionally delete the hosted data.
|
|
806
|
+
|
|
807
|
+
Plain deregistration only delists — the hosted data stays up for
|
|
808
|
+
existing consumers. ``delete_data=True`` (retraction) additionally
|
|
809
|
+
deletes the remote cfdb via ebooklet, after verifying no OTHER entry
|
|
810
|
+
references the same remote target (the shared-target guard); it needs
|
|
811
|
+
the data owner's credentials injected.
|
|
812
|
+
"""
|
|
813
|
+
rcg_conn = _as_connection(rcg_remote_conn)
|
|
814
|
+
with ebooklet.open_rcg(rcg_conn, self._rcg_cache_path(rcg_conn), flag='w') as rcg:
|
|
815
|
+
entry = rcg.get(dataset_version_id)
|
|
816
|
+
if entry is None:
|
|
817
|
+
msg = f'no catalogue entry for dataset_version_id {dataset_version_id!r}.'
|
|
818
|
+
raise ValidationError(msg)
|
|
819
|
+
if delete_data:
|
|
820
|
+
target_conn = entry.get('remote_conn') or {}
|
|
821
|
+
target = (target_conn.get('endpoint_url'), target_conn.get('bucket'), target_conn.get('db_key'))
|
|
822
|
+
# list() first: fetching entries while iterating keys() would
|
|
823
|
+
# deadlock on the underlying booklet thread lock (see
|
|
824
|
+
# _read_cached_index).
|
|
825
|
+
for other_key in list(rcg.keys()):
|
|
826
|
+
if other_key == dataset_version_id or not _HEX24_RE.fullmatch(str(other_key)):
|
|
827
|
+
continue
|
|
828
|
+
other = rcg.get(other_key) or {}
|
|
829
|
+
other_conn = other.get('remote_conn') or {}
|
|
830
|
+
if (other_conn.get('endpoint_url'), other_conn.get('bucket'), other_conn.get('db_key')) == target:
|
|
831
|
+
msg = (
|
|
832
|
+
f'refusing delete_data=True: entry {other_key!r} references the same remote '
|
|
833
|
+
f'target ({target[2]!r} in bucket {target[1]!r}); deleting would destroy its data. '
|
|
834
|
+
f'Deregister without delete_data, or resolve the shared target first.'
|
|
835
|
+
)
|
|
836
|
+
raise ValidationError(msg)
|
|
837
|
+
if access_key_id is None or access_key is None:
|
|
838
|
+
msg = 'delete_data=True needs the data owner credentials (access_key_id/access_key).'
|
|
839
|
+
raise ValidationError(msg)
|
|
840
|
+
member_conn = ebooklet.S3Connection(
|
|
841
|
+
access_key_id=access_key_id,
|
|
842
|
+
access_key=access_key,
|
|
843
|
+
db_key=target_conn.get('db_key'),
|
|
844
|
+
bucket=target_conn.get('bucket'),
|
|
845
|
+
endpoint_url=target_conn.get('endpoint_url'),
|
|
846
|
+
)
|
|
847
|
+
with member_conn.open('w') as session:
|
|
848
|
+
session.delete_remote()
|
|
849
|
+
del rcg[dataset_version_id]
|
|
850
|
+
rcg.changes().push()
|
|
851
|
+
self.refresh()
|
|
852
|
+
|
|
853
|
+
# -- entry construction ----------------------------------------------------
|
|
854
|
+
|
|
855
|
+
def _upsert_entry(self, rcg_remote_conn, member_conn: ebooklet.S3Connection, result: dict):
|
|
856
|
+
rcg_conn = _as_connection(rcg_remote_conn)
|
|
857
|
+
dataset_version_id = result['dataset_version_id']
|
|
858
|
+
with ebooklet.open_rcg(rcg_conn, self._rcg_cache_path(rcg_conn), flag='c') as rcg:
|
|
859
|
+
existing = rcg.get(dataset_version_id)
|
|
860
|
+
existing_meta = (existing or {}).get('user_meta') or {}
|
|
861
|
+
now = _utc_now_iso()
|
|
862
|
+
user_meta = _build_user_meta(result, member_conn)
|
|
863
|
+
user_meta['created_at'] = existing_meta.get('created_at') or now
|
|
864
|
+
|
|
865
|
+
comparable_new = {k: v for k, v in user_meta.items() if k != 'modified_at'}
|
|
866
|
+
comparable_old = {k: v for k, v in existing_meta.items() if k != 'modified_at'}
|
|
867
|
+
stored_conn = (existing or {}).get('remote_conn') or {}
|
|
868
|
+
conn_changed = stored_conn != member_conn.to_dict()
|
|
869
|
+
if existing is not None and comparable_new == comparable_old and not conn_changed:
|
|
870
|
+
return # true no-op: do not bump modified_at, do not push
|
|
871
|
+
|
|
872
|
+
user_meta['modified_at'] = now
|
|
873
|
+
rcg.add(member_conn, key=dataset_version_id, user_meta=user_meta)
|
|
874
|
+
rcg.changes().push()
|
|
875
|
+
self.refresh()
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _build_user_meta(result: dict, member_conn: ebooklet.S3Connection) -> dict:
|
|
879
|
+
meta = result['metadata']
|
|
880
|
+
values = {f: getattr(meta, f) for f in IDENTITY_FIELDS}
|
|
881
|
+
user_meta = dict(values)
|
|
882
|
+
for f in GENERAL_FIELDS:
|
|
883
|
+
value = getattr(meta, f)
|
|
884
|
+
if value is not None:
|
|
885
|
+
user_meta[f] = value
|
|
886
|
+
user_meta['dataset_version_id'] = result['dataset_version_id']
|
|
887
|
+
user_meta['dataset_id'] = result['dataset_id']
|
|
888
|
+
user_meta.update(result['state'])
|
|
889
|
+
user_meta['data_url'] = _validate_data_url(member_conn.db_url)
|
|
890
|
+
sn = result['standard_name']
|
|
891
|
+
if sn['value'] is not None:
|
|
892
|
+
user_meta['standard_name'] = sn['value']
|
|
893
|
+
return user_meta
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
###################################################
|
|
897
|
+
# query helpers
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _field_matches(stored, wanted) -> bool:
|
|
901
|
+
if isinstance(wanted, (list, tuple, set)):
|
|
902
|
+
return any(_field_matches(stored, w) for w in wanted)
|
|
903
|
+
if wanted is None:
|
|
904
|
+
return stored is None
|
|
905
|
+
if stored is None:
|
|
906
|
+
return False
|
|
907
|
+
return str(stored).lower() == str(wanted).strip().lower()
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _time_overlaps(time_start, time_end, start_date, end_date) -> bool:
|
|
911
|
+
if time_start is None or time_end is None:
|
|
912
|
+
return False
|
|
913
|
+
t0 = _parse_iso(time_start)
|
|
914
|
+
t1 = _parse_iso(time_end)
|
|
915
|
+
if start_date is not None and t1 < _parse_iso(start_date):
|
|
916
|
+
return False
|
|
917
|
+
return not (end_date is not None and t0 > _parse_iso(end_date))
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _latest_per_dataset(refs: list) -> list:
|
|
921
|
+
by_dataset: dict = {}
|
|
922
|
+
no_dataset_id = []
|
|
923
|
+
for ref in refs:
|
|
924
|
+
user_meta = ref.metadata
|
|
925
|
+
dataset_id = user_meta.get('dataset_id')
|
|
926
|
+
if dataset_id is None:
|
|
927
|
+
no_dataset_id.append(ref)
|
|
928
|
+
continue
|
|
929
|
+
current = by_dataset.get(dataset_id)
|
|
930
|
+
if current is None or _created_at(ref) > _created_at(current):
|
|
931
|
+
by_dataset[dataset_id] = ref
|
|
932
|
+
return list(by_dataset.values()) + no_dataset_id
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _created_at(ref: DatasetRef) -> datetime.datetime:
|
|
936
|
+
value = ref.metadata.get('created_at')
|
|
937
|
+
if value is None:
|
|
938
|
+
return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
|
|
939
|
+
return _parse_iso(value)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _read_cached_index(path: pathlib.Path) -> dict:
|
|
943
|
+
"""Offline fallback: read a previously pulled RCG index directly with booklet.
|
|
944
|
+
|
|
945
|
+
Booklet files are self-describing (serializers stored in-file), so entries
|
|
946
|
+
decode without parameters. envlib entries are keyed by 24-hex dataset_ids;
|
|
947
|
+
everything else (ebooklet-internal keys) is skipped. Values that fail to
|
|
948
|
+
decode as entry dicts are skipped too.
|
|
949
|
+
"""
|
|
950
|
+
entries: dict = {}
|
|
951
|
+
with booklet.open(path, 'r') as blt:
|
|
952
|
+
# materialize keys() BEFORE fetching: booklet's keys() generator holds
|
|
953
|
+
# the file's thread lock across yields, so a get() inside the loop
|
|
954
|
+
# deadlocks (single-threaded) — found via the hung offline test.
|
|
955
|
+
for key in list(blt.keys()):
|
|
956
|
+
key_str = key.decode() if isinstance(key, bytes) else str(key)
|
|
957
|
+
if not _HEX24_RE.fullmatch(key_str):
|
|
958
|
+
continue
|
|
959
|
+
try:
|
|
960
|
+
value = blt[key]
|
|
961
|
+
except (KeyError, ValueError):
|
|
962
|
+
continue
|
|
963
|
+
if isinstance(value, bytes):
|
|
964
|
+
try:
|
|
965
|
+
value = json.loads(value)
|
|
966
|
+
except ValueError:
|
|
967
|
+
continue
|
|
968
|
+
if isinstance(value, dict) and 'remote_conn' in value:
|
|
969
|
+
entries[key_str] = value
|
|
970
|
+
return entries
|