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