@@ -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
402415def 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+
447569class 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