lsst-pex-config 27.2024.3500__py3-none-any.whl → 29.2025.4900__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. lsst/pex/config/callStack.py +2 -2
  2. lsst/pex/config/comparison.py +11 -7
  3. lsst/pex/config/config.py +58 -20
  4. lsst/pex/config/configChoiceField.py +43 -28
  5. lsst/pex/config/configDictField.py +65 -10
  6. lsst/pex/config/configField.py +7 -0
  7. lsst/pex/config/configurableActions/_configurableAction.py +6 -1
  8. lsst/pex/config/configurableActions/_configurableActionStructField.py +19 -6
  9. lsst/pex/config/configurableActions/tests.py +1 -1
  10. lsst/pex/config/configurableField.py +27 -10
  11. lsst/pex/config/dictField.py +56 -27
  12. lsst/pex/config/history.py +14 -11
  13. lsst/pex/config/listField.py +46 -27
  14. lsst/pex/config/registry.py +3 -3
  15. lsst/pex/config/version.py +1 -1
  16. lsst/pex/config/wrap.py +6 -4
  17. {lsst_pex_config-27.2024.3500.dist-info → lsst_pex_config-29.2025.4900.dist-info}/METADATA +9 -9
  18. lsst_pex_config-29.2025.4900.dist-info/RECORD +35 -0
  19. {lsst_pex_config-27.2024.3500.dist-info → lsst_pex_config-29.2025.4900.dist-info}/WHEEL +1 -1
  20. lsst_pex_config-27.2024.3500.dist-info/RECORD +0 -35
  21. {lsst_pex_config-27.2024.3500.dist-info → lsst_pex_config-29.2025.4900.dist-info/licenses}/COPYRIGHT +0 -0
  22. {lsst_pex_config-27.2024.3500.dist-info → lsst_pex_config-29.2025.4900.dist-info/licenses}/LICENSE +0 -0
  23. {lsst_pex_config-27.2024.3500.dist-info → lsst_pex_config-29.2025.4900.dist-info/licenses}/bsd_license.txt +0 -0
  24. {lsst_pex_config-27.2024.3500.dist-info → lsst_pex_config-29.2025.4900.dist-info/licenses}/gpl-v3.0.txt +0 -0
  25. {lsst_pex_config-27.2024.3500.dist-info → lsst_pex_config-29.2025.4900.dist-info}/top_level.txt +0 -0
  26. {lsst_pex_config-27.2024.3500.dist-info → lsst_pex_config-29.2025.4900.dist-info}/zip-safe +0 -0
@@ -25,7 +25,7 @@
25
25
  # You should have received a copy of the GNU General Public License
26
26
  # along with this program. If not, see <http://www.gnu.org/licenses/>.
27
27
 
28
- __all__ = ["getCallerFrame", "getStackFrame", "StackFrame", "getCallStack"]
28
+ __all__ = ["StackFrame", "getCallStack", "getCallerFrame", "getStackFrame"]
29
29
 
30
30
  import inspect
31
31
  import linecache
@@ -50,7 +50,7 @@ def getCallerFrame(relative=0):
50
50
  This function is excluded from the frame.
51
51
  """
52
52
  frame = inspect.currentframe().f_back.f_back # Our caller's caller
53
- for ii in range(relative):
53
+ for _ in range(relative):
54
54
  frame = frame.f_back
55
55
  return frame
56
56
 
@@ -32,7 +32,7 @@ or `lsst.pex.config.Field._compare` implementation, as they take care of
32
32
  writing messages as well as floating-point comparisons and shortcuts.
33
33
  """
34
34
 
35
- __all__ = ("getComparisonName", "compareScalars", "compareConfigs")
35
+ __all__ = ("compareConfigs", "compareScalars", "getComparisonName")
36
36
 
37
37
  import numpy
38
38
 
@@ -68,7 +68,8 @@ def compareScalars(name, v1, v2, output, rtol=1e-8, atol=1e-8, dtype=None):
68
68
  ----------
69
69
  name : `str`
70
70
  Name to use when reporting differences, typically created by
71
- `getComparisonName`.
71
+ `getComparisonName`. This will always appear as the beginning of any
72
+ messages reported via ``output``.
72
73
  v1 : object
73
74
  Left-hand side value to compare.
74
75
  v2 : object
@@ -104,7 +105,7 @@ def compareScalars(name, v1, v2, output, rtol=1e-8, atol=1e-8, dtype=None):
104
105
  else:
105
106
  result = v1 == v2
106
107
  if not result and output is not None:
107
- output(f"Inequality in {name}: {v1!r} != {v2!r}")
108
+ output(f"{name}: {v1!r} != {v2!r}")
108
109
  return result
109
110
 
110
111
 
@@ -117,7 +118,8 @@ def compareConfigs(name, c1, c2, shortcut=True, rtol=1e-8, atol=1e-8, output=Non
117
118
  ----------
118
119
  name : `str`
119
120
  Name to use when reporting differences, typically created by
120
- `getComparisonName`.
121
+ `getComparisonName`. This will always appear as the beginning of any
122
+ messages reported via ``output``.
121
123
  c1 : `lsst.pex.config.Config`
122
124
  Left-hand side config to compare.
123
125
  c2 : `lsst.pex.config.Config`
@@ -150,22 +152,24 @@ def compareConfigs(name, c1, c2, shortcut=True, rtol=1e-8, atol=1e-8, output=Non
150
152
  `~lsst.pex.config.ConfigChoiceField` instances, *unselected*
151
153
  `~lsst.pex.config.Config` instances will not be compared.
152
154
  """
155
+ from .config import _typeStr
156
+
153
157
  assert name is not None
154
158
  if c1 is None:
155
159
  if c2 is None:
156
160
  return True
157
161
  else:
158
162
  if output is not None:
159
- output(f"LHS is None for {name}")
163
+ output(f"{name}: None != {c2!r}.")
160
164
  return False
161
165
  else:
162
166
  if c2 is None:
163
167
  if output is not None:
164
- output(f"RHS is None for {name}")
168
+ output(f"{name}: {c1!r} != None.")
165
169
  return False
166
170
  if type(c1) is not type(c2):
167
171
  if output is not None:
168
- output(f"Config types do not match for {name}: {type(c1)} != {type(c2)}")
172
+ output(f"{name}: config types do not match; {_typeStr(c1)} != {_typeStr(c2)}.")
169
173
  return False
170
174
  equal = True
171
175
  for field in c1._fields.values():
lsst/pex/config/config.py CHANGED
@@ -30,15 +30,16 @@ __all__ = (
30
30
  "Config",
31
31
  "ConfigMeta",
32
32
  "Field",
33
+ "FieldTypeVar",
33
34
  "FieldValidationError",
34
35
  "UnexpectedProxyUsageError",
35
- "FieldTypeVar",
36
36
  )
37
37
 
38
38
  import copy
39
39
  import importlib
40
40
  import io
41
41
  import math
42
+ import numbers
42
43
  import os
43
44
  import re
44
45
  import shutil
@@ -134,9 +135,15 @@ def _autocast(x, dtype):
134
135
  If appropriate, the returned value is ``x`` cast to the given type
135
136
  ``dtype``. If the cast cannot be performed the original value of
136
137
  ``x`` is returned.
138
+
139
+ Notes
140
+ -----
141
+ Will convert numpy scalar types to the standard Python equivalents.
137
142
  """
138
- if dtype is float and isinstance(x, int):
143
+ if dtype is float and isinstance(x, numbers.Real):
139
144
  return float(x)
145
+ if dtype is int and isinstance(x, numbers.Integral):
146
+ return int(x)
140
147
  return x
141
148
 
142
149
 
@@ -383,7 +390,6 @@ class Field(Generic[FieldTypeVar]):
383
390
  >>> class Example(Config):
384
391
  ... myInt = Field("An integer field.", int, default=0)
385
392
  ... name = Field[str](doc="A string Field")
386
- ...
387
393
  >>> print(config.myInt)
388
394
  0
389
395
  >>> config.myInt = 5
@@ -694,6 +700,14 @@ class Field(Generic[FieldTypeVar]):
694
700
  """
695
701
  return self.__get__(instance)
696
702
 
703
+ def _copy_storage(self, old: Config, new: Config) -> Any:
704
+ """Copy the storage for this field in the given field into an object
705
+ suitable for storage in a new copy of that config.
706
+
707
+ Any frozen storage should be unfrozen.
708
+ """
709
+ return copy.deepcopy(old._storage[self.name])
710
+
697
711
  @overload
698
712
  def __get__(
699
713
  self, instance: None, owner: Any = None, at: Any = None, label: str = "default"
@@ -729,7 +743,7 @@ class Field(Generic[FieldTypeVar]):
729
743
  else:
730
744
  raise AttributeError(
731
745
  f"Config {instance} is missing _storage attribute, likely incorrectly initialized"
732
- )
746
+ ) from None
733
747
 
734
748
  def __set__(
735
749
  self, instance: Config, value: FieldTypeVar | None, at: Any = None, label: str = "assignment"
@@ -784,7 +798,7 @@ class Field(Generic[FieldTypeVar]):
784
798
  try:
785
799
  self._validateValue(value)
786
800
  except BaseException as e:
787
- raise FieldValidationError(self, instance, str(e))
801
+ raise FieldValidationError(self, instance, str(e)) from e
788
802
 
789
803
  instance._storage[self.name] = value
790
804
  if at is None:
@@ -936,23 +950,25 @@ class Config(metaclass=ConfigMeta): # type: ignore
936
950
  >>> from lsst.pex.config import Config, Field, ListField
937
951
  >>> class DemoConfig(Config):
938
952
  ... intField = Field(doc="An integer field", dtype=int, default=42)
939
- ... listField = ListField(doc="List of favorite beverages.", dtype=str,
940
- ... default=['coffee', 'green tea', 'water'])
941
- ...
953
+ ... listField = ListField(
954
+ ... doc="List of favorite beverages.",
955
+ ... dtype=str,
956
+ ... default=["coffee", "green tea", "water"],
957
+ ... )
942
958
  >>> config = DemoConfig()
943
959
 
944
960
  Configs support many `dict`-like APIs:
945
961
 
946
962
  >>> config.keys()
947
963
  ['intField', 'listField']
948
- >>> 'intField' in config
964
+ >>> "intField" in config
949
965
  True
950
966
 
951
967
  Individual fields can be accessed as attributes of the configuration:
952
968
 
953
969
  >>> config.intField
954
970
  42
955
- >>> config.listField.append('earl grey tea')
971
+ >>> config.listField.append("earl grey tea")
956
972
  >>> print(config.listField)
957
973
  ['coffee', 'green tea', 'water', 'earl grey tea']
958
974
  """
@@ -1047,6 +1063,30 @@ class Config(metaclass=ConfigMeta): # type: ignore
1047
1063
  instance.update(__at=at, **kw)
1048
1064
  return instance
1049
1065
 
1066
+ def copy(self) -> Config:
1067
+ """Return a deep copy of this config.
1068
+
1069
+ Notes
1070
+ -----
1071
+ The returned config object is not frozen, even if the original was.
1072
+ If a nested config object is copied, it retains the name from its
1073
+ original hierarchy.
1074
+
1075
+ Nested objects are only shared between the new and old configs if they
1076
+ are not possible to modify via the config's interfaces (e.g. entries
1077
+ in the the history list are not copied, but the lists themselves are,
1078
+ so modifications to one copy do not modify the other).
1079
+ """
1080
+ instance = object.__new__(type(self))
1081
+ instance._frozen = False
1082
+ instance._name = self._name
1083
+ instance._history = {k: list(v) for k, v in self._history.items()}
1084
+ instance._imports = set(self._imports)
1085
+ # Important to set up storage last, since fields sometimes store
1086
+ # proxy objects that reference their parent (especially for history).
1087
+ instance._storage = {k: self._fields[k]._copy_storage(self, instance) for k in self._storage}
1088
+ return instance
1089
+
1050
1090
  def __reduce__(self):
1051
1091
  """Reduction for pickling (function with arguments to reproduce).
1052
1092
 
@@ -1095,30 +1135,27 @@ class Config(metaclass=ConfigMeta): # type: ignore
1095
1135
 
1096
1136
  >>> from lsst.pex.config import Config, Field
1097
1137
  >>> class DemoConfig(Config):
1098
- ... fieldA = Field(doc='Field A', dtype=int, default=42)
1099
- ... fieldB = Field(doc='Field B', dtype=bool, default=True)
1100
- ... fieldC = Field(doc='Field C', dtype=str, default='Hello world')
1101
- ...
1138
+ ... fieldA = Field(doc="Field A", dtype=int, default=42)
1139
+ ... fieldB = Field(doc="Field B", dtype=bool, default=True)
1140
+ ... fieldC = Field(doc="Field C", dtype=str, default="Hello world")
1102
1141
  >>> config = DemoConfig()
1103
1142
 
1104
1143
  These are the default values of each field:
1105
1144
 
1106
1145
  >>> for name, value in config.iteritems():
1107
1146
  ... print(f"{name}: {value}")
1108
- ...
1109
1147
  fieldA: 42
1110
1148
  fieldB: True
1111
1149
  fieldC: 'Hello world'
1112
1150
 
1113
1151
  Using this method to update ``fieldA`` and ``fieldC``:
1114
1152
 
1115
- >>> config.update(fieldA=13, fieldC='Updated!')
1153
+ >>> config.update(fieldA=13, fieldC="Updated!")
1116
1154
 
1117
1155
  Now the values of each field are:
1118
1156
 
1119
1157
  >>> for name, value in config.iteritems():
1120
1158
  ... print(f"{name}: {value}")
1121
- ...
1122
1159
  fieldA: 13
1123
1160
  fieldB: True
1124
1161
  fieldC: 'Updated!'
@@ -1130,8 +1167,9 @@ class Config(metaclass=ConfigMeta): # type: ignore
1130
1167
  try:
1131
1168
  field = self._fields[name]
1132
1169
  field.__set__(self, value, at=at, label=label)
1133
- except KeyError:
1134
- raise KeyError(f"No field of name {name} exists in config type {_typeStr(self)}")
1170
+ except KeyError as e:
1171
+ e.add_note(f"No field of name {name} exists in config type {_typeStr(self)}")
1172
+ raise
1135
1173
 
1136
1174
  def load(self, filename, root="config"):
1137
1175
  """Modify this config in place by executing the Python code in a
@@ -1408,7 +1446,7 @@ class Config(metaclass=ConfigMeta): # type: ignore
1408
1446
  class.
1409
1447
  """
1410
1448
  self._imports.add(self.__module__)
1411
- for name, field in self._fields.items():
1449
+ for field in self._fields.values():
1412
1450
  field._collectImports(self, self._imports)
1413
1451
 
1414
1452
  def toDict(self):
@@ -30,7 +30,6 @@ __all__ = ["ConfigChoiceField"]
30
30
 
31
31
  import collections.abc
32
32
  import copy
33
- import weakref
34
33
  from typing import Any, ForwardRef, overload
35
34
 
36
35
  from .callStack import getCallStack, getStackFrame
@@ -63,35 +62,34 @@ class SelectionSet(collections.abc.MutableSet):
63
62
  history.
64
63
  """
65
64
 
66
- def __init__(self, dict_, value, at=None, label="assignment", setHistory=True):
65
+ def __init__(
66
+ self,
67
+ dict_: ConfigInstanceDict,
68
+ value: Any,
69
+ at=None,
70
+ label: str = "assignment",
71
+ setHistory: bool = True,
72
+ ):
67
73
  if at is None:
68
74
  at = getCallStack()
69
75
  self._dict = dict_
70
76
  self._field = self._dict._field
71
- self._config_ = weakref.ref(self._dict._config)
72
- self.__history = self._config._history.setdefault(self._field.name, [])
77
+ self._history = self._dict._config._history.setdefault(self._field.name, [])
73
78
  if value is not None:
74
79
  try:
75
80
  for v in value:
76
81
  if v not in self._dict:
77
82
  # invoke __getitem__ to ensure it's present
78
83
  self._dict.__getitem__(v, at=at)
79
- except TypeError:
84
+ except TypeError as e:
80
85
  msg = f"Value {value} is of incorrect type {_typeStr(value)}. Sequence type expected"
81
- raise FieldValidationError(self._field, self._config, msg)
86
+ raise FieldValidationError(self._field, self._dict._config, msg) from e
82
87
  self._set = set(value)
83
88
  else:
84
89
  self._set = set()
85
90
 
86
91
  if setHistory:
87
- self.__history.append((f"Set selection to {self}", at, label))
88
-
89
- @property
90
- def _config(self) -> Config:
91
- # Config Fields should never outlive their config class instance
92
- # assert that as such here
93
- assert self._config_() is not None
94
- return self._config_()
92
+ self._history.append((f"Set selection to {self}", at, label))
95
93
 
96
94
  def add(self, value, at=None):
97
95
  """Add a value to the selected set.
@@ -104,7 +102,7 @@ class SelectionSet(collections.abc.MutableSet):
104
102
  optional
105
103
  Stack frames for history recording.
106
104
  """
107
- if self._config._frozen:
105
+ if self._dict._config._frozen:
108
106
  raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
109
107
 
110
108
  if at is None:
@@ -114,7 +112,7 @@ class SelectionSet(collections.abc.MutableSet):
114
112
  # invoke __getitem__ to make sure it's present
115
113
  self._dict.__getitem__(value, at=at)
116
114
 
117
- self.__history.append((f"added {value} to selection", at, "selection"))
115
+ self._history.append((f"added {value} to selection", at, "selection"))
118
116
  self._set.add(value)
119
117
 
120
118
  def discard(self, value, at=None):
@@ -128,8 +126,8 @@ class SelectionSet(collections.abc.MutableSet):
128
126
  optional
129
127
  Stack frames for history recording.
130
128
  """
131
- if self._config._frozen:
132
- raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
129
+ if self._dict._config._frozen:
130
+ raise FieldValidationError(self._field, self._dict._config, "Cannot modify a frozen Config")
133
131
 
134
132
  if value not in self._dict:
135
133
  return
@@ -137,7 +135,7 @@ class SelectionSet(collections.abc.MutableSet):
137
135
  if at is None:
138
136
  at = getCallStack()
139
137
 
140
- self.__history.append((f"removed {value} from selection", at, "selection"))
138
+ self._history.append((f"removed {value} from selection", at, "selection"))
141
139
  self._set.discard(value)
142
140
 
143
141
  def __len__(self):
@@ -177,9 +175,9 @@ class ConfigInstanceDict(collections.abc.Mapping[str, Config]):
177
175
  (that is, ``typemap[name]``).
178
176
  """
179
177
 
180
- def __init__(self, config, field):
178
+ def __init__(self, config: Config, field: ConfigChoiceField):
181
179
  collections.abc.Mapping.__init__(self)
182
- self._dict = {}
180
+ self._dict: dict[str, Config] = {}
183
181
  self._selection = None
184
182
  self._config = config
185
183
  self._field = field
@@ -187,6 +185,18 @@ class ConfigInstanceDict(collections.abc.Mapping[str, Config]):
187
185
  self.__doc__ = field.doc
188
186
  self._typemap = None
189
187
 
188
+ def _copy(self, config: Config) -> ConfigInstanceDict:
189
+ result = type(self)(config, self._field)
190
+ result._dict = {k: v.copy() for k, v in self._dict.items()}
191
+ result._history.extend(self._history)
192
+ result._typemap = self._typemap
193
+ if self._selection is not None:
194
+ if self._field.multi:
195
+ result._selection = SelectionSet(result._dict, self._selection._set)
196
+ else:
197
+ result._selection = self._selection
198
+ return result
199
+
190
200
  @property
191
201
  def types(self):
192
202
  return self._typemap if self._typemap is not None else self._field.typemap
@@ -293,10 +303,10 @@ class ConfigInstanceDict(collections.abc.Mapping[str, Config]):
293
303
  except KeyError:
294
304
  try:
295
305
  dtype = self.types[k]
296
- except Exception:
306
+ except Exception as e:
297
307
  raise FieldValidationError(
298
308
  self._field, self._config, f"Unknown key {k!r} in Registry/ConfigChoiceField"
299
- )
309
+ ) from e
300
310
  name = _joinNamePath(self._config._name, self._field.name, k)
301
311
  if at is None:
302
312
  at = getCallStack()
@@ -310,8 +320,8 @@ class ConfigInstanceDict(collections.abc.Mapping[str, Config]):
310
320
 
311
321
  try:
312
322
  dtype = self.types[k]
313
- except Exception:
314
- raise FieldValidationError(self._field, self._config, f"Unknown key {k!r}")
323
+ except Exception as e:
324
+ raise FieldValidationError(self._field, self._config, f"Unknown key {k!r}") from e
315
325
 
316
326
  if value != dtype and type(value) is not dtype:
317
327
  msg = (
@@ -443,7 +453,6 @@ class ConfigChoiceField(Field[ConfigInstanceDict]):
443
453
  >>> from lsst.pex.config import Config, ConfigChoiceField, Field
444
454
  >>> class AaaConfig(Config):
445
455
  ... somefield = Field("doc", int)
446
- ...
447
456
 
448
457
  The ``MyConfig`` config has a ``ConfigChoiceField`` field called ``choice``
449
458
  that maps the ``AaaConfig`` type to the ``"AAA"`` key:
@@ -451,7 +460,6 @@ class ConfigChoiceField(Field[ConfigInstanceDict]):
451
460
  >>> TYPEMAP = {"AAA", AaaConfig}
452
461
  >>> class MyConfig(Config):
453
462
  ... choice = ConfigChoiceField("doc for choice", TYPEMAP)
454
- ...
455
463
 
456
464
  Creating an instance of ``MyConfig``:
457
465
 
@@ -460,7 +468,7 @@ class ConfigChoiceField(Field[ConfigInstanceDict]):
460
468
  Setting value of the field ``somefield`` on the "AAA" key of the ``choice``
461
469
  field:
462
470
 
463
- >>> instance.choice['AAA'].somefield = 5
471
+ >>> instance.choice["AAA"].somefield = 5
464
472
 
465
473
  **Selecting the active configuration**
466
474
 
@@ -546,6 +554,13 @@ class ConfigChoiceField(Field[ConfigInstanceDict]):
546
554
  else:
547
555
  instanceDict._setSelection(value, at=at, label=label)
548
556
 
557
+ def _copy_storage(self, old: Config, new: Config) -> Any:
558
+ instance_dict: ConfigInstanceDict | None = old._storage.get(self.name)
559
+ if instance_dict is not None:
560
+ return instance_dict._copy(new)
561
+ else:
562
+ return None
563
+
549
564
  def rename(self, instance):
550
565
  instanceDict = self.__get__(instance)
551
566
  fullname = _joinNamePath(instance._name, self.name)
@@ -24,10 +24,13 @@
24
24
  #
25
25
  # You should have received a copy of the GNU General Public License
26
26
  # along with this program. If not, see <http://www.gnu.org/licenses/>.
27
+ from __future__ import annotations
27
28
 
28
29
  __all__ = ["ConfigDictField"]
29
30
 
30
- from .callStack import getCallStack, getStackFrame
31
+ from collections.abc import Mapping
32
+
33
+ from .callStack import StackFrame, getCallStack, getStackFrame
31
34
  from .comparison import compareConfigs, compareScalars, getComparisonName
32
35
  from .config import Config, FieldValidationError, _autocast, _joinNamePath, _typeStr
33
36
  from .dictField import Dict, DictField
@@ -51,11 +54,33 @@ class ConfigDict(Dict[str, Config]):
51
54
  Stack frame for history recording. Will be calculated if `None`.
52
55
  label : `str`, optional
53
56
  Label to use for history recording.
57
+ setHistory : `bool`, optional
58
+ Whether to append to the history record.
54
59
  """
55
60
 
56
- def __init__(self, config, field, value, at, label):
57
- Dict.__init__(self, config, field, value, at, label, setHistory=False)
58
- self.history.append(("Dict initialized", at, label))
61
+ def __init__(
62
+ self,
63
+ config: Config,
64
+ field: ConfigDictField,
65
+ value: Mapping[str, Config] | None,
66
+ *,
67
+ at: list[StackFrame] | None,
68
+ label: str,
69
+ setHistory: bool = True,
70
+ ):
71
+ Dict.__init__(self, config, field, value, at=at, label=label, setHistory=False)
72
+ if setHistory:
73
+ self.history.append(("Dict initialized", at, label))
74
+
75
+ def _copy(self, config: Config) -> Dict:
76
+ return type(self)(
77
+ config,
78
+ self._field,
79
+ {k: v._copy() for k, v in self._dict.items()},
80
+ at=None,
81
+ label="copy",
82
+ setHistory=False,
83
+ )
59
84
 
60
85
  def __setitem__(self, k, x, at=None, label="setitem", setHistory=True):
61
86
  if self._config._frozen:
@@ -77,6 +102,11 @@ class ConfigDict(Dict[str, Config]):
77
102
  )
78
103
  raise FieldValidationError(self._field, self._config, msg)
79
104
 
105
+ # validate key using keycheck
106
+ if self._field.keyCheck is not None and not self._field.keyCheck(k):
107
+ msg = f"Key {k!r} is not a valid key"
108
+ raise FieldValidationError(self._field, self._config, msg)
109
+
80
110
  if at is None:
81
111
  at = getCallStack()
82
112
  name = _joinNamePath(self._config._name, self._field.name, k)
@@ -127,6 +157,8 @@ class ConfigDictField(DictField):
127
157
  Default is `True`.
128
158
  dictCheck : `~collections.abc.Callable` or `None`, optional
129
159
  Callable to check a dict.
160
+ keyCheck : `~collections.abc.Callable` or `None`, optional
161
+ Callable to check a key.
130
162
  itemCheck : `~collections.abc.Callable` or `None`, optional
131
163
  Callable to check an item.
132
164
  deprecated : None or `str`, optional
@@ -140,7 +172,8 @@ class ConfigDictField(DictField):
140
172
 
141
173
  - ``keytype`` or ``itemtype`` arguments are not supported types
142
174
  (members of `ConfigDictField.supportedTypes`.
143
- - ``dictCheck`` or ``itemCheck`` is not a callable function.
175
+ - ``dictCheck``, ``keyCheck`` or ``itemCheck`` is not a callable
176
+ function.
144
177
 
145
178
  See Also
146
179
  --------
@@ -172,6 +205,7 @@ class ConfigDictField(DictField):
172
205
  default=None,
173
206
  optional=False,
174
207
  dictCheck=None,
208
+ keyCheck=None,
175
209
  itemCheck=None,
176
210
  deprecated=None,
177
211
  ):
@@ -189,14 +223,18 @@ class ConfigDictField(DictField):
189
223
  raise ValueError(f"'keytype' {_typeStr(keytype)} is not a supported type")
190
224
  elif not issubclass(itemtype, Config):
191
225
  raise ValueError(f"'itemtype' {_typeStr(itemtype)} is not a supported type")
192
- if dictCheck is not None and not hasattr(dictCheck, "__call__"):
193
- raise ValueError("'dictCheck' must be callable")
194
- if itemCheck is not None and not hasattr(itemCheck, "__call__"):
195
- raise ValueError("'itemCheck' must be callable")
226
+
227
+ check_errors = []
228
+ for name, check in (("dictCheck", dictCheck), ("keyCheck", keyCheck), ("itemCheck", itemCheck)):
229
+ if check is not None and not callable(check):
230
+ check_errors.append(name)
231
+ if check_errors:
232
+ raise ValueError(f"{', '.join(check_errors)} must be callable")
196
233
 
197
234
  self.keytype = keytype
198
235
  self.itemtype = itemtype
199
236
  self.dictCheck = dictCheck
237
+ self.keyCheck = keyCheck
200
238
  self.itemCheck = itemCheck
201
239
 
202
240
  def rename(self, instance):
@@ -207,6 +245,23 @@ class ConfigDictField(DictField):
207
245
  configDict[k]._rename(fullname)
208
246
 
209
247
  def validate(self, instance):
248
+ """Validate the field.
249
+
250
+ Parameters
251
+ ----------
252
+ instance : `lsst.pex.config.Config`
253
+ The config instance that contains this field.
254
+
255
+ Raises
256
+ ------
257
+ lsst.pex.config.FieldValidationError
258
+ Raised if validation fails for this field.
259
+
260
+ Notes
261
+ -----
262
+ Individual key checks (``keyCheck``) are applied when each key is added
263
+ and are not re-checked by this method.
264
+ """
210
265
  value = self.__get__(instance)
211
266
  if value is not None:
212
267
  for k in value:
@@ -289,7 +344,7 @@ class ConfigDictField(DictField):
289
344
  name = getComparisonName(
290
345
  _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
291
346
  )
292
- if not compareScalars(f"keys for {name}", set(d1.keys()), set(d2.keys()), output=output):
347
+ if not compareScalars(f"{name} (keys)", set(d1.keys()), set(d2.keys()), output=output):
293
348
  return False
294
349
  equal = True
295
350
  for k, v1 in d1.items():
@@ -243,6 +243,13 @@ class ConfigField(Field[FieldTypeVar]):
243
243
  value = self.__get__(instance)
244
244
  return value.toDict()
245
245
 
246
+ def _copy_storage(self, old: Config, new: Config) -> Any:
247
+ value: Config | None = old._storage.get(self.name)
248
+ if value is not None:
249
+ return value.copy()
250
+ else:
251
+ return None
252
+
246
253
  def validate(self, instance):
247
254
  """Validate the field (for internal use only).
248
255
 
@@ -20,7 +20,7 @@
20
20
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
21
21
  from __future__ import annotations
22
22
 
23
- __all__ = ["ConfigurableAction", "ActionTypeVar"]
23
+ __all__ = ["ActionTypeVar", "ConfigurableAction"]
24
24
 
25
25
  from typing import Any, TypeVar
26
26
 
@@ -62,3 +62,8 @@ class ConfigurableAction(Config):
62
62
 
63
63
  def __call__(self, *args: Any, **kwargs: Any) -> Any:
64
64
  raise NotImplementedError("This method should be overloaded in subclasses")
65
+
66
+ def copy(self) -> ConfigurableAction:
67
+ result = super().copy()
68
+ result.identity = self.identity
69
+ return result