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/metadata.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""envlib dataset metadata: validation, normalization, and identity hashing.
|
|
2
|
+
|
|
3
|
+
Implements the Identity/General metadata model from ``plans/architecture_plan.md``.
|
|
4
|
+
The serialization rules in :func:`compute_dataset_id` / :func:`compute_dataset_version_id` /
|
|
5
|
+
:func:`compute_station_id` are permanent public contracts — changing any aspect of
|
|
6
|
+
them (field order, the ``"None"`` sentinel, the ``\\x1f`` delimiter, the UTF-8
|
|
7
|
+
encoding, the keyless blake2b construction, the digest size, the WKB byte order,
|
|
8
|
+
the signed-zero normalization) would fork every existing id. Do not change them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from hashlib import blake2b
|
|
16
|
+
from typing import cast
|
|
17
|
+
|
|
18
|
+
import shapely
|
|
19
|
+
from shapely import wkt
|
|
20
|
+
|
|
21
|
+
from envlib import vocabularies
|
|
22
|
+
|
|
23
|
+
# Hash-internal field order — MUST NOT change (user-facing display order is free to differ).
|
|
24
|
+
IDENTITY_FIELDS = (
|
|
25
|
+
'feature',
|
|
26
|
+
'variable',
|
|
27
|
+
'method',
|
|
28
|
+
'product_code',
|
|
29
|
+
'processing_level',
|
|
30
|
+
'owner',
|
|
31
|
+
'aggregation_statistic',
|
|
32
|
+
'frequency_interval',
|
|
33
|
+
'utc_offset',
|
|
34
|
+
'spatial_resolution',
|
|
35
|
+
'version',
|
|
36
|
+
)
|
|
37
|
+
GENERAL_FIELDS = ('license', 'attribution', 'description', 'derived_from', 'doi')
|
|
38
|
+
|
|
39
|
+
# Identity fields for which None is a legitimate value (not "unset").
|
|
40
|
+
NULLABLE_IDENTITY_FIELDS = frozenset({'product_code', 'frequency_interval', 'spatial_resolution'})
|
|
41
|
+
|
|
42
|
+
# Fields whose canonical value comes from a controlled vocabulary that can drift
|
|
43
|
+
# across vocabulary refreshes — validated on change only, never on re-read.
|
|
44
|
+
_CV_FIELDS = frozenset(
|
|
45
|
+
{'feature', 'variable', 'method', 'processing_level', 'aggregation_statistic', 'frequency_interval', 'license'}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
ATTR_PREFIX = 'envlib_'
|
|
49
|
+
|
|
50
|
+
ID_HEX_LEN = 24 # blake2b(digest_size=12).hexdigest()
|
|
51
|
+
|
|
52
|
+
_SLUG_RE = re.compile(r'[a-z0-9._-]+')
|
|
53
|
+
_SLUG_ALNUM_RE = re.compile(r'[a-z0-9]')
|
|
54
|
+
_UTC_OFFSET_RE = re.compile(r'([+-])([0-9]{2}):([0-9]{2})')
|
|
55
|
+
_UTC_OFFSET_SHORTHAND_RE = re.compile(r'([+-])([0-9]{2})')
|
|
56
|
+
_SPATIAL_RESOLUTION_RE = re.compile(r'(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?(?:m|km|deg)')
|
|
57
|
+
_HEX_ID_RE = re.compile(r'[0-9a-f]{24}')
|
|
58
|
+
|
|
59
|
+
_UTC_OFFSET_MIN_MINUTES = -12 * 60
|
|
60
|
+
_UTC_OFFSET_MAX_MINUTES = 14 * 60
|
|
61
|
+
_ALLOWED_OFFSET_MINUTES = frozenset({0, 15, 30, 45})
|
|
62
|
+
|
|
63
|
+
_DOI_URL_PREFIX = 'https://doi.org/'
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ValidationError(ValueError):
|
|
67
|
+
"""Raised when metadata or dataset content fails envlib validation.
|
|
68
|
+
|
|
69
|
+
Subclasses ValueError so ``except ValueError`` keeps working for callers
|
|
70
|
+
that don't import envlib's exception.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
###################################################
|
|
75
|
+
# Field validators (pure functions; return the normalized value or raise)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _require_str(field: str, value) -> str:
|
|
79
|
+
if not isinstance(value, str):
|
|
80
|
+
msg = f'{field} must be a str, not {type(value).__name__}.'
|
|
81
|
+
raise ValidationError(msg)
|
|
82
|
+
return value.strip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_slug(field: str, value: str) -> str:
|
|
86
|
+
"""Normalize a free-form slug field (strip, reject non-ASCII, lowercase, grammar-check).
|
|
87
|
+
|
|
88
|
+
Non-ASCII input is rejected *before* lowercasing so no locale-dependent case
|
|
89
|
+
mapping ever reaches the hash.
|
|
90
|
+
"""
|
|
91
|
+
v = _require_str(field, value)
|
|
92
|
+
if not v.isascii():
|
|
93
|
+
msg = f'{field} must be ASCII; got {value!r}.'
|
|
94
|
+
raise ValidationError(msg)
|
|
95
|
+
v = v.lower()
|
|
96
|
+
if not _SLUG_RE.fullmatch(v):
|
|
97
|
+
msg = f'{field} must match [a-z0-9._-]+ after lowercasing; got {value!r}.'
|
|
98
|
+
raise ValidationError(msg)
|
|
99
|
+
if not _SLUG_ALNUM_RE.search(v):
|
|
100
|
+
# punctuation-only slugs ('.', '-', '...') are typo-class garbage that
|
|
101
|
+
# would mint permanent identities (ruling 2026-07-07)
|
|
102
|
+
msg = f'{field} must contain at least one letter or digit; got {value!r}.'
|
|
103
|
+
raise ValidationError(msg)
|
|
104
|
+
return v
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_utc_offset(value: str) -> str:
|
|
108
|
+
"""Validate/canonicalize a utc_offset to ``±HH:MM``.
|
|
109
|
+
|
|
110
|
+
Accepts the ``±HH`` input shorthand (expanded to ``±HH:00``). ``-00:00``
|
|
111
|
+
normalizes to ``+00:00``. The offset must lie within [-12:00, +14:00] with
|
|
112
|
+
minutes in {00, 15, 30, 45}. The frequency-dependent reduction rule is
|
|
113
|
+
applied separately (see ``Metadata``) because it needs ``frequency_interval``.
|
|
114
|
+
"""
|
|
115
|
+
v = _require_str('utc_offset', value)
|
|
116
|
+
m = _UTC_OFFSET_SHORTHAND_RE.fullmatch(v)
|
|
117
|
+
if m is not None:
|
|
118
|
+
v = f'{m.group(1)}{m.group(2)}:00'
|
|
119
|
+
m = _UTC_OFFSET_RE.fullmatch(v)
|
|
120
|
+
if m is None:
|
|
121
|
+
msg = f'utc_offset must be ±HH:MM (or ±HH shorthand); got {value!r}.'
|
|
122
|
+
raise ValidationError(msg)
|
|
123
|
+
sign, hh, mm = m.group(1), int(m.group(2)), int(m.group(3))
|
|
124
|
+
if mm not in _ALLOWED_OFFSET_MINUTES:
|
|
125
|
+
msg = f'utc_offset minutes must be one of 00/15/30/45; got {value!r}.'
|
|
126
|
+
raise ValidationError(msg)
|
|
127
|
+
total = (hh * 60 + mm) * (-1 if sign == '-' else 1)
|
|
128
|
+
if not _UTC_OFFSET_MIN_MINUTES <= total <= _UTC_OFFSET_MAX_MINUTES:
|
|
129
|
+
msg = f'utc_offset must lie within [-12:00, +14:00]; got {value!r}.'
|
|
130
|
+
raise ValidationError(msg)
|
|
131
|
+
if total == 0:
|
|
132
|
+
return '+00:00'
|
|
133
|
+
return f'{sign}{hh:02d}:{mm:02d}'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _utc_offset_seconds(canonical_offset: str) -> int:
|
|
137
|
+
m = _UTC_OFFSET_RE.fullmatch(canonical_offset)
|
|
138
|
+
if m is None: # unreachable for canonical offsets; guards type narrowing
|
|
139
|
+
msg = f'not a canonical utc_offset: {canonical_offset!r}.'
|
|
140
|
+
raise ValueError(msg)
|
|
141
|
+
sign, hh, mm = m.group(1), int(m.group(2)), int(m.group(3))
|
|
142
|
+
return (hh * 3600 + mm * 60) * (-1 if sign == '-' else 1)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def validate_spatial_resolution(value):
|
|
146
|
+
"""Validate spatial_resolution: ``<number><unit>`` | ``'point'`` | None.
|
|
147
|
+
|
|
148
|
+
The numeric grammar admits exactly one spelling per value; non-canonical
|
|
149
|
+
spellings (``.25deg``, ``00.25deg``, ``0.250deg``, ``1.0km``) are REJECTED,
|
|
150
|
+
never rewritten. Only case and whitespace normalize.
|
|
151
|
+
"""
|
|
152
|
+
if value is None:
|
|
153
|
+
return None
|
|
154
|
+
v = _require_str('spatial_resolution', value).lower()
|
|
155
|
+
if v == 'point':
|
|
156
|
+
return v
|
|
157
|
+
if not _SPATIAL_RESOLUTION_RE.fullmatch(v):
|
|
158
|
+
msg = (
|
|
159
|
+
f'spatial_resolution must be <number><m|km|deg> in canonical spelling '
|
|
160
|
+
f'(e.g. 0.25deg, 1km, 500m), the literal point, or None; got {value!r}.'
|
|
161
|
+
)
|
|
162
|
+
raise ValidationError(msg)
|
|
163
|
+
return v
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def validate_product_code(value):
|
|
167
|
+
if value is None:
|
|
168
|
+
return None
|
|
169
|
+
v = validate_slug('product_code', value)
|
|
170
|
+
if v == 'none':
|
|
171
|
+
msg = "product_code 'none' is rejected to avoid confusion with the None sentinel; use None itself."
|
|
172
|
+
raise ValidationError(msg)
|
|
173
|
+
return v
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_derived_from(value):
|
|
177
|
+
if value is None:
|
|
178
|
+
return None
|
|
179
|
+
if isinstance(value, str) or not isinstance(value, (list, tuple)):
|
|
180
|
+
msg = f'derived_from must be a list of dataset_version_ids and/or DOI URLs, not {type(value).__name__}.'
|
|
181
|
+
raise ValidationError(msg)
|
|
182
|
+
out = []
|
|
183
|
+
for item in value:
|
|
184
|
+
v = _require_str('derived_from entry', item)
|
|
185
|
+
if not (_HEX_ID_RE.fullmatch(v) or (v.startswith(_DOI_URL_PREFIX) and len(v) > len(_DOI_URL_PREFIX))):
|
|
186
|
+
msg = (
|
|
187
|
+
f'derived_from entries must be 24-char hex dataset_version_ids '
|
|
188
|
+
f'or {_DOI_URL_PREFIX}... URLs; got {item!r}.'
|
|
189
|
+
)
|
|
190
|
+
raise ValidationError(msg)
|
|
191
|
+
out.append(v)
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def validate_doi(value):
|
|
196
|
+
if value is None:
|
|
197
|
+
return None
|
|
198
|
+
v = _require_str('doi', value)
|
|
199
|
+
if not (v.startswith(_DOI_URL_PREFIX) and len(v) > len(_DOI_URL_PREFIX)):
|
|
200
|
+
msg = f'doi must be a full DOI URL ({_DOI_URL_PREFIX}...); got {value!r}.'
|
|
201
|
+
raise ValidationError(msg)
|
|
202
|
+
return v
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _validate_text(field: str, value):
|
|
206
|
+
if value is None:
|
|
207
|
+
return None
|
|
208
|
+
return _require_str(field, value)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
###################################################
|
|
212
|
+
# Identity hashing — permanent contract
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _serialize_identity_value(value) -> str:
|
|
216
|
+
return 'None' if value is None else value
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def compute_dataset_version_id(values: dict) -> str:
|
|
220
|
+
"""blake2b-12 hex of all 11 Identity fields — identifies one version of a dataset.
|
|
221
|
+
|
|
222
|
+
This is the catalogue entry key. ``values`` must hold fully
|
|
223
|
+
normalized/canonical values (as produced by ``Metadata`` — including the
|
|
224
|
+
frequency-reduced ``utc_offset``); prefer ``Metadata.dataset_version_id``
|
|
225
|
+
unless you are certain the inputs are canonical.
|
|
226
|
+
"""
|
|
227
|
+
return _hash_fields([values[f] for f in IDENTITY_FIELDS])
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def compute_dataset_id(values: dict) -> str:
|
|
231
|
+
"""blake2b-12 hex of the 10 Identity fields excluding ``version`` — the dataset identity, stable across versions."""
|
|
232
|
+
return _hash_fields([values[f] for f in IDENTITY_FIELDS if f != 'version'])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _hash_fields(field_values) -> str:
|
|
236
|
+
joined = '\x1f'.join(_serialize_identity_value(v) for v in field_values)
|
|
237
|
+
return blake2b(joined.encode('utf-8'), digest_size=12).hexdigest()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def compute_station_id(geometry) -> str:
|
|
241
|
+
"""Deterministic station id from a shapely Point in EPSG:4326.
|
|
242
|
+
|
|
243
|
+
tethys-compatible derivation: z stripped if present; WKT round-trip with
|
|
244
|
+
``rounding_precision=5`` (~1 m at the equator); signed zero collapsed
|
|
245
|
+
(``-0.0`` -> ``0.0``, reachable after reprojection); explicit little-endian
|
|
246
|
+
WKB; keyless blake2b-12 hex. Same x/y at different z share a station_id.
|
|
247
|
+
"""
|
|
248
|
+
if not isinstance(geometry, shapely.Point):
|
|
249
|
+
msg = f'station geometry must be a shapely Point (v1 supports Points only), not {type(geometry).__name__}.'
|
|
250
|
+
raise ValidationError(msg)
|
|
251
|
+
if geometry.is_empty:
|
|
252
|
+
msg = 'station geometry must be a non-empty Point.'
|
|
253
|
+
raise ValidationError(msg)
|
|
254
|
+
if not (math.isfinite(geometry.x) and math.isfinite(geometry.y)):
|
|
255
|
+
# NaN/inf would hash to a "valid-looking" id (NaN has a fixed WKB bit
|
|
256
|
+
# pattern), silently correlating unrelated corrupt stations.
|
|
257
|
+
msg = f'station coordinates must be finite; got ({geometry.x}, {geometry.y}).'
|
|
258
|
+
raise ValidationError(msg)
|
|
259
|
+
if geometry.has_z:
|
|
260
|
+
geometry = shapely.Point(geometry.x, geometry.y)
|
|
261
|
+
rounded = cast('shapely.Point', wkt.loads(wkt.dumps(geometry, rounding_precision=5)))
|
|
262
|
+
canonical_point = shapely.Point(rounded.x + 0.0, rounded.y + 0.0)
|
|
263
|
+
return blake2b(shapely.to_wkb(canonical_point, byte_order=1), digest_size=12).hexdigest()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
###################################################
|
|
267
|
+
# Metadata class
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Metadata:
|
|
271
|
+
"""Structured envlib dataset metadata with validation and normalization on set.
|
|
272
|
+
|
|
273
|
+
Construct all at once (``Metadata(feature=..., variable=..., ...)``) or
|
|
274
|
+
incrementally (``meta = Metadata(); meta.feature = 'atmosphere'``). Every
|
|
275
|
+
setter validates and normalizes; cross-field canonicalization (the
|
|
276
|
+
utc_offset reduction rule, which depends on ``frequency_interval``) is
|
|
277
|
+
applied lazily at every read point, so construction order never matters.
|
|
278
|
+
|
|
279
|
+
``feature``, ``variable``, ``method``, ``processing_level``,
|
|
280
|
+
``aggregation_statistic``, ``frequency_interval``, and ``license`` are
|
|
281
|
+
CV-validated on set; per the validation-on-change-only rule, reading stored
|
|
282
|
+
metadata back (``from_attrs(..., validate_cv=False)``) trusts the stored
|
|
283
|
+
canonical values so vocabulary drift never orphans an existing dataset.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def __init__(self, **kwargs):
|
|
287
|
+
self._values = dict.fromkeys(IDENTITY_FIELDS + GENERAL_FIELDS)
|
|
288
|
+
for key, value in kwargs.items():
|
|
289
|
+
if key not in self._values:
|
|
290
|
+
msg = f'Unknown metadata field {key!r}.'
|
|
291
|
+
raise ValidationError(msg)
|
|
292
|
+
setattr(self, key, value)
|
|
293
|
+
|
|
294
|
+
# -- CV-constrained identity fields ------------------------------------
|
|
295
|
+
|
|
296
|
+
def _set_cv(self, field: str, value):
|
|
297
|
+
if value is None:
|
|
298
|
+
self._values[field] = None
|
|
299
|
+
return
|
|
300
|
+
try:
|
|
301
|
+
self._values[field] = vocabularies.canonical(field, value)
|
|
302
|
+
except (ValueError, TypeError) as err:
|
|
303
|
+
# uniform error contract: every Metadata setter failure is a
|
|
304
|
+
# ValidationError (vocabularies itself stays exception-agnostic)
|
|
305
|
+
raise ValidationError(str(err)) from err
|
|
306
|
+
|
|
307
|
+
feature = property(
|
|
308
|
+
lambda self: self._values['feature'],
|
|
309
|
+
lambda self, v: self._set_cv('feature', v),
|
|
310
|
+
doc="Feature CV value (e.g. 'atmosphere', 'waterway').",
|
|
311
|
+
)
|
|
312
|
+
variable = property(
|
|
313
|
+
lambda self: self._values['variable'],
|
|
314
|
+
lambda self, v: self._set_cv('variable', v),
|
|
315
|
+
doc='Variable CV value (ODM2-derived union envlib extensions).',
|
|
316
|
+
)
|
|
317
|
+
method = property(
|
|
318
|
+
lambda self: self._values['method'],
|
|
319
|
+
lambda self, v: self._set_cv('method', v),
|
|
320
|
+
doc='Method CV value.',
|
|
321
|
+
)
|
|
322
|
+
processing_level = property(
|
|
323
|
+
lambda self: self._values['processing_level'],
|
|
324
|
+
lambda self, v: self._set_cv('processing_level', v),
|
|
325
|
+
doc='Processing-level CV value (raw / preliminary / quality_controlled).',
|
|
326
|
+
)
|
|
327
|
+
aggregation_statistic = property(
|
|
328
|
+
lambda self: self._values['aggregation_statistic'],
|
|
329
|
+
lambda self, v: self._set_cv('aggregation_statistic', v),
|
|
330
|
+
doc='CF cell_methods statistical subset value.',
|
|
331
|
+
)
|
|
332
|
+
license = property(
|
|
333
|
+
lambda self: self._values['license'],
|
|
334
|
+
lambda self, v: self._set_cv('license', v),
|
|
335
|
+
doc='License CV value (canonical case preserved, e.g. CC-BY-4.0).',
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def frequency_interval(self):
|
|
340
|
+
"""Canonical envlib frequency code, or None for irregular cadences."""
|
|
341
|
+
return self._values['frequency_interval']
|
|
342
|
+
|
|
343
|
+
@frequency_interval.setter
|
|
344
|
+
def frequency_interval(self, value):
|
|
345
|
+
self._set_cv('frequency_interval', value)
|
|
346
|
+
|
|
347
|
+
# -- free-form / grammar-validated fields ------------------------------
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def owner(self):
|
|
351
|
+
return self._values['owner']
|
|
352
|
+
|
|
353
|
+
@owner.setter
|
|
354
|
+
def owner(self, value):
|
|
355
|
+
self._values['owner'] = None if value is None else validate_slug('owner', value)
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def product_code(self):
|
|
359
|
+
return self._values['product_code']
|
|
360
|
+
|
|
361
|
+
@product_code.setter
|
|
362
|
+
def product_code(self, value):
|
|
363
|
+
self._values['product_code'] = validate_product_code(value)
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def version(self):
|
|
367
|
+
return self._values['version']
|
|
368
|
+
|
|
369
|
+
@version.setter
|
|
370
|
+
def version(self, value):
|
|
371
|
+
self._values['version'] = None if value is None else validate_slug('version', value)
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def spatial_resolution(self):
|
|
375
|
+
return self._values['spatial_resolution']
|
|
376
|
+
|
|
377
|
+
@spatial_resolution.setter
|
|
378
|
+
def spatial_resolution(self, value):
|
|
379
|
+
self._values['spatial_resolution'] = validate_spatial_resolution(value)
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def utc_offset(self):
|
|
383
|
+
"""The canonical utc_offset, with the frequency reduction rule applied.
|
|
384
|
+
|
|
385
|
+
For fixed-duration cadences an offset that divides the cadence evenly
|
|
386
|
+
(identical binning to UTC) reduces to ``+00:00``; likewise when
|
|
387
|
+
``frequency_interval`` is None (no binning at all). Calendar cadences
|
|
388
|
+
(month/year) always retain the stored offset.
|
|
389
|
+
"""
|
|
390
|
+
return self._reduced_utc_offset()
|
|
391
|
+
|
|
392
|
+
@utc_offset.setter
|
|
393
|
+
def utc_offset(self, value):
|
|
394
|
+
self._values['utc_offset'] = None if value is None else validate_utc_offset(value)
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def attribution(self):
|
|
398
|
+
return self._values['attribution']
|
|
399
|
+
|
|
400
|
+
@attribution.setter
|
|
401
|
+
def attribution(self, value):
|
|
402
|
+
self._values['attribution'] = _validate_text('attribution', value)
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def description(self):
|
|
406
|
+
return self._values['description']
|
|
407
|
+
|
|
408
|
+
@description.setter
|
|
409
|
+
def description(self, value):
|
|
410
|
+
self._values['description'] = _validate_text('description', value)
|
|
411
|
+
|
|
412
|
+
@property
|
|
413
|
+
def derived_from(self):
|
|
414
|
+
return self._values['derived_from']
|
|
415
|
+
|
|
416
|
+
@derived_from.setter
|
|
417
|
+
def derived_from(self, value):
|
|
418
|
+
self._values['derived_from'] = validate_derived_from(value)
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def doi(self):
|
|
422
|
+
return self._values['doi']
|
|
423
|
+
|
|
424
|
+
@doi.setter
|
|
425
|
+
def doi(self, value):
|
|
426
|
+
self._values['doi'] = validate_doi(value)
|
|
427
|
+
|
|
428
|
+
# -- cross-field canonicalization ---------------------------------------
|
|
429
|
+
|
|
430
|
+
def _reduced_utc_offset(self):
|
|
431
|
+
stored = self._values['utc_offset']
|
|
432
|
+
if stored is None:
|
|
433
|
+
return None
|
|
434
|
+
frequency = self._values['frequency_interval']
|
|
435
|
+
if frequency is None:
|
|
436
|
+
return '+00:00'
|
|
437
|
+
try:
|
|
438
|
+
entry = vocabularies.frequency_entry(frequency)
|
|
439
|
+
except ValueError:
|
|
440
|
+
# Stored frequency code no longer in the vocabulary (drift on a
|
|
441
|
+
# re-read of old metadata): the stored offset was already reduced
|
|
442
|
+
# at first registration, so returning it unchanged keeps the
|
|
443
|
+
# dataset_version_id stable. New codes always resolve here.
|
|
444
|
+
return stored
|
|
445
|
+
if entry['kind'] == 'calendar':
|
|
446
|
+
return stored
|
|
447
|
+
if _utc_offset_seconds(stored) % entry['seconds'] == 0:
|
|
448
|
+
return '+00:00'
|
|
449
|
+
return stored
|
|
450
|
+
|
|
451
|
+
# -- identity / completeness --------------------------------------------
|
|
452
|
+
|
|
453
|
+
def missing_fields(self) -> list:
|
|
454
|
+
"""Identity fields still unset (nullable ones excepted) plus missing required General fields."""
|
|
455
|
+
missing = [f for f in IDENTITY_FIELDS if self._values[f] is None and f not in NULLABLE_IDENTITY_FIELDS]
|
|
456
|
+
missing += [f for f in ('license', 'attribution') if self._values[f] is None]
|
|
457
|
+
return missing
|
|
458
|
+
|
|
459
|
+
def _identity_values(self) -> dict:
|
|
460
|
+
values = {f: self._values[f] for f in IDENTITY_FIELDS}
|
|
461
|
+
values['utc_offset'] = self._reduced_utc_offset()
|
|
462
|
+
return values
|
|
463
|
+
|
|
464
|
+
def _require_identity_complete(self):
|
|
465
|
+
missing = [f for f in IDENTITY_FIELDS if self._values[f] is None and f not in NULLABLE_IDENTITY_FIELDS]
|
|
466
|
+
if missing:
|
|
467
|
+
msg = f'Identity metadata incomplete; missing fields: {missing}.'
|
|
468
|
+
raise ValidationError(msg)
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def dataset_version_id(self) -> str:
|
|
472
|
+
"""Deterministic id of this version of the dataset (all 11 Identity fields; the catalogue entry key)."""
|
|
473
|
+
self._require_identity_complete()
|
|
474
|
+
return compute_dataset_version_id(self._identity_values())
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def dataset_id(self) -> str:
|
|
478
|
+
"""Deterministic id of the dataset, stable across versions (Identity fields minus version)."""
|
|
479
|
+
self._require_identity_complete()
|
|
480
|
+
return compute_dataset_id(self._identity_values())
|
|
481
|
+
|
|
482
|
+
# -- serialization --------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
def to_dict(self) -> dict:
|
|
485
|
+
"""Emit the ``envlib_``-prefixed attr dict for ``ds.attrs.update()``.
|
|
486
|
+
|
|
487
|
+
Requires complete Identity metadata plus the required General fields
|
|
488
|
+
(license, attribution). All 11 identity keys are always present
|
|
489
|
+
(nullable ones as JSON null); optional General fields only when set;
|
|
490
|
+
the computed dataset_version_id/dataset_id are included (self-identification).
|
|
491
|
+
"""
|
|
492
|
+
missing = self.missing_fields()
|
|
493
|
+
if missing:
|
|
494
|
+
msg = f'Metadata incomplete; missing fields: {missing}.'
|
|
495
|
+
raise ValidationError(msg)
|
|
496
|
+
out = {ATTR_PREFIX + f: v for f, v in self._identity_values().items()}
|
|
497
|
+
for f in GENERAL_FIELDS:
|
|
498
|
+
value = self._values[f]
|
|
499
|
+
if value is not None:
|
|
500
|
+
out[ATTR_PREFIX + f] = value
|
|
501
|
+
out[ATTR_PREFIX + 'dataset_version_id'] = self.dataset_version_id
|
|
502
|
+
out[ATTR_PREFIX + 'dataset_id'] = self.dataset_id
|
|
503
|
+
return out
|
|
504
|
+
|
|
505
|
+
@classmethod
|
|
506
|
+
def from_attrs(cls, attrs, *, validate_cv: bool = True) -> Metadata:
|
|
507
|
+
"""Rebuild Metadata from ``envlib_``-prefixed attrs (``ds.attrs`` or a dict).
|
|
508
|
+
|
|
509
|
+
Non-envlib keys are ignored. When ``envlib_dataset_version_id`` /
|
|
510
|
+
``envlib_dataset_id`` are present, the hash is re-derived from the
|
|
511
|
+
identity attrs and a mismatch raises (catches attrs hand-edited after
|
|
512
|
+
first registration).
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
attrs: Mapping that may contain ``envlib_``-prefixed keys.
|
|
516
|
+
validate_cv: When False (re-reads of already-registered metadata),
|
|
517
|
+
CV-membership checks are skipped and stored canonical values are
|
|
518
|
+
trusted — the validation-on-change-only rule, so vocabulary
|
|
519
|
+
drift never orphans existing datasets. Grammar-validated fields
|
|
520
|
+
are always re-checked (their rules never drift).
|
|
521
|
+
"""
|
|
522
|
+
meta = cls()
|
|
523
|
+
for field in IDENTITY_FIELDS + GENERAL_FIELDS:
|
|
524
|
+
key = ATTR_PREFIX + field
|
|
525
|
+
if key not in attrs:
|
|
526
|
+
continue
|
|
527
|
+
value = attrs[key]
|
|
528
|
+
if not validate_cv and field in _CV_FIELDS:
|
|
529
|
+
meta._values[field] = value
|
|
530
|
+
else:
|
|
531
|
+
setattr(meta, field, value)
|
|
532
|
+
|
|
533
|
+
stored_id = attrs.get(ATTR_PREFIX + 'dataset_version_id')
|
|
534
|
+
if stored_id is not None and stored_id != meta.dataset_version_id:
|
|
535
|
+
msg = (
|
|
536
|
+
f'Stored envlib_dataset_version_id {stored_id!r} does not match the id derived from the identity '
|
|
537
|
+
f'attrs ({meta.dataset_version_id!r}) — identity attrs were modified after first registration.'
|
|
538
|
+
)
|
|
539
|
+
raise ValidationError(msg)
|
|
540
|
+
stored_dataset_id = attrs.get(ATTR_PREFIX + 'dataset_id')
|
|
541
|
+
if stored_dataset_id is not None and stored_dataset_id != meta.dataset_id:
|
|
542
|
+
msg = (
|
|
543
|
+
f'Stored envlib_dataset_id {stored_dataset_id!r} does not match the id derived from the '
|
|
544
|
+
f'identity attrs ({meta.dataset_id!r}).'
|
|
545
|
+
)
|
|
546
|
+
raise ValidationError(msg)
|
|
547
|
+
return meta
|
|
548
|
+
|
|
549
|
+
def __repr__(self):
|
|
550
|
+
set_fields = {f: v for f, v in self._values.items() if v is not None}
|
|
551
|
+
if self._values['utc_offset'] is not None:
|
|
552
|
+
set_fields['utc_offset'] = self._reduced_utc_offset()
|
|
553
|
+
return f'Metadata({set_fields!r})'
|
|
554
|
+
|
|
555
|
+
def __eq__(self, other):
|
|
556
|
+
if not isinstance(other, Metadata):
|
|
557
|
+
return NotImplemented
|
|
558
|
+
mine = dict(self._values)
|
|
559
|
+
theirs = dict(other._values)
|
|
560
|
+
mine['utc_offset'] = self._reduced_utc_offset()
|
|
561
|
+
theirs['utc_offset'] = other._reduced_utc_offset()
|
|
562
|
+
return mine == theirs
|
|
563
|
+
|
|
564
|
+
def __hash__(self):
|
|
565
|
+
# must mirror __eq__: hash over the REDUCED utc_offset, or two equal
|
|
566
|
+
# objects (e.g. '+12:00' vs '+00:00' at a 1h cadence) hash differently
|
|
567
|
+
# and corrupt sets/dicts. Caveat: Metadata is mutable — mutating an
|
|
568
|
+
# instance after using it as a dict/set member breaks lookup, as with
|
|
569
|
+
# any value-hashed mutable object. Prefer dataset_version_id strings for dedup.
|
|
570
|
+
values = dict(self._values)
|
|
571
|
+
values['utc_offset'] = self._reduced_utc_offset()
|
|
572
|
+
return hash(tuple(sorted((k, str(v)) for k, v in values.items())))
|