Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DM-48074: Introduce keyCheck callback function for DictField and ConfigDictField #113

Merged
merged 11 commits into from
Dec 24, 2024
Merged
1 change: 1 addition & 0 deletions doc/changes/DM-48074.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Introduced the `keyCheck` callback to `DictField` and `ConfigDictField`, allowing custom key validation during assignment. Added unit tests to ensure functionality.
31 changes: 30 additions & 1 deletion python/lsst/pex/config/configDictField.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ def __setitem__(self, k, x, at=None, label="setitem", setHistory=True):
)
raise FieldValidationError(self._field, self._config, msg)

# validate key using keycheck
if self._field.keyCheck is not None and not self._field.keyCheck(k):
msg = f"Key {k!r} is not a valid key"
raise FieldValidationError(self._field, self._config, msg)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of wish this method would collect all errors before throwing in case there are multiple, but I suppose that's not how it was designed. Oh well.


if at is None:
at = getCallStack()
name = _joinNamePath(self._config._name, self._field.name, k)
Expand Down Expand Up @@ -127,6 +132,8 @@ class ConfigDictField(DictField):
Default is `True`.
dictCheck : `~collections.abc.Callable` or `None`, optional
Callable to check a dict.
keyCheck : `~collections.abc.Callable` or `None`, optional
Callable to check a key.
itemCheck : `~collections.abc.Callable` or `None`, optional
Callable to check an item.
deprecated : None or `str`, optional
Expand All @@ -140,7 +147,8 @@ class ConfigDictField(DictField):

