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/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