Skip to main content

Merge pull request #30 from alexwlchan/work-around-typing-bug

ID
cbd03c1
date
2025-04-13 20:54:29+00:00
author
Alex Chan <alex@alexwlchan.net>
parents
ee337c4, 98d1aab
message
Merge pull request #30 from alexwlchan/work-around-typing-bug

Fix a bug in the validation of union types
changed files
5 files, 191 additions, 6 deletions

Changed files

CHANGELOG.md (2182) → CHANGELOG.md (3196)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7ad325..d12433f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,32 @@
 # CHANGELOG
 
-## v1.2.0
+## 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.
 
@@ -12,7 +38,7 @@ This is useful if you want to check your data or you write typed Python.
 
 You need to install the typed extra to get this function, i.e. `pip install javascript-data-files[typed]`.
 
-## v1.1.1
+## v1.1.1 - 2025-01-10
 
 Tweak the way the JavaScript is encoded to make it slightly more compact and readable -- in particular, short lists will now be encoded as a single line, rather than split across multiple lines.
 

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 034b15b..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.1.1"
+__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