3333from collections .abc import Mapping
3434from dataclasses import dataclass , field
3535from functools import lru_cache
36+ from types import MappingProxyType
3637from typing import Any , Dict , Iterator , Optional , Set , Tuple
3738
3839from .schema import ParamDef , ParamType
@@ -48,6 +49,33 @@ class RegistryFrozenError(RuntimeError):
4849_INDEXED_RE = re .compile (r"^([a-zA-Z_]\w*)\((\d+)\)%(.+)$" )
4950
5051
52+ def _resolve_family (
53+ name : str , families : Dict [str , "IndexedFamily" ]
54+ ) -> Optional [Tuple [ParamType , Set [str ]]]:
55+ """
56+ Resolve a parameter name against indexed families.
57+
58+ Returns (ParamType, tags) if the name matches a registered family
59+ attribute, or None otherwise. This is the single implementation of
60+ family pattern-matching used by both _FamilyAwareMapping and
61+ ParamRegistry.
62+ """
63+ m = _INDEXED_RE .match (name )
64+ if m is None :
65+ return None
66+ base , idx_str , attr = m .groups ()
67+ fam = families .get (base )
68+ if fam is None :
69+ return None
70+ idx = int (idx_str )
71+ if idx < 1 :
72+ return None
73+ if fam .max_index is not None and idx > fam .max_index :
74+ return None
75+ entry = fam .attrs .get (attr )
76+ return entry if entry is not None else None
77+
78+
5179@dataclass
5280class IndexedFamily :
5381 """
@@ -70,6 +98,12 @@ class IndexedFamily:
7098 tags : Set [str ] = field (default_factory = set )
7199 max_index : Optional [int ] = None
72100
101+ def __post_init__ (self ):
102+ if not self .base_name or not re .match (r"^[a-zA-Z_]\w*$" , self .base_name ):
103+ raise ValueError (f"Invalid base_name: { self .base_name !r} " )
104+ if self .max_index is not None and self .max_index < 1 :
105+ raise ValueError (f"max_index must be >= 1 or None, got { self .max_index } " )
106+
73107
74108class _FamilyAwareMapping (Mapping ):
75109 """
@@ -82,6 +116,9 @@ class _FamilyAwareMapping(Mapping):
82116 For iteration (items/keys/values/len), only scalar params and one
83117 representative example per family attribute (index=1) are yielded.
84118 This keeps iteration bounded regardless of max_index.
119+
120+ keys(), items(), values() are inherited from collections.abc.Mapping
121+ and return proper KeysView/ItemsView/ValuesView objects.
85122 """
86123
87124 __slots__ = ("_scalars" , "_families" , "_examples" )
@@ -100,67 +137,41 @@ def __init__(
100137 key = f"{ fam .base_name } (1)%{ attr_name } "
101138 self ._examples [key ] = ParamDef (name = key , param_type = ptype , tags = set (tags ))
102139
103- def _resolve_family (self , name : str ) -> Optional [ParamDef ]:
104- """Try to resolve a name against indexed families. Returns ParamDef or None."""
105- m = _INDEXED_RE .match (name )
106- if m is None :
107- return None
108- base , idx_str , attr = m .groups ()
109- fam = self ._families .get (base )
110- if fam is None :
111- return None
112- idx = int (idx_str )
113- if idx < 1 :
114- return None
115- if fam .max_index is not None and idx > fam .max_index :
116- return None
117- entry = fam .attrs .get (attr )
118- if entry is None :
140+ def _make_param_def (self , name : str ) -> Optional [ParamDef ]:
141+ """Build a ParamDef from a family match, or return None."""
142+ result = _resolve_family (name , self ._families )
143+ if result is None :
119144 return None
120- ptype , tags = entry
145+ ptype , tags = result
121146 return ParamDef (name = name , param_type = ptype , tags = set (tags ))
122147
123148 def __getitem__ (self , key : str ) -> ParamDef :
124149 try :
125150 return self ._scalars [key ]
126151 except KeyError :
127152 pass
128- result = self ._resolve_family (key )
153+ result = self ._make_param_def (key )
129154 if result is not None :
130155 return result
131156 raise KeyError (key )
132157
133158 def __contains__ (self , key : object ) -> bool :
159+ # Note: this intentionally deviates from the standard Mapping contract.
160+ # `key in self` may be True for family params (e.g., patch_ib(500)%geometry)
161+ # that do NOT appear in iter(self) (which only yields index=1 examples).
134162 if key in self ._scalars :
135163 return True
136164 if isinstance (key , str ):
137- return self . _resolve_family (key ) is not None
165+ return _resolve_family (key , self . _families ) is not None
138166 return False
139167
140- def get (self , key : str , default = None ):
141- if key in self ._scalars :
142- return self ._scalars [key ]
143- result = self ._resolve_family (key )
144- return result if result is not None else default
145-
146168 def __iter__ (self ) -> Iterator [str ]:
147169 yield from self ._scalars
148170 yield from self ._examples
149171
150172 def __len__ (self ) -> int :
151173 return len (self ._scalars ) + len (self ._examples )
152174
153- def keys (self ):
154- return set (self ._scalars .keys ()) | set (self ._examples .keys ())
155-
156- def items (self ):
157- yield from self ._scalars .items ()
158- yield from self ._examples .items ()
159-
160- def values (self ):
161- yield from self ._scalars .values ()
162- yield from self ._examples .values ()
163-
164175
165176class ParamRegistry :
166177 """
@@ -257,9 +268,9 @@ def register_family(self, family: IndexedFamily) -> None:
257268 self ._by_tag [tag ].add (example )
258269
259270 @property
260- def families (self ) -> Dict [str , IndexedFamily ]:
261- """Get all registered indexed families."""
262- return self ._families
271+ def families (self ) -> Mapping [str , IndexedFamily ]:
272+ """Get all registered indexed families (read-only view) ."""
273+ return MappingProxyType ( self ._families )
263274
264275 @property
265276 def all_params (self ) -> Mapping [str , ParamDef ]:
@@ -272,10 +283,17 @@ def all_params(self) -> Mapping[str, ParamDef]:
272283 - Iteration: yields scalar params + one example per family attr
273284
274285 If the registry is frozen, returns an immutable view.
275- If not frozen, returns the internal dict (no family support).
286+ If not frozen and no families are registered, returns the internal dict.
287+ If not frozen but families exist, raises RuntimeError (the plain dict
288+ cannot resolve family params — call freeze() first).
276289 """
277290 if self ._frozen and self ._all_params_view is not None :
278291 return self ._all_params_view
292+ if self ._families :
293+ raise RuntimeError (
294+ "Cannot access all_params before freeze() when indexed families "
295+ "are registered. Call freeze() first."
296+ )
279297 return self ._params
280298
281299 def is_known_param (self , name : str ) -> bool :
@@ -287,20 +305,7 @@ def is_known_param(self, name: str) -> bool:
287305 """
288306 if name in self ._params :
289307 return True
290- # Check indexed families via pattern matching
291- m = _INDEXED_RE .match (name )
292- if m is None :
293- return False
294- base , idx_str , attr = m .groups ()
295- fam = self ._families .get (base )
296- if fam is None :
297- return False
298- idx = int (idx_str )
299- if idx < 1 :
300- return False
301- if fam .max_index is not None and idx > fam .max_index :
302- return False
303- return attr in fam .attrs
308+ return _resolve_family (name , self ._families ) is not None
304309
305310 def get_param_def (self , name : str ) -> Optional [ParamDef ]:
306311 """
@@ -310,22 +315,10 @@ def get_param_def(self, name: str) -> Optional[ParamDef]:
310315 """
311316 if name in self ._params :
312317 return self ._params [name ]
313- m = _INDEXED_RE .match (name )
314- if m is None :
315- return None
316- base , idx_str , attr = m .groups ()
317- fam = self ._families .get (base )
318- if fam is None :
319- return None
320- idx = int (idx_str )
321- if idx < 1 :
322- return None
323- if fam .max_index is not None and idx > fam .max_index :
324- return None
325- entry = fam .attrs .get (attr )
326- if entry is None :
318+ result = _resolve_family (name , self ._families )
319+ if result is None :
327320 return None
328- ptype , tags = entry
321+ ptype , tags = result
329322 return ParamDef (name = name , param_type = ptype , tags = set (tags ))
330323
331324 def get_params_by_tag (self , tag : str ) -> Dict [str , ParamDef ]:
@@ -390,7 +383,7 @@ def get_json_schema(self) -> Dict[str, Any]:
390383 # Escape the attr name but replace sub-indices with \(\d+\)
391384 attr_pattern = re .sub (r"\(\d+\)" , "__IDX__" , attr_name )
392385 attr_pattern = re .escape (attr_pattern ).replace ("__IDX__" , r"\(\d+\)" )
393- pattern = f"^{ base_esc } \\ (\\ d+ \\ )%{ attr_pattern } $"
386+ pattern = f"^{ base_esc } \\ ([1-9] \\ d* \\ )%{ attr_pattern } $"
394387 if pattern not in pattern_props :
395388 pattern_props [pattern ] = ptype .json_schema
396389
0 commit comments