|
35 | 35 | from __future__ import annotations |
36 | 36 |
|
37 | 37 | import difflib |
| 38 | +import os |
38 | 39 | import threading |
39 | 40 | import warnings |
40 | 41 | from collections.abc import Iterator, Mapping |
41 | 42 | from dataclasses import dataclass, field, fields, is_dataclass, replace |
42 | 43 | from types import MappingProxyType |
43 | | -from typing import Any, Literal, Self, cast, overload |
| 44 | +from typing import Any, Literal, Self, TypedDict, cast, get_type_hints, overload |
44 | 45 |
|
45 | 46 | from donfig.config_obj import canonical_name |
46 | 47 |
|
@@ -377,66 +378,125 @@ def convert(obj: Any) -> Any: |
377 | 378 | return convert(cfg) # type: ignore[no-any-return] |
378 | 379 |
|
379 | 380 |
|
380 | | -def _flatten_mapping(data: Mapping[str, object], prefix: str = "") -> dict[str, object]: |
381 | | - out: dict[str, object] = {} |
382 | | - for k, v in data.items(): |
383 | | - key = f"{prefix}{k}" if not prefix else f"{prefix}.{k}" |
384 | | - if isinstance(v, Mapping): |
385 | | - out.update(_flatten_mapping(v, key)) |
386 | | - else: |
387 | | - out[key] = v |
388 | | - return out |
| 381 | +def _config_value_type(cfg: ZarrConfig, key: str) -> Any: |
| 382 | + """Resolve a schema type, including names in the open codecs mapping.""" |
| 383 | + node: object = cfg |
| 384 | + segments = key.split(".") |
| 385 | + for index, segment in enumerate(segments): |
| 386 | + if isinstance(node, Mapping): |
| 387 | + if not ".".join(segments[index:]): |
| 388 | + raise KeyError(key) |
| 389 | + return str |
| 390 | + if not is_dataclass(node): |
| 391 | + raise KeyError(key) |
| 392 | + field_name = _resolve_field(node, segment) |
| 393 | + hints = get_type_hints(type(node)) |
| 394 | + if field_name not in hints: |
| 395 | + raise KeyError(key) |
| 396 | + value_type = hints[field_name] |
| 397 | + node = getattr(node, field_name) |
| 398 | + return value_type |
389 | 399 |
|
390 | 400 |
|
391 | 401 | def apply_overrides(cfg: ZarrConfig, overrides: Mapping[str, object]) -> ZarrConfig: |
392 | | - """Apply a flat dotted-key override map to a snapshot. |
| 402 | + """Validate and apply collected leaf values; unknown keys are errors here.""" |
| 403 | + for key, value in overrides.items(): |
| 404 | + value_type = _config_value_type(cfg, key) |
| 405 | + cfg = replace_path(cfg, key, parse_field(value, value_type, key)) |
| 406 | + return cfg |
| 407 | + |
393 | 408 |
|
394 | | - Used exclusively by `build_config` for env/YAML ingest. Unknown keys are |
395 | | - skipped with a warning rather than raising, so a stray environment variable |
396 | | - or extra YAML key never prevents `import zarr` from succeeding. |
| 409 | +# These names belong to other Zarr consumers, not the runtime config schema. |
| 410 | +# Donfig reads discovery controls directly from os.environ; benchmarks read |
| 411 | +# their cache-control flag there as well. Collection never mutates os.environ. |
| 412 | +_ENVIRONMENT_CONTROLS = frozenset({"ZARR_CONFIG", "ZARR_ROOT_CONFIG", "ZARR_BENCHMARK_CLEAR_CACHE"}) |
| 413 | + |
| 414 | + |
| 415 | +class CollectedEnvironment(TypedDict): |
| 416 | + config: dict[str, str] |
| 417 | + controls: dict[str, str] |
| 418 | + |
| 419 | + |
| 420 | +def collect_environment(environment: Mapping[str, str]) -> CollectedEnvironment: |
| 421 | + """Classify recognized ZARR_ names and warn about unknown environment names. |
| 422 | +
|
| 423 | + Config names are derived from the schema, with an open ``codecs.*`` |
| 424 | + namespace. Control values remain raw and are left to their owning consumer. |
| 425 | + Parsing and value validation happen after this stage. |
397 | 426 | """ |
398 | | - for key, value in overrides.items(): |
| 427 | + result: CollectedEnvironment = {"config": {}, "controls": {}} |
| 428 | + schema = make_default_config() |
| 429 | + for name, value in environment.items(): |
| 430 | + if not name.startswith("ZARR_"): |
| 431 | + continue |
| 432 | + if name in _ENVIRONMENT_CONTROLS: |
| 433 | + result["controls"][name] = value |
| 434 | + continue |
| 435 | + key = name[len("ZARR_") :].lower().replace("__", ".") |
399 | 436 | try: |
400 | | - cfg = replace_path(cfg, key, value) |
| 437 | + _config_value_type(schema, key) |
401 | 438 | except KeyError: |
402 | 439 | warnings.warn( |
403 | | - f"Unrecognized zarr config key {key!r} from environment or YAML — ignoring.", |
| 440 | + f"Unrecognized zarr environment variable {name!r} (config key {key!r}) — ignoring.", |
404 | 441 | ZarrUserWarning, |
405 | 442 | stacklevel=2, |
406 | 443 | ) |
407 | | - return cfg |
408 | | - |
| 444 | + else: |
| 445 | + result["config"][name] = value |
| 446 | + return result |
409 | 447 |
|
410 | | -# donfig's env collection also surfaces the `ZARR_CONFIG` / `ZARR_ROOT_CONFIG` |
411 | | -# path directives as if they were config values (keys `config` / `root_config`); |
412 | | -# drop them so they don't trip `apply_overrides`'s unknown-key warning. |
413 | | -_DONFIG_META_KEYS: frozenset[str] = frozenset({"config", "root_config"}) |
414 | 448 |
|
| 449 | +def collect_config() -> dict[str, object]: |
| 450 | + """Collect known config keys through donfig, preserving parsing and precedence. |
415 | 451 |
|
416 | | -def build_config() -> ZarrConfig: |
417 | | - """Build the base snapshot: typed defaults overlaid with donfig's ingest. |
418 | | -
|
419 | | - `donfig` reads `ZARR_*` environment variables and YAML config files from its |
420 | | - standard locations |
421 | | - (https://donfig.readthedocs.io/en/latest/configuration.html#yaml-files) and |
422 | | - merges them into a nested override mapping. That mapping is flattened to |
423 | | - dotted keys and applied on top of the typed defaults. `donfig` owns discovery, |
424 | | - parsing, and precedence; this module owns the typed representation. Unknown |
425 | | - keys are warned about and skipped by `apply_overrides`, so a stray variable or |
426 | | - a version-skewed config file never blocks `import zarr`. |
| 452 | + Environment controls are recognized separately and never become config |
| 453 | + values. Unknown keys in external input are warned about and skipped here; |
| 454 | + recognized values are left for ``create_config`` to validate. |
427 | 455 | """ |
428 | 456 | import donfig |
429 | 457 |
|
430 | 458 | defaults = make_default_config() |
431 | | - # Supplying defaults lets donfig resolve '-'/'_' aliases while merging |
432 | | - # YAML and environment values, before their original spelling is lost. |
433 | | - overrides = _flatten_mapping(donfig.Config("zarr", defaults=[to_nested_dict(defaults)]).config) |
434 | | - overrides = { |
435 | | - key: value |
436 | | - for key, value in overrides.items() |
437 | | - if key.split(".", 1)[0] not in _DONFIG_META_KEYS |
438 | | - } |
439 | | - return apply_overrides(defaults, overrides) |
| 459 | + environment = collect_environment(os.environ) |
| 460 | + # Donfig merges external sources; construction supplies typed defaults. |
| 461 | + # Keeping defaults out also lets invalid leaf values reach our validator |
| 462 | + # instead of failing inside donfig's recursive merge against a scalar. |
| 463 | + reader = donfig.Config("zarr", env=environment["config"]) |
| 464 | + return _collect_config_values(defaults, reader.config) |
| 465 | + |
| 466 | + |
| 467 | +def _collect_config_values( |
| 468 | + schema: ZarrConfig, data: Mapping[str, object], prefix: str = "" |
| 469 | +) -> dict[str, object]: |
| 470 | + """Flatten recognized namespaces, preserving malformed leaves for validation.""" |
| 471 | + collected: dict[str, object] = {} |
| 472 | + for name, value in data.items(): |
| 473 | + key = f"{prefix}.{name}" if prefix else name |
| 474 | + try: |
| 475 | + value_type = _config_value_type(schema, key) |
| 476 | + except KeyError: |
| 477 | + warnings.warn( |
| 478 | + f"Unrecognized zarr config key {key!r} from environment or YAML — ignoring.", |
| 479 | + ZarrUserWarning, |
| 480 | + stacklevel=2, |
| 481 | + ) |
| 482 | + else: |
| 483 | + if isinstance(value, Mapping) and ( |
| 484 | + is_dataclass(value_type) or key == "codecs" or key.startswith("codecs.") |
| 485 | + ): |
| 486 | + collected.update(_collect_config_values(schema, value, key)) |
| 487 | + else: |
| 488 | + collected[key] = value |
| 489 | + return collected |
| 490 | + |
| 491 | + |
| 492 | +def create_config(overrides: Mapping[str, object]) -> ZarrConfig: |
| 493 | + """Create a typed config from collected values, without reading external state.""" |
| 494 | + return apply_overrides(make_default_config(), overrides) |
| 495 | + |
| 496 | + |
| 497 | +def build_config() -> ZarrConfig: |
| 498 | + """Collect external configuration, then validate and construct the snapshot.""" |
| 499 | + return create_config(collect_config()) |
440 | 500 |
|
441 | 501 |
|
442 | 502 | _MISSING = object() |
@@ -611,8 +671,8 @@ def get(self, key: str, default: object = _MISSING) -> Any: |
611 | 671 | # |
612 | 672 | # NOTE: `set` accepts `Mapping[str, Any]`, so — unlike `get`, which is fully |
613 | 673 | # typed via per-key overloads — it does NOT statically validate values: |
614 | | - # `config.set({"array.order": "Q"})` is not a type error; it is caught at |
615 | | - # runtime instead. This is a deliberate, documented limitation. |
| 674 | + # `config.set({"array.order": "Q"})` is not a type error; invalid values |
| 675 | + # surface at use sites. External values are validated by `create_config`. |
616 | 676 | # |
617 | 677 | # Static value typing would require an *open* TypedDict — declared structured |
618 | 678 | # keys validated by type, PLUS arbitrary `codecs.<name>` string keys allowed |
@@ -643,8 +703,8 @@ def set(self, updates: Mapping[str, object] | None = None, **kwargs: object) -> |
643 | 703 | does **not** validate *values*: `config.set({"array.order": "Q"})` is |
644 | 704 | accepted, and the invalid value surfaces later at its use site rather |
645 | 705 | than here. Static value typing is prevented by the open `codecs.*` |
646 | | - namespace (see the implementation comment above); runtime value |
647 | | - validation is planned via the unified `parse_json` checker (gh-3285). |
| 706 | + namespace (see the implementation comment above). Values loaded from |
| 707 | + environment variables and YAML are validated by `create_config`. |
648 | 708 | """ |
649 | 709 | all_updates: dict[str, object] = {} |
650 | 710 | if updates: |
|
0 commit comments