Skip to content

Commit 19a4325

Browse files
committed
Add type-aware config
1 parent 0b546e2 commit 19a4325

4 files changed

Lines changed: 680 additions & 13 deletions

File tree

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
> This version is **not released yet** and is under active development.
77
88
- Fix argument names (e.g. `keys`) being collected as option keywords, which caused partial highlighting of hyphenated options like `--list-keys`.
9+
- Add type-aware flattening to `flatten_config_keys()` and `normalize_config_keys()`. Both accept an `opaque_keys` parameter: a `frozenset` of fully-qualified key names where recursion stops, preserving dict values whose keys are data (e.g. GitHub Actions matrix axis names) rather than config structure.
10+
- Add field metadata support for dataclass schemas. Fields may carry `click_extra.config_path` (a dotted TOML path for explicit extraction) and `click_extra.normalize_keys` (set to `False` to skip key normalization on the extracted value).
11+
- Add nested dataclass support to `config_schema`. Fields whose type is another dataclass are recursively instantiated with the same normalize/flatten/opaque logic. Combined with `config_path` and `normalize_keys` metadata, this allows opaque sub-sections (e.g. GitHub Actions matrix config) to be modeled as typed nested dataclasses.
912

1013
## [`7.9.0` (2026-03-31)](https://github.com/kdeldycke/click-extra/compare/v7.8.0...v7.9.0)
1114

click_extra/config.py

Lines changed: 232 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,11 @@ def _expand_dotted_keys(conf: dict, strict: bool = False) -> dict:
372372
return expanded
373373

374374

