Fix a bug in the validation of union types
- ID
98d1aab- date
2025-04-13 20:47:56+00:00- author
Alex Chan <alex@alexwlchan.net>- parent
8b17b7e- message
Fix a bug in the validation of union types- changed files
5 files, 189 additions, 4 deletions
Changed files
CHANGELOG.md (2208) → CHANGELOG.md (3196)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a0ab81..d12433f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,31 @@
# CHANGELOG
+## v1.2.1 - 2025-04-13
+
+Fix a bug in the validation of `typing.Union[A, B]` where both types are a `TypedDict`.
+
+The validation is stricter, and will require an exact match to either `A` or `B` -- previously it was possible for data to validate that was only a "partial" match, and this could cause data to be lost.
+This was only possible in cases where the fields of `A` were a strict subset of the fields of `B`, and you passed a value which used more fields than `A` but less than `B`.
+
+For example, consider the following type:
+
+```python
+BasicShape = typing.TypedDict("Shape", {"sides": int, })
+NamedShape = typing.TypedDict("Shape", {"sides": int, "colour": str, "name": str })
+
+Shape = BasicShape | NamedShape
+```
+
+if you passed the data:
+
+```javascript
+const shape = {"sides": "5", "colour": "red"};
+```
+
+this isn't a strict match for `BasicShape` or `NamedShape`, but would be incorrectly validated and returned as `{'sides': 5}`.
+
+Now this throws a `pydantic.ValidationError`.
+
## v1.2.0 - 2025-03-07
This adds a new function `read_typed_js`, which is like `read_js` but will additionally validate the data against a type you specify.
pyproject.toml (1323) → pyproject.toml (1352)
diff --git a/pyproject.toml b/pyproject.toml
index 4bb99b5..ba84238 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -58,3 +58,4 @@ strict = true
[tool.interrogate]
fail_under = 100
omit-covered-files = true
+ignore-nested-classes = true
src/javascript_data_files/__init__.py (6907) → src/javascript_data_files/__init__.py (6907)
diff --git a/src/javascript_data_files/__init__.py b/src/javascript_data_files/__init__.py
index 14f1cbd..f4079f1 100644
--- a/src/javascript_data_files/__init__.py
+++ b/src/javascript_data_files/__init__.py
@@ -22,7 +22,7 @@ import uuid
from .encoder import encode_as_js, encode_as_json
-__version__ = "1.2.0"
+__version__ = "1.2.1"
T = typing.TypeVar("T")
src/javascript_data_files/validate_type.py (1290) → src/javascript_data_files/validate_type.py (2493)
diff --git a/src/javascript_data_files/validate_type.py b/src/javascript_data_files/validate_type.py
index 490d4d6..b532ca0 100644
--- a/src/javascript_data_files/validate_type.py
+++ b/src/javascript_data_files/validate_type.py
@@ -5,7 +5,7 @@ Helper methods for validating that an arbitrary blob matches a given model.
import functools
import typing
-from pydantic import ConfigDict, TypeAdapter
+from pydantic import ConfigDict, PydanticUserError, TypeAdapter
T = typing.TypeVar("T")
@@ -18,12 +18,40 @@ def _get_validator(model: type[T]) -> TypeAdapter[T]:
process, so we cache the result -- we only need to create the
validator once for each type.
"""
+ # By default, TypedDict's allow extra keys.
+ #
+ # You can disable this by setting `extra="forbid"` on the model,
+ # either setting it as an attribute directly on the type or
+ # passing it to the `TypedAdapter`.
+ #
+ # We do both:
+ #
+ # 1. We try to set the attribute directly on the model. This is
+ # useful for a model which is a single TypedDict.
+ #
+ # It will throw for types which don't support `__pydantic_config__`,
+ # e.g. builtin types.
+ #
+ # 2. We try to pass the config to `TypeAdapter` instead. This is
+ # useful for a Union of TypedDict's, but will throw a UserError
+ # if you try to pass it for a single TypedDict where you can
+ # set the attribute instead.
+ #
+ # See https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.validate_assignment
+ # See https://github.com/pydantic/pydantic/issues/11328#issuecomment-2790046722
+ config = ConfigDict(extra="forbid")
+
+ # What is this doing?
+ # see https://github.com/pydantic/pydantic/issues/11328#issuecomment-2790046722
try:
- model.__pydantic_config__ = ConfigDict(extra="forbid") # type: ignore
+ model.__pydantic_config__ = config # type: ignore
except (AttributeError, TypeError):
pass
- return TypeAdapter(model)
+ try:
+ return TypeAdapter(model, config=config)
+ except PydanticUserError:
+ return TypeAdapter(model)
def validate_type(t: typing.Any, *, model: type[T]) -> T:
tests/test_validate_type.py (0) → tests/test_validate_type.py (3563)
diff --git a/tests/test_validate_type.py b/tests/test_validate_type.py
new file mode 100644
index 0000000..c239091
--- /dev/null
+++ b/tests/test_validate_type.py
@@ -0,0 +1,130 @@
+"""
+Tests for ``javascript_data_files.validate_type``.
+"""
+
+import typing
+
+import pytest
+from pydantic import ValidationError
+
+from javascript_data_files.validate_type import validate_type
+
+
+Shape = typing.TypedDict("Shape", {"colour": str, "sides": int})
+Circle = typing.TypedDict("Circle", {"colour": str, "radius": int})
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {"colour": "red"},
+ {"sides": 4},
+ {"colour": "red", "sides": "four"},
+ {"colour": (255, 0, 0), "sides": 4},
+ {"colour": "red", "sides": 4, "angle": 36},
+ ],
+)
+def test_validate_type_flags_incorrect_data(data: typing.Any) -> None:
+ """
+ If you pass data that doesn't match the model to ``validate_type``,
+ it throws a ``ValidationError``.
+ """
+ with pytest.raises(ValidationError):
+ validate_type(data, model=Shape)
+
+
+def test_validate_type_allows_valid_data() -> None:
+ """
+ If you pass data which matches the model to ``validate_type``,
+ it passes without exception.
+ """
+ validate_type({"colour": "red", "sides": 4}, model=Shape)
+
+
+def test_validate_type_supports_builtin_list() -> None:
+ """
+ You can validate a list with ``validate_type``.
+ """
+ validate_type([1, 2, 3], model=list[int])
+
+
+def test_validate_type_supports_builtin_type() -> None:
+ """
+ You can validate a list with ``validate_type``.
+ """
+ validate_type(1, model=int)
+
+
+@pytest.mark.parametrize(
+ "data", [{"colour": "red", "sides": 4}, {"colour": "blue", "radius": 3}]
+)
+def test_validate_type_supports_union_type(data: typing.Any) -> None:
+ """
+ You can validate a type which is a union of two TypedDict's.
+ """
+ validate_type(data, model=Shape | Circle) # type: ignore
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {"colour": "red", "sides": 4, "name": "square"},
+ {"colour": "red", "sides": 4, "stroke": "black", "depth": 3},
+ ],
+)
+def test_validate_type_rejects_extra_fields(data: typing.Any) -> None:
+ """
+ Adding extra keys to a TypedDict is a validation error.
+ """
+ with pytest.raises(ValidationError):
+ validate_type(data, model=Shape)
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {"colour": "red", "sides": 4, "name": "square"},
+ {"colour": "red", "sides": 4, "stroke": "black", "depth": 3},
+ ],
+)
+def test_validate_type_of_union_rejects_extra_fields(data: typing.Any) -> None:
+ """
+ Adding extra keys to a Union of TypedDict's is a validation error.
+ """
+ with pytest.raises(ValidationError):
+ validate_type(data, model=Shape | Circle) # type: ignore
+
+
+def test_validate_type_does_not_change_data() -> None:
+ """
+ Check that ``validate_type`` does not change the value, merely make
+ assertions about the type.
+
+ This is a regression test for a bug I encountered in my bookmarks
+ project -- notice that `s` does not really conform to either type.
+
+ * If it's UncolouredShape, it shouldn't have a "type"
+ * If it's ColouredShape, it should have a "colour"
+
+ This appears to be caused by a bug in Pydantic, see
+ https://github.com/pydantic/pydantic/issues/11328
+
+ """
+
+ class UncolouredShape(typing.TypedDict):
+ sides: int
+
+ class ColouredShape(typing.TypedDict):
+ sides: str
+ colour: str
+ type: typing.Literal["coloured_shape"]
+
+ Shape = UncolouredShape | ColouredShape
+
+ s = {
+ "sides": 5,
+ "type": "coloured_shape",
+ }
+
+ with pytest.raises(ValidationError):
+ assert validate_type(s, model=Shape) == s # type: ignore