Skip to content

Commit f462ae4

Browse files
committed
refactor: separate config collection from construction
Classify environment controls separately from schema-derived config names. Warn about unknown external names during collection, and validate keys and values when constructing the typed configuration. Remove the benchmark warning filter and cover both stages with regression and schema-derived property tests. Assisted-by: Codex:GPT-6
1 parent 8941999 commit f462ae4

8 files changed

Lines changed: 311 additions & 68 deletions

File tree

changes/4101.changed.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ Gave `zarr.config` a statically-typed, dataclass-backed representation.
22
`zarr.config` now provides precise static types for attribute access
33
(`zarr.config.array.order`) and for the dotted-string API
44
(`zarr.config.get("array.order")`), and validates configuration *keys* at
5-
runtime with suggestions for typos. (Value validation is not yet performed; an
6-
out-of-range value such as `config.set({"array.order": "Q"})` is accepted and
7-
surfaces at its use site.) `donfig` is retained as the reader for environment
5+
runtime with suggestions for typos. External values are validated when the
6+
configuration is created; for example, `ZARR_ARRAY__ORDER=Q` raises an error.
7+
Programmatic `config.set` continues to defer value validation to use sites.
8+
`donfig` is retained as the reader for environment
89
variables (`ZARR_FOO__BAR`) and YAML config files, so their locations and
910
precedence are unchanged. The string API, `config.set` (permanent and as a
1011
context manager, including cross-thread visibility of permanent overrides),
@@ -15,6 +16,12 @@ Hyphenated and underscored key spellings remain interchangeable, including in
1516
YAML files, and nested keyword overrides such as `config.set(array__order="F")`
1617
remain supported. The configuration manager can still be deep-copied.
1718

19+
Environment collection recognizes config names separately from controls such
20+
as `ZARR_CONFIG`, `ZARR_ROOT_CONFIG`, and `ZARR_BENCHMARK_CLEAR_CACHE`. Controls
21+
never become configuration fields. Unknown environment names and YAML keys
22+
warn and are skipped during collection; construction rejects unknown keys and
23+
invalid value types. Custom names remain supported in the open `codecs` namespace.
24+
1825
Note: `zarr.config.defaults` now returns a nested `dict` directly; donfig
1926
previously returned a one-element `list[dict]`, so callers that used
2027
`config.defaults[0]` must be updated to use `config.defaults`. Subtree reads

docs/user-guide/config.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,26 @@ Alternatively, configuration values can be set using environment variables.
3131
The variable name uses a `ZARR_` prefix, with `__` to denote nesting, e.g.
3232
`ZARR_ARRAY__ORDER=F`.
3333

34+
External configuration is processed in two stages. Collection recognizes names
35+
from the configuration schema and a separate set of environment controls:
36+
`ZARR_CONFIG` and `ZARR_ROOT_CONFIG` control file discovery, and
37+
`ZARR_BENCHMARK_CLEAR_CACHE` controls benchmark cache clearing. These controls
38+
are left to their consumers and never become configuration fields. Unrecognized
39+
`ZARR_*` names produce a warning and are ignored. The `codecs` namespace is open,
40+
so environment variables can also select implementations for custom codec names.
41+
42+
Config creation then validates the collected values against the schema and
43+
applies them to the defaults. Invalid values raise an error identifying the
44+
field, for example `ZARR_ARRAY__ORDER=Q`. Programmatic `config.set()` validates
45+
keys but continues to defer value validation to the setting's use site.
46+
3447
The configuration can also be read from YAML files. Environment variables and
3548
YAML files are read by [`donfig`](https://donfig.readthedocs.io/), so zarr uses
3649
donfig's [standard search
3750
locations](https://donfig.readthedocs.io/en/latest/configuration.html#yaml-files),
3851
in increasing order of precedence:
3952

40-
- `/etc/zarr/` (override the `/etc` prefix with the `ZARR_ROOT_CONFIG`
53+
- `/etc/zarr/` (override this directory with the `ZARR_ROOT_CONFIG`
4154
environment variable),
4255
- `<sys.prefix>/etc/zarr/` and each entry in Python's `site.PREFIXES` (e.g.
4356
inside a virtual environment),
@@ -46,8 +59,10 @@ in increasing order of precedence:
4659
single file or a directory and takes precedence over all of the above.
4760

4861
Place a `zarr.yaml` in any of these directories, or point `ZARR_CONFIG` at a
49-
specific file. Values read from these files are validated against zarr's typed
50-
configuration schema; unrecognized keys are ignored with a warning.
62+
specific file. YAML files contain configuration fields only: environment
63+
controls such as `benchmark_clear_cache` are not valid YAML config keys.
64+
Unrecognized keys are ignored with a warning during collection; recognized
65+
values are checked when the typed configuration is created.
5166

5267
Configuration options include the following:
5368

src/zarr/core/config.py

Lines changed: 107 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,13 @@
3535
from __future__ import annotations
3636

3737
import difflib
38+
import os
3839
import threading
3940
import warnings
4041
from collections.abc import Iterator, Mapping
4142
from dataclasses import dataclass, field, fields, is_dataclass, replace
4243
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
4445

4546
from donfig.config_obj import canonical_name
4647

@@ -377,66 +378,125 @@ def convert(obj: Any) -> Any:
377378
return convert(cfg) # type: ignore[no-any-return]
378379

379380

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
389399

390400

391401
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+
393408

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.
397426
"""
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("__", ".")
399436
try:
400-
cfg = replace_path(cfg, key, value)
437+
_config_value_type(schema, key)
401438
except KeyError:
402439
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.",
404441
ZarrUserWarning,
405442
stacklevel=2,
406443
)
407-
return cfg
408-
444+
else:
445+
result["config"][name] = value
446+
return result
409447

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"})
414448

449+
def collect_config() -> dict[str, object]:
450+
"""Collect known config keys through donfig, preserving parsing and precedence.
415451
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.
427455
"""
428456
import donfig
429457

430458
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())
440500

441501

442502
_MISSING = object()
@@ -611,8 +671,8 @@ def get(self, key: str, default: object = _MISSING) -> Any:
611671
#
612672
# NOTE: `set` accepts `Mapping[str, Any]`, so — unlike `get`, which is fully
613673
# 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`.
616676
#
617677
# Static value typing would require an *open* TypedDict — declared structured
618678
# 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) ->
643703
does **not** validate *values*: `config.set({"array.order": "Q"})` is
644704
accepted, and the invalid value surfaces later at its use site rather
645705
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`.
648708
"""
649709
all_updates: dict[str, object] = {}
650710
if updates:

tests/benchmarks/conftest.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,6 @@
99

1010

1111
def pytest_configure(config: pytest.Config) -> None:
12-
# CI's cache-control flag is consumed by the benchmark runner, but shares
13-
# the library's ZARR_ prefix. Config resets legitimately warn about it;
14-
# allow that specific warning without hiding unknown configuration keys.
15-
config.addinivalue_line(
16-
"filterwarnings",
17-
"ignore:^Unrecognized zarr config key 'benchmark_clear_cache' "
18-
"from environment or YAML — ignoring\\.$:zarr.errors.ZarrUserWarning",
19-
)
2012
config.addinivalue_line(
2113
"filterwarnings",
2214
"ignore:Failed to set executed benchmark:RuntimeWarning",

tests/benchmarks/test_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Keep benchmark control flags compatible with strict configuration warnings."""
1+
"""Benchmark controls are recognized during collection; typos still warn."""
22

33
import pytest
44

0 commit comments

Comments
 (0)