- ``keytype`` or ``itemtype`` arguments are not supported types
(members of `ConfigDictField.supportedTypes`.
- ``dictCheck`` or ``itemCheck`` is not a callable function.
- ``dictCheck``, ``keyCheck`` or ``itemCheck`` is not a callable
function.

See Also
--------
Expand Down Expand Up @@ -172,6 +180,7 @@ def __init__(
default=None,
optional=False,
dictCheck=None,
keyCheck=None,
itemCheck=None,
deprecated=None,
):
Expand All @@ -191,12 +200,15 @@ def __init__(
raise ValueError(f"'itemtype' {_typeStr(itemtype)} is not a supported type")
if dictCheck is not None and not hasattr(dictCheck, "__call__"):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These errors could actually be consolidated into one, e.g.:

check_errors = []
for name, check in (("dictCheck", dictCheck), ("keyCheck", keyCheck), ("itemCheck", itemCheck)):
    if check is not None and not hasattr(check, "__call__"):
        check_errors.append(name)
if check_errors:
    raise ValueError(f"{', '.join(check_errors)} must be callable")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I just wanted to be minimally invasive in my approach but this sounds like an improvement.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

raise ValueError("'dictCheck' must be callable")
if keyCheck is not None and not hasattr(keyCheck, "__call__"):
raise ValueError("'keyCheck' must be callable")
if itemCheck is not None and not hasattr(itemCheck, "__call__"):
raise ValueError("'itemCheck' must be callable")

self.keytype = keytype
self.itemtype = itemtype
self.dictCheck = dictCheck
self.keyCheck = keyCheck
self.itemCheck = itemCheck

def rename(self, instance):
Expand All @@ -207,6 +219,23 @@ def rename(self, instance):
configDict[k]._rename(fullname)

def validate(self, instance):
"""Validate the field.

Parameters
----------
instance : `lsst.pex.config.Config`
The config instance that contains this field.

Raises
------
lsst.pex.config.FieldValidationError
Raised if validation fails for this field.

Notes
-----
Individual key checks (``keyCheck``) are applied when each key is added
and are not re-checked by this method.
"""
value = self.__get__(instance)
if value is not None:
for k in value:
Expand Down
30 changes: 20 additions & 10 deletions python/lsst/pex/config/dictField.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ def __setitem__(
)
raise FieldValidationError(self._field, self._config, msg)

# validate key using keycheck
if self._field.keyCheck is not None and not self._field.keyCheck(k):
msg = f"Key {k!r} is not a valid key"
raise FieldValidationError(self._field, self._config, msg)

# validate item using itemcheck
if self._field.itemCheck is not None and not self._field.itemCheck(x):
msg = f"Item at key {k!r} is not a valid value: {x}"
Expand Down Expand Up @@ -214,6 +219,8 @@ class DictField(Field[Dict[KeyTypeVar, ItemTypeVar]], Generic[KeyTypeVar, ItemTy
If `True`, the field doesn't need to have a set value.
dictCheck : callable
A function that validates the dictionary as a whole.
keyCheck : callable
A function that validates individual mapping keys.
itemCheck : callable
A function that validates individual mapping values.
deprecated : None or `str`, optional
Expand Down Expand Up @@ -289,6 +296,7 @@ def __init__(
default=None,
optional=False,
dictCheck=None,
keyCheck=None,
itemCheck=None,
deprecated=None,
):
Expand All @@ -312,12 +320,15 @@ def __init__(
raise ValueError(f"'itemtype' {_typeStr(itemtype)} is not a supported type")
if dictCheck is not None and not hasattr(dictCheck, "__call__"):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about consolidating errors here. Also it seems like a lot of code could have been shared between configDictField and regular dictField, but that's another issue for another day.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

raise ValueError("'dictCheck' must be callable")
if keyCheck is not None and not hasattr(keyCheck, "__call__"):
raise ValueError("'keyCheck' must be callable")
if itemCheck is not None and not hasattr(itemCheck, "__call__"):
raise ValueError("'itemCheck' must be callable")

self.keytype = keytype
self.itemtype = itemtype
self.dictCheck = dictCheck
self.keyCheck = keyCheck
self.itemCheck = itemCheck

def validate(self, instance):
Expand All @@ -328,23 +339,22 @@ def validate(self, instance):
instance : `lsst.pex.config.Config`
The configuration that contains this field.

Returns
-------
isValid : `bool`
`True` is returned if the field passes validation criteria (see
*Notes*). Otherwise `False`.
Raises
------
lsst.pex.config.FieldValidationError
Raised if validation fails for this field (see *Notes*).

Notes
-----
This method validates values according to the following criteria:

- A non-optional field is not `None`.
- If a value is not `None`, is must pass the `ConfigField.dictCheck`
user callback functon.
- If a value is not `None`, it must pass the `ConfigField.dictCheck`
user callback function.

Individual item checks by the `ConfigField.itemCheck` user callback
function are done immediately when the value is set on a key. Those
checks are not repeated by this method.
Individual key and item checks by the ``keyCheck`` and ``itemCheck``
user callback functions are done immediately when the value is set on a
key. Those checks are not repeated by this method.
"""
Field.validate(self, instance)
value = self.__get__(instance)
Expand Down
8 changes: 4 additions & 4 deletions python/lsst/pex/config/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@
if writeSourceLine:
line.append(
[
"%s" % ("%s:%d" % (frame.filename, frame.lineno)),
f"{frame.filename}:{frame.lineno}",
"FILE",
]
)
Expand Down Expand Up @@ -252,14 +252,14 @@
fullname = f"{config._name}.{name}" if config._name is not None else name
msg.append(_colorize(re.sub(r"^root\.", "", fullname), "NAME"))
for value, output in outputs:
line = prefix + _colorize("%-*s" % (valueLength, value), "VALUE") + " "
line = prefix + _colorize(f"{value:<{valueLength}}", "VALUE") + " "
for i, vt in enumerate(output):
if writeSourceLine:
vt[0][0] = "%-*s" % (sourceLength, vt[0][0])
vt[0][0] = f"{vt[0][0]:<{sourceLength}}"

Check warning on line 258 in python/lsst/pex/config/history.py

View check run for this annotation

Codecov / codecov/patch

python/lsst/pex/config/history.py#L258

Added line #L258 was not covered by tests
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume you've checked that these formats and the ones above are identical (it is not totally obvious to me).


output[i] = " ".join([_colorize(v, t) for v, t in vt])

line += ("\n%*s" % (valueLength + 1, "")).join(output)
line += ("\n" + f"{'':>{valueLength + 1}}").join(output)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why the \n can't go inside the f-string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch! Moved it inside the f-string.

msg.append(line)

return "\n".join(msg)
25 changes: 12 additions & 13 deletions python/lsst/pex/config/listField.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,14 @@
`ListField.itemCheck` method.
"""
if not isinstance(x, self._field.itemtype) and x is not None:
msg = "Item at position %d with value %s is of incorrect type %s. Expected %s" % (
i,
x,
_typeStr(x),
_typeStr(self._field.itemtype),
msg = (
f"Item at position {i} with value {x} is of incorrect type {_typeStr(x)}. "
f"Expected {_typeStr(self._field.itemtype)}"
)
raise FieldValidationError(self._field, self._config, msg)

if self._field.itemCheck is not None and not self._field.itemCheck(x):
msg = "Item at position %d is not a valid value: %s" % (i, x)
msg = f"Item at position {i} is not a valid value: {x}"
raise FieldValidationError(self._field, self._config, msg)

def list(self):
Expand Down Expand Up @@ -329,15 +327,15 @@
raise ValueError(f"Unsupported dtype {_typeStr(dtype)}")
if length is not None:
if length <= 0:
raise ValueError("'length' (%d) must be positive" % length)
raise ValueError(f"'length' ({length}) must be positive")
minLength = None
maxLength = None
else:
if maxLength is not None and maxLength <= 0:
raise ValueError("'maxLength' (%d) must be positive" % maxLength)
raise ValueError(f"'maxLength' ({maxLength}) must be positive")
if minLength is not None and maxLength is not None and minLength > maxLength:
raise ValueError(
"'maxLength' (%d) must be at least as large as 'minLength' (%d)" % (maxLength, minLength)
f"'maxLength' ({maxLength}) must be at least as large as 'minLength' ({minLength})"
)

if listCheck is not None and not hasattr(listCheck, "__call__"):
Expand Down Expand Up @@ -412,13 +410,13 @@
if value is not None:
lenValue = len(value)
if self.length is not None and not lenValue == self.length:
msg = "Required list length=%d, got length=%d" % (self.length, lenValue)
msg = f"Required list length={self.length}, got length={lenValue}"

Check warning on line 413 in python/lsst/pex/config/listField.py

View check run for this annotation

Codecov / codecov/patch

python/lsst/pex/config/listField.py#L413

Added line #L413 was not covered by tests
raise FieldValidationError(self, instance, msg)
elif self.minLength is not None and lenValue < self.minLength:
msg = "Minimum allowed list length=%d, got length=%d" % (self.minLength, lenValue)
msg = f"Minimum allowed list length={self.minLength}, got length={lenValue}"

Check warning on line 416 in python/lsst/pex/config/listField.py

View check run for this annotation

Codecov / codecov/patch

python/lsst/pex/config/listField.py#L416

Added line #L416 was not covered by tests
raise FieldValidationError(self, instance, msg)
elif self.maxLength is not None and lenValue > self.maxLength:
msg = "Maximum allowed list length=%d, got length=%d" % (self.maxLength, lenValue)
msg = f"Maximum allowed list length={self.maxLength}, got length={lenValue}"

Check warning on line 419 in python/lsst/pex/config/listField.py

View check run for this annotation

Codecov / codecov/patch

python/lsst/pex/config/listField.py#L419

Added line #L419 was not covered by tests
raise FieldValidationError(self, instance, msg)
elif self.listCheck is not None and not self.listCheck(value):
msg = f"{value} is not a valid value"
Expand Down Expand Up @@ -510,8 +508,9 @@
equal = True
for n, v1, v2 in zip(range(len(l1)), l1, l2):
result = compareScalars(
"%s[%d]" % (name, n), v1, v2, dtype=self.dtype, rtol=rtol, atol=atol, output=output
f"{name}[{n}]", v1, v2, dtype=self.dtype, rtol=rtol, atol=atol, output=output
)

if not result and shortcut:
return False
equal = equal and result
Expand Down
26 changes: 26 additions & 0 deletions tests/test_configDictField.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@
field1 = pexConfig.ConfigDictField(keytype=str, itemtype=pexConfig.Config, default={}, doc="doc")


class Config4(pexConfig.Config):
"""Fourth test config."""

field1 = pexConfig.ConfigDictField(
keytype=str, itemtype=pexConfig.Config, default={}, doc="doc", keyCheck=lambda k: k.islower()
)


class ConfigDictFieldTest(unittest.TestCase):
"""Test of ConfigDictField."""

Expand All @@ -78,6 +86,16 @@
else:
raise SyntaxError("Unsupported itemtypes should not be allowed")

try:

class BadKeyCheck(pexConfig.Config):
d = pexConfig.ConfigDictField("...", keytype=str, itemtype=Config1, keyCheck=4)

except Exception:
pass
else:
raise SyntaxError("Non-callable keyCheck should not be allowed")

Check warning on line 97 in tests/test_configDictField.py

View check run for this annotation

Codecov / codecov/patch

tests/test_configDictField.py#L97

Added line #L97 was not covered by tests

try:

class BadItemCheck(pexConfig.Config):
Expand Down Expand Up @@ -116,6 +134,14 @@
c.d1["a"].f = 5
c.validate()

def testKeyCheckValidation(self):
c = Config4()
c.field1["lower"] = pexConfig.Config()
with self.assertRaises(pexConfig.FieldValidationError, msg="Key check should fail"):
c.field1["UPPER"] = pexConfig.Config()
# No need for c.validate() here, as the exception for key check is
# raised by the assignment.

def testInPlaceModification(self):
c = Config2(d1={})
self.assertRaises(pexConfig.FieldValidationError, c.d1.__setitem__, 1, 0)
Expand Down
29 changes: 29 additions & 0 deletions tests/test_dictField.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
d2 = pexConfig.DictField("d2", keytype=str, itemtype=str, default=None)
d3 = pexConfig.DictField("d3", keytype=float, itemtype=float, optional=True, itemCheck=lambda x: x > 0)
d4 = pexConfig.DictField("d4", keytype=str, itemtype=None, default={})
d5 = pexConfig.DictField[str, float]("d5", default={}, keyCheck=lambda k: k not in ["k1", "k2"])
d6 = pexConfig.DictField[int, str]("d6", default={-2: "v1", 4: "v2"}, keyCheck=lambda k: k % 2 == 0)


class DictFieldTest(unittest.TestCase):
Expand All @@ -64,6 +66,16 @@
else:
raise SyntaxError("Unsupported itemtype DictFields should not be allowed")

try:

class BadKeyCheck(pexConfig.Config):
d = pexConfig.DictField("...", keytype=int, itemtype=int, keyCheck=4)

except Exception:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not ValueError, or a with self.assertRaises(ValueError, ... like below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, and that does seem more suitable to me as well. However, there are four more try...except blocks in this test case, both above and below, and I didn’t want to break consistency. I think we should modernize all these old tests in a new ticket.

pass
else:
raise SyntaxError("Non-callable keyCheck DictFields should not be allowed")

Check warning on line 77 in tests/test_dictField.py

View check run for this annotation

Codecov / codecov/patch

tests/test_dictField.py#L77

Added line #L77 was not covered by tests

try:

class BadItemCheck(pexConfig.Config):
Expand Down Expand Up @@ -139,6 +151,23 @@
c.d2 = {"a": "b"}
c.validate()

def testKeyCheckValidation(self):
c = Config1()
c.d5 = {"k3": -1, "k4": 0.25}
c.d6 = {6: "v3"}

with self.assertRaises(
pexConfig.FieldValidationError,
msg="Key check must reject dictionary assignment with invalid keys",
):
c.d5 = {"k1": 1.5, "k2": 2.0}

with self.assertRaises(
pexConfig.FieldValidationError,
msg="Key check must reject invalid key addition",
):
c.d6[3] = "v4"

def testInPlaceModification(self):
c = Config1()
self.assertRaises(pexConfig.FieldValidationError, c.d1.__setitem__, 2, 0)
Expand Down
Loading