375-
def normalize_config_keys(conf: dict[str, Any]) -> dict[str, Any]:
375+
def normalize_config_keys(
376+
conf: dict[str, Any],
377+
opaque_keys: frozenset[str] = frozenset(),
378+
_prefix: str = "",
379+
) -> dict[str, Any]:
376380
"""Normalize configuration keys to valid Python identifiers.
377381
378382
Recursively replaces hyphens with underscores in all dict keys, using the
@@ -385,6 +389,14 @@ def normalize_config_keys(conf: dict[str, Any]) -> dict[str, Any]:
385389
JSON all commonly use kebab-case) and Python identifiers. Works with all
386390
configuration formats supported by ``ConfigOption``.
387391
392+
:param opaque_keys: Fully-qualified key names (using ``"_"`` as
393+
separator) where recursion stops. The key itself is still
394+
normalized, but its dict value is kept as-is. Used in tandem
395+
with ``flatten_config_keys``'s ``opaque_keys`` to protect data
396+
dicts (e.g. GitHub Actions matrix axes) from normalization.
397+
:param _prefix: Internal parameter for tracking the accumulated key
398+
path during recursion. Callers should not set this.
399+
388400
.. todo::
389401
Propose upstream to Click to extract the inline ``name.replace("-", "_")``
390402
into a private ``_normalize_param_name`` helper, so downstream projects
@@ -393,15 +405,18 @@ def normalize_config_keys(conf: dict[str, Any]) -> dict[str, Any]:
393405
normalized: dict[str, Any] = {}
394406
for key, value in conf.items():
395407
py_key = key.replace("-", "_")
396-
if isinstance(value, dict):
397-
value = normalize_config_keys(value)
408+
full_key = f"{_prefix}_{py_key}" if _prefix else py_key
409+
if isinstance(value, dict) and full_key not in opaque_keys:
410+
value = normalize_config_keys(value, opaque_keys, full_key)
398411
normalized[py_key] = value
399412
return normalized
400413

401414

402415
def flatten_config_keys(
403416
conf: dict[str, Any],
404417
sep: str = "_",
418+
opaque_keys: frozenset[str] = frozenset(),
419+
_prefix: str = "",
405420
) -> dict[str, Any]:
406421
"""Flatten nested dicts into a single level by joining keys with a separator.
407422
@@ -419,14 +434,25 @@ def flatten_config_keys(
419434
:param sep: Separator used to join parent and child keys. Defaults to
420435
``"_"`` which produces valid Python identifiers when combined with
421436
`normalize_config_keys`.
437+
:param opaque_keys: Fully-qualified key names where flattening stops.
438+
When the accumulated key matches an entry in this set, the dict
439+
value is kept as-is instead of being recursively flattened. This
440+
is useful for fields typed as ``dict[str, X]`` where the dict keys
441+
are data (e.g. GitHub Actions matrix axis names), not config
442+
structure.
443+
:param _prefix: Internal parameter for tracking the accumulated key
444+
path during recursion. Callers should not set this.
422445
"""
423446
items: dict[str, Any] = {}
424447
for key, value in conf.items():
425-
if isinstance(value, dict):
426-
for sub_key, sub_value in flatten_config_keys(value, sep).items():
427-
items[f"{key}{sep}{sub_key}"] = sub_value
448+
full_key = f"{_prefix}{sep}{key}" if _prefix else key
449+
if isinstance(value, dict) and full_key not in opaque_keys:
450+
for sub_key, sub_value in flatten_config_keys(
451+
value, sep, opaque_keys, full_key
452+
).items():
453+
items[sub_key] = sub_value
428454
else:
429-
items[key] = value
455+
items[full_key] = value
430456
return items
431457

432458

@@ -444,6 +470,102 @@ def get_tool_config(ctx: click.Context | None = None) -> Any:
444470
return ctx.find_root().meta.get("click_extra.tool_config")
445471

446472

473+
def _safe_get_type_hints(cls: type) -> dict[str, Any]:
474+
"""Resolve type hints for a class, returning empty dict on failure.
475+
476+
Wraps ``typing.get_type_hints`` to handle cases where annotations
477+
reference types that are not importable in the current context (e.g.
478+
forward references to types only available under ``TYPE_CHECKING``).
479+
480+
When the initial resolution fails (common for locally-defined classes
481+
whose annotations are stringified by ``from __future__ import
482+
annotations``), a second attempt is made with a ``localns`` built from
483+
``default_factory`` values on the class's dataclass fields. This
484+
allows nested dataclass types like ``sub: SubConfig =
485+
field(default_factory=SubConfig)`` to be resolved even when
486+
``SubConfig`` is not in the module's global scope.
487+
"""
488+
from typing import get_type_hints
489+
490+
try:
491+
return get_type_hints(cls)
492+
except Exception:
493+
pass
494+
495+
# Fallback: build localns from default_factory class references.
496+
from dataclasses import MISSING
497+
from dataclasses import fields as dc_fields
498+
499+
localns: dict[str, Any] = {}
500+
try:
501+
for f in dc_fields(cls): # type: ignore[arg-type]
502+
factory = f.default_factory
503+
if factory is not MISSING and isinstance(factory, type):
504+
localns[factory.__name__] = factory
505+
except Exception:
506+
pass
507+
508+
if localns:
509+
try:
510+
module = sys.modules.get(cls.__module__, None)
511+
globalns = getattr(module, "__dict__", {}) if module else {}
512+
return get_type_hints(
513+
cls, globalns=globalns, localns=localns,
514+
)
515+
except Exception:
516+
pass
517+
518+
return {}
519+
520+
521+
def _is_mapping_type(hint: object) -> bool:
522+
"""Check if a resolved type hint is a ``dict`` or ``Mapping``."""
523+
if hint is None:
524+
return False
525+
from collections.abc import Mapping
526+
from typing import get_origin
527+
528+
origin = get_origin(hint)
529+
return origin is dict or origin is Mapping
530+
531+
532+
def _is_dataclass_type(hint: object) -> bool:
533+
"""Check if a resolved type hint is a dataclass."""
534+
return hint is not None and hasattr(hint, "__dataclass_fields__")
535+
536+
537+
def _extract_dotted(conf: dict[str, Any], path: str) -> tuple[Any, bool]:
538+
"""Extract a value at a dotted path from a nested dict.
539+
540+
:param conf: Nested dict to search.
541+
:param path: Dotted path (e.g. ``"test-matrix.replace"``).
542+
:return: ``(value, True)`` if found, ``(None, False)`` otherwise.
543+
"""
544+
current: Any = conf
545+
for part in path.split("."):
546+
if not isinstance(current, dict) or part not in current:
547+
return None, False
548+
current = current[part]
549+
return current, True
550+
551+
552+
def _remove_dotted(conf: dict[str, Any], path: str) -> dict[str, Any]:
553+
"""Remove a value at a dotted path, returning a modified shallow copy.
554+
555+
Parent dicts that become empty after removal are also pruned.
556+
"""
557+
parts = path.split(".")
558+
if len(parts) == 1:
559+
return {k: v for k, v in conf.items() if k != parts[0]}
560+
top = parts[0]
561+
if top not in conf or not isinstance(conf[top], dict):
562+
return conf
563+
sub = _remove_dotted(conf[top], ".".join(parts[1:]))
564+
if not sub:
565+
return {k: v for k, v in conf.items() if k != top}
566+
return {**conf, top: sub}
567+
568+
447569
class Sentinel(Enum):
448570
"""Enum used to define sentinel values.
449571
@@ -1341,20 +1463,42 @@ def _make_schema_callable(
13411463
schema: type | Callable[[dict[str, Any]], Any] | None,
13421464
*,
13431465
strict: bool = False,
1466+
normalize: bool = True,
13441467
) -> Callable[[dict[str, Any]], Any] | None:
13451468
"""Wrap a schema type into a callable that accepts a raw config dict.
13461469
13471470
- **Dataclass types** (detected via ``__dataclass_fields__``) are
13481471
auto-wrapped: keys are normalized (hyphens → underscores), nested
13491472
dicts are flattened, and the result is filtered to known fields
1350-
before instantiation.
1473+
before instantiation. Three schema-aware features refine this
1474+
process:
1475+
1476+
1. **Type-aware flattening.** Fields typed as ``dict[str, X]``
1477+
are treated as opaque: ``flatten_config_keys`` stops at their
1478+
boundary so the dict value is kept intact.
1479+
1480+
2. **Field metadata.** Dataclass fields may carry
1481+
``click_extra.config_path`` (a dotted TOML path like
1482+
``"test-matrix.replace"``) and ``click_extra.normalize_keys``
1483+
(``False`` to skip key normalization on the extracted value).
1484+
Fields with an explicit path are extracted from the raw config
1485+
before normalization and flattening.
1486+
1487+
3. **Nested dataclass support.** Fields whose resolved type is
1488+
itself a dataclass are recursively processed with the same
1489+
logic.
1490+
13511491
- **Any other callable** is returned as-is. The caller is responsible
13521492
for key normalization if needed.
13531493
- ``None`` returns ``None``.
13541494
13551495
:param strict: If ``True``, raise ``ValueError`` when the config
13561496
contains keys that do not match any dataclass field (after
13571497
normalization and flattening).
1498+
:param normalize: If ``False``, skip ``normalize_config_keys`` on
1499+
the remaining config dict. Used internally when recursing into
1500+
nested dataclasses whose parent opted out of normalization via
1501+
``click_extra.normalize_keys = False``.
13581502
"""
13591503
if schema is None:
13601504
return None
@@ -1363,20 +1507,95 @@ def _make_schema_callable(
13631507
from dataclasses import fields as dc_fields
13641508

13651509
def _from_dataclass(raw: dict[str, Any]) -> Any:
1366-
normalized = normalize_config_keys(raw)
1367-
flattened = flatten_config_keys(normalized)
1368-
known = {f.name for f in dc_fields(schema)} # type: ignore[arg-type]
1510+
all_fields = dc_fields(schema) # type: ignore[arg-type]
1511+
known = {f.name for f in all_fields}
1512+
hints = _safe_get_type_hints(schema) # type: ignore[arg-type]
1513+
1514+
# --- Phase 1: extract fields with explicit config_path. ---
1515+
result: dict[str, Any] = {}
1516+
remaining = dict(raw)
1517+
1518+
for f in all_fields:
1519+
path = f.metadata.get("click_extra.config_path")
1520+
if path is None:
1521+
continue
1522+
do_normalize = f.metadata.get(
1523+
"click_extra.normalize_keys", True,
1524+
)
1525+
value, found = _extract_dotted(remaining, path)
1526+
if not found:
1527+
continue
1528+
remaining = _remove_dotted(remaining, path)
1529+
1530+
hint = hints.get(f.name)
1531+
if isinstance(value, dict) and _is_dataclass_type(hint):
1532+
# Nested dataclass: recurse.
1533+
sub = ConfigOption._make_schema_callable(
1534+
hint,
1535+
strict=strict,
1536+
normalize=do_normalize,
1537+
)
1538+
value = sub(value) if sub else value
1539+
elif isinstance(value, dict) and do_normalize:
1540+
value = normalize_config_keys(value)
1541+
result[f.name] = value
1542+
1543+
# --- Phase 2: type-aware normalize + flatten. ---
1544+
# Detect opaque fields: dict-typed or nested-dataclass-typed.
1545+
opaque = frozenset(
1546+
f.name
1547+
for f in all_fields
1548+
if f.name not in result
1549+
and (
1550+
_is_mapping_type(hints.get(f.name))
1551+
or _is_dataclass_type(hints.get(f.name))
1552+
)
1553+
)
1554+
1555+
normalized = (
1556+
normalize_config_keys(remaining, opaque_keys=opaque)
1557+
if normalize
1558+
else remaining
1559+
)
1560+
flattened = flatten_config_keys(
1561+
normalized, opaque_keys=opaque,
1562+
)
1563+
1564+
# --- Phase 3: recursively process nested dataclasses. ---
1565+
for f in all_fields:
1566+
if f.name in result:
1567+
continue
1568+
hint = hints.get(f.name)
1569+
if (
1570+
_is_dataclass_type(hint)
1571+
and f.name in flattened
1572+
and isinstance(flattened[f.name], dict)
1573+
):
1574+
sub = ConfigOption._make_schema_callable(
1575+
hint, strict=strict,
1576+
)
1577+
flattened[f.name] = (
1578+
sub(flattened[f.name]) if sub else flattened[f.name]
1579+
)
1580+
1581+
# --- Phase 4: merge and validate. ---
1582+
for k, v in flattened.items():
1583+
if k in known and k not in result:
1584+
result[k] = v
1585+
13691586
if strict:
1370-
unknown = sorted(set(flattened) - known)
1587+
all_keys = set(result) | set(flattened)
1588+
unknown = sorted(all_keys - known)
13711589
if unknown:
13721590
msg = (
13731591
f"Unknown configuration option(s): "
13741592
f"{', '.join(unknown)}. "
13751593
f"Valid options: {', '.join(sorted(known))}"
13761594
)
13771595
raise ValueError(msg)
1596+
13781597
return schema( # type: ignore[call-arg]
1379-
**{k: v for k, v in flattened.items() if k in known}
1598+
**{k: v for k, v in result.items() if k in known}
13801599
)
13811600

13821601
return _from_dataclass

0 commit comments

Comments
 (0)