Skip to content

Commit 2f9457e

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 54fad99 commit 2f9457e

File tree

3 files changed

+114
-12
lines changed

3 files changed

+114
-12
lines changed

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 (
@@ -122,6 +131,21 @@ class attribute. Documentation is in strings immediately following the
122131
"""The set of supported constraint arguments for properties."""
123132

124133

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

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

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

391431
@builtins.property
392432
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)