Skip to content

Commit cc1b2bc

Browse files
committed
Use a typeddict for stricter validation and helpful autocompletion
The `constraints` property is now a typed dictionary. Assigning dictionary literals to it still ought to work, but it should flag type errors if the keys are incorrect. The method `BaseProperty._validate_constraints` is provided to convert untyped dictionaries with appropriate validation. This now uses `pydantic` to validate the typeddict. It is stricter than what I did before, as it also checks the type of the keys, not just their names. Pydantic was less ugly than coming up with my own logic to coerce an untyped dictionary into a typeddict. I've added a unit test on validation to check it does what I expect. It would be lovely to deduplicate the typeddict and the constant with key names in it - but this is hard to do neatly.
1 parent 75448f0 commit cc1b2bc

3 files changed

Lines changed: 114 additions & 12 deletions

File tree

src/labthings_fastapi/properties.py

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,23 @@ class attribute. Documentation is in strings immediately following the
5555
Callable,
5656
Generic,
5757
TypeVar,
58+
TypedDict,
5859
overload,
5960
TYPE_CHECKING,
6061
)
6162
from typing_extensions import Self
6263
from weakref import WeakSet
6364

6465
from fastapi import Body, FastAPI
65-
from pydantic import BaseModel, ConfigDict, RootModel, ValidationError, create_model
66+
from pydantic import (
67+
BaseModel,
68+
ConfigDict,
69+
RootModel,
70+
TypeAdapter,
71+
ValidationError,
72+
create_model,
73+
with_config,
74+
)
6675

6776
from .thing_description import type_to_dataschema
6877
from .thing_description._model import (
@@ -123,6 +132,21 @@ class attribute. Documentation is in strings immediately following the
123132
"""The set of supported constraint arguments for properties."""
124133

125134

135+
@with_config(ConfigDict(extra="forbid"))
136+
class FieldConstraints(TypedDict, total=False):
137+
r"""Constraints that may be applied to a `.property`\ ."""
138+
139+
gt: int | float
140+
ge: int | float
141+
lt: int | float
142+
le: int | float
143+
multiple_of: int | float
144+
allow_inf_nan: bool
145+
min_length: int
146+
max_length: int
147+
pattern: str
148+
149+
126150
# The following exceptions are raised only when creating/setting up properties.
127151
class OverspecifiedDefaultError(ValueError):
128152
"""The default value has been specified more than once.
@@ -351,14 +375,33 @@ def __init__(self, constraints: Mapping[str, Any] | None = None) -> None:
351375
super().__init__()
352376
self._model: type[BaseModel] | None = None
353377
self.readonly: bool = False
354-
self._constraints = {}
378+
self._constraints: FieldConstraints = {}
355379
try:
356-
self.constraints = constraints or {}
380+
self.constraints = self._validate_constraints(constraints or {})
357381
except UnsupportedConstraintError:
358382
raise
359383

384+
@staticmethod
385+
def _validate_constraints(constraints: Mapping[str, Any]) -> FieldConstraints:
386+
"""Validate an untyped dictionary of constraints.
387+
388+
:param constraints: A mapping that will be validated against the
389+
`.FieldConstraints` typed dictionary.
390+
:return: A `.FieldConstraints` instance.
391+
:raises UnsupportedConstraintError: if the input is not valid.
392+
"""
393+
validator = TypeAdapter(FieldConstraints)
394+
try:
395+
return validator.validate_python(constraints)
396+
except ValidationError as e:
397+
raise UnsupportedConstraintError(
398+
f"Bad constraint arguments were supplied ({constraints}). \n"
399+
f"Supported arguments are: {', '.join(CONSTRAINT_ARGS)}.\n"
400+
f"Validation error details are below: \n\n{e}"
401+
) from e
402+
360403
@builtins.property
361-
def constraints(self) -> Mapping[str, Any]: # noqa[DOC201]
404+
def constraints(self) -> FieldConstraints: # noqa[DOC201]
362405
"""Validation constraints applied to this property.
363406
364407
This mapping contains keyword arguments that will be passed to
@@ -374,20 +417,17 @@ def constraints(self) -> Mapping[str, Any]: # noqa[DOC201]
374417
return self._constraints
375418

376419
@constraints.setter
377-
def constraints(self, new_constraints: Mapping[str, Any]) -> None:
420+
def constraints(self, new_constraints: FieldConstraints) -> None:
378421
r"""Set the constraints added to the model.
379422
380423
:param new_constraints: the new value of ``constraints``\ .
381424
382425
:raises UnsupportedConstraintError: if invalid dictionary keys are present.
383426
"""
384-
for key in new_constraints:
385-
if key not in CONSTRAINT_ARGS:
386-
raise UnsupportedConstraintError(
387-
f"Unknown constraint argument: {key}. \n"
388-
f"Supported arguments are: {', '.join(CONSTRAINT_ARGS)}."
389-
)
390-
self._constraints = new_constraints
427+
try:
428+
self._constraints = self._validate_constraints(new_constraints)
429+
except UnsupportedConstraintError:
430+
raise
391431

392432
@builtins.property
393433
def model(self) -> type[BaseModel]:

tests/test_properties.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,44 @@ def functional_bad_prop(self) -> str:
467467
functional_bad_prop.constraints = {"bad_constraint": 2}
468468

469469

470+
GOOD_CONSTRAINTS = []
471+
# Single numeric constraints (test float and int)
472+
GOOD_CONSTRAINTS += [
473+
{k: v} for k in ["ge", "gt", "le", "lt", "multiple_of"] for v in [3, 3.4]
474+
]
475+
# Max/min length
476+
GOOD_CONSTRAINTS += [{k: 10} for k in ["max_length", "min_length"]]
477+
# Allow_inf_nan
478+
GOOD_CONSTRAINTS += [{"allow_inf_nan": v} for v in [True, False]]
479+
# Pattern
480+
GOOD_CONSTRAINTS += [{"pattern": v} for v in ["test", r"[0-9]+"]]
481+
482+
483+
BAD_CONSTRAINTS = []
484+
# These should be numerics
485+
BAD_CONSTRAINTS += [
486+
{k: "str"}
487+
for k in ["ge", "gt", "le", "lt", "multiple_of", "max_length", "min_length"]
488+
]
489+
# pattern must be a string
490+
BAD_CONSTRAINTS += [{"pattern": 152}]
491+
# other keys should not be allowed
492+
BAD_CONSTRAINTS += [{"invalid": None}]
493+
494+
495+
@pytest.mark.parametrize("constraints", GOOD_CONSTRAINTS)
496+
def test_successful_constraint_validation(constraints):
497+
"""Check valid constraints values are passed through."""
498+
assert BaseProperty._validate_constraints(constraints) == constraints
499+
500+
501+
@pytest.mark.parametrize("constraints", BAD_CONSTRAINTS)
502+
def test_unsuccessful_constraint_validation(constraints):
503+
"""Check invalid constraints values are flagged."""
504+
with pytest.raises(UnsupportedConstraintError):
505+
BaseProperty._validate_constraints(constraints)
506+
507+
470508
def test_propertyinfo():
471509
"""Check the PropertyInfo class is generated correctly."""
472510

typing_tests/thing_properties.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,27 @@ def strprop(self, val: str) -> None:
288288
assert_type(test_functional_property.intprop3, int)
289289
assert_type(test_functional_property.fprop, int)
290290
# ``strprop`` will be ``Any`` because of the ``[no-redef]`` error.
291+
292+
293+
class TestConstrainedProperties(lt.Thing):
294+
"""A class with some correctly and incorrectly-defined constraints."""
295+
296+
# Constraints can be passed as kwargs to `lt.property` but currently
297+
# aren't explicit, so don't get checked by mypy.
298+
# The line below is valid
299+
positiveint: int = lt.property(default=0, ge=0)
300+
301+
# The line below is not valid but doesn't bother mypy.
302+
# This would get picked up at runtime, as we validate the kwargs.
303+
negativeint: int = lt.property(default=0, sign="negative")
304+
305+
@lt.property
306+
def positivefloat(self) -> float:
307+
"""A functional property."""
308+
return 42
309+
310+
positivefloat.constraints = {"gt": 0.0} # This is OK
311+
312+
# The typed dict checks the name and type of constraints, so the line
313+
# below should be flagged. This is also validated at runtime by pydantic
314+
positivefloat.constraints = {"gt": "zero"} # type:ignore[typeddict-item]

0 commit comments

Comments
 (0)