Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: test
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv tool install hatch
- run: |
hatch test --cover --python ${{ matrix.python-version }}
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
dist/
/dist/
/**/__pycache__/
/credentials
/.coverage
/htmlcov
.DS_Store
39 changes: 33 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,22 +1,49 @@
[project]
name = "latch-data-validation"
version = "0.1.12"
version = "0.1.13"
description = "Runtime type validation"
authors = [{ name = "maximsmol", email = "max@latch.bio" }]
dependencies = ["opentelemetry-api>=1.15.0"]
requires-python = ">=3.11.0"
dependencies = [
"typing-extensions>=4.15.0",
]
requires-python = ">=3.10.0"
readme = "README.md"
license = { text = "CC0-1.0" }

[project.optional-dependencies]
otel = ["opentelemetry-api>=1.15.0"]

[dependency-groups]
dev = [
{include-group = "lint"},
{include-group = "test"}
]
lint = [
"ruff"
]
test = [
"pytest",
"syrupy"
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv]
dev-dependencies = ["ruff>=0.9.5"]
[tool.hatch.envs.default]
installer = "uv"

[tool.hatch.envs.hatch-test]
dependency-groups = ["test"]

[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.14", "3.13", "3.12", "3.11", "3.10"]

[tool.hatch.build.targets.sdist]
exclude = ["tests"]

[tool.ruff]
target-version = "py311"
target-version = "py310"

[tool.ruff.lint]
preview = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import dataclasses
import sys
import typing
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Iterable, Sequence
from contextlib import suppress
from enum import Enum
from itertools import chain
from types import FrameType, NoneType, UnionType
Expand All @@ -13,16 +14,23 @@
NewType,
TypeAlias,
TypeVar,
Union,
Union, # pyright: ignore[reportDeprecated]
get_args,
get_origin,
get_type_hints,
overload,
)
from typing import ( # noqa: UP035
Mapping as MappingOld, # pyright: ignore[reportDeprecated]
)
from typing import ( # noqa: UP035
Sequence as SequenceOld, # pyright: ignore[reportDeprecated]
)

from opentelemetry.trace import get_tracer
from typing_extensions import NotRequired, Required

tracer = get_tracer(__name__)
get_tracer = None
with suppress(ImportError):
from opentelemetry.trace import get_tracer

forward_frames: dict[int, FrameType] = {}
real_init = ForwardRef.__init__
Expand All @@ -45,8 +53,10 @@ def init(self, *args, **kwargs):

T = TypeVar("T")

JsonArray: TypeAlias = Sequence["JsonValue"]
JsonObject: TypeAlias = Mapping[str, "JsonValue"]
# `collections.abc` types do not create `ForwardRef` instances for us to patch
# so we can't support the non-deprecated type. It works with `type T = ...` syntax though
JsonArray: TypeAlias = SequenceOld["JsonValue"]
JsonObject: TypeAlias = MappingOld[str, "JsonValue"]
JsonValue: TypeAlias = JsonObject | JsonArray | str | int | float | bool | None


Expand Down Expand Up @@ -92,7 +102,9 @@ def json(self) -> JsonValue:
return dict(
msg=self.msg,
val=self.val,
cls=self.cls.__qualname__,
cls=self.cls.__qualname__
if hasattr(self.cls, "__qualname__")
else type(self.cls).__qualname__,
details=self.details,
children=[(x[0], x[1].json()) for x in self.children],
)
Expand Down Expand Up @@ -153,15 +165,20 @@ def __str__(self) -> str:
# todo(maximsmol): generics
# todo(maximsmol): typing
def untraced_validate(x: JsonValue, cls: type[T]) -> T:
if cls is None:
if x is not None:
raise DataValidationError("expected None", x, cls)
return None

if dataclasses.is_dataclass(cls) and isinstance(x, cls):
return x

if isinstance(cls, ForwardRef):
fr = typing.cast(ForwardRef, cls)
fr = typing.cast(ForwardRef, cls) # pyright: ignore[reportUnreachable]

frame = forward_frames.get(id(cls))
if frame is None:
raise DataValidationError("untraced ForwardRef", x, cls)
raise ValueError(f"untraced ForwardRef: {cls!r}")

f_globals = frame.f_globals
f_locals = frame.f_locals
Expand All @@ -171,10 +188,13 @@ def untraced_validate(x: JsonValue, cls: type[T]) -> T:
next = f_locals.get(fr.__forward_arg__)

if next is None:
raise DataValidationError("unresolvable ForwardRef", x, cls)
raise ValueError(f"unresolvable ForwardRef: {cls!r}")

return untraced_validate(x, next)

if isinstance(cls, str):
raise ValueError(f"untraced ForwardRef: {cls!r}")

if cls is Any:
return x

Expand Down Expand Up @@ -326,6 +346,22 @@ def untraced_validate(x: JsonValue, cls: type[T]) -> T:

ts = get_args(cls)

if len(ts) == 2 and ts[1] is ...:
res: list[object] = []
errors: DataValidationErrorChildren = []
for idx, item in enumerate(x):
try:
res.append(untraced_validate(item, ts[0]))
except DataValidationError as e:
errors.append((f"item {idx + 1}", e))

if len(errors) > 0:
raise DataValidationError(
"tuple items did not match schema", x, cls, children=errors
)

return origin(res)

res: list[object] = []
errors: DataValidationErrorChildren = []
for idx, (item, item_type) in enumerate(zip(x, ts)):
Expand Down Expand Up @@ -392,19 +428,31 @@ def untraced_validate(x: JsonValue, cls: type[T]) -> T:
for k in chain(cls.__required_keys__, cls.__optional_keys__):
schema_fields.add(k)
if k not in x:
if k in cls.__required_keys__:
# check origin to support Python versions that do not have native NotRequired
if get_origin(types[k]) is NotRequired:
continue

if k in cls.__required_keys__ or get_origin(types[k]) is Required:
missing_fields.append("- " + repr(k))

continue

try:
fields[k] = untraced_validate(x[k], types[k])
typ = types[k]

origin = get_origin(typ)
if origin is NotRequired or origin is Required:
typ = get_args(typ)[0]

fields[k] = untraced_validate(x[k], typ)
except DataValidationError as e:
errors.append((f"field {k!r} did not match schema", e))

for k in x.keys():
if k in schema_fields:
continue

# todo(maximsmol): after PEP 728, default should be open `TypedDict`s
extraneous_fields.append(f"- {k!r}")

if len(missing_fields) > 0 or len(extraneous_fields) > 0 or len(errors) > 0:
Expand Down Expand Up @@ -468,13 +516,23 @@ def untraced_validate(x: JsonValue, cls: type[T]) -> T:


def validate(x: JsonValue, cls: type[T]) -> T:
if get_tracer is None:
return untraced_validate(x, cls)

tracer = get_tracer(__name__)

with tracer.start_as_current_span(
validate.__qualname__,
attributes={
"code.function": validate.__name__,
"code.namespace": validate.__module__,
},
) as s:
s.set_attribute("validation.target", cls.__qualname__)
s.set_attribute(
"validation.target",
cls.__qualname__
if hasattr(cls, "__qualname__")
else type(cls).__qualname__,
)

return untraced_validate(x, cls)
File renamed without changes.
Empty file added tests/__init__.py
Empty file.
26 changes: 26 additions & 0 deletions tests/__snapshots__/test_basic.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# serializer version: 1
# name: test_explain
'''

Dictionary did not match schema:
Missing fields:
- 'a'
Extraneous fields:
- 'c'
- Field 'b' did not match schema:
List items did not match schema:
- Item 1:
Mapping items did not match schema:
- Value for key '1':
Expected an integer:
'hello'
did not match
<class 'int'>
- Item 2:
Mapping type does not match:
True
did not match
dict[str, int]

'''
# ---
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"children": [],
"cls": "test_dataclass.<locals>.Test",
"details": {
"extraneous fields": [
"- 'extra'"
]
},
"msg": "dataclass did not match schema",
"val": {
"a": 123,
"b": false,
"c": {
"o1": {
"zzz": "world"
},
"o2": {
"zzz": "hello"
}
},
"extra": "123",
"nullable": null
}
}
22 changes: 22 additions & 0 deletions tests/__snapshots__/test_basic/test_dataclass[missing field].json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"children": [],
"cls": "test_dataclass.<locals>.Test",
"details": {
"missing fields": [
"- 'nullable'"
]
},
"msg": "dataclass did not match schema",
"val": {
"a": 123,
"b": false,
"c": {
"o1": {
"zzz": "world"
},
"o2": {
"zzz": "hello"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"untraced ForwardRef: 'BrokenJsonValue2'"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"untraced ForwardRef: ForwardRef('BrokenJsonValue')"
18 changes: 18 additions & 0 deletions tests/__snapshots__/test_basic/test_smoke[bad dict key].json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"children": [
[
"key True",
{
"children": [],
"cls": "str",
"details": {},
"msg": "expected a string",
"val": true
}
]
],
"cls": "dict",
"details": {},
"msg": "mapping items did not match schema",
"val": {}
}
20 changes: 20 additions & 0 deletions tests/__snapshots__/test_basic/test_smoke[bad dict value].json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"children": [
[
"value for key 'hello'",
{
"children": [],
"cls": "int",
"details": {},
"msg": "expected an integer",
"val": true
}
]
],
"cls": "dict",
"details": {},
"msg": "mapping items did not match schema",
"val": {
"hello": true
}
}
7 changes: 7 additions & 0 deletions tests/__snapshots__/test_basic/test_smoke[bad enum].json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"children": [],
"cls": "test_smoke.<locals>.Stuff",
"details": {},
"msg": "enum value did not match",
"val": 3
}
Loading
Loading