Skip to content

Commit 33eb542

Browse files
committed
yo...
1 parent a99f4be commit 33eb542

File tree

8 files changed

+187
-33
lines changed

8 files changed

+187
-33
lines changed

_setup.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55
name="python/exacting/",
66
ext_modules=cythonize(
77
[
8-
"python/exacting/etypes.py",
9-
"python/exacting/dc.py",
10-
"python/exacting/core.py",
118
]
129
),
1310
package_dir={"": "python"},

python/exacting/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .core import Exact, exact
2+
from .fields import field
23
from .types import ValidationError
34
from .validators import (
45
AnnotatedV,
@@ -40,5 +41,6 @@
4041
"StrV",
4142
"UnionV",
4243
"Validator",
43-
"unsafe"
44+
"unsafe",
45+
"field"
4446
]

python/exacting/core.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .result import Result
1111
from .utils import get_field_value, unsafe_mode
1212

13-
from .exacting import py_to_bytes
13+
from .exacting import bytes_to_py, json_to_py, jsonc_to_py, py_to_bytes
1414

1515

1616
def get_exact_init(dc: DataclassType):
@@ -88,19 +88,51 @@ def __init_subclass__(cls) -> None:
8888
exact(cls)
8989

9090
def exact_as_dict(self) -> Dict[str, Any]:
91-
"""Get this model instance as dictionary."""
91+
"""Get this model instance as a dictionary."""
9292
return asdict(self)
9393

9494
def exact_as_json(self) -> str:
9595
"""Get this model instance as JSON."""
9696
return json.dumps(self.exact_as_dict())
9797

9898
def exact_as_bytes(self) -> bytes:
99-
"""Get this model instance as bytes with `rkyv`."""
99+
"""Get this model instance as bytes with `rkyv`.
100+
101+
This may **not** be super efficient.
102+
"""
100103
return py_to_bytes(self.exact_as_dict())
101104

102105
@classmethod
103106
def exact_from_dict(cls, d: Dict[str, Any]) -> Self:
107+
"""(exacting) Get this model from a raw dictionary."""
108+
res = cls.__validator__.validate(d, from_dict=True)
109+
res.raise_for_err()
110+
return res.unwrap()
111+
112+
@classmethod
113+
def exact_from_json(cls, raw: str, /, *, strict: bool = True) -> Self:
114+
"""(exacting) Get this model from raw JSON.
115+
116+
When strict mode is set to `False`, you could use JSON with comments
117+
and more modern features.
118+
119+
Args:
120+
raw (str): The raw JSON data.
121+
strict (bool): Whether to turn strict mode on.
122+
"""
123+
if strict:
124+
d = json_to_py(raw)
125+
else:
126+
d = jsonc_to_py(raw)
127+
128+
res = cls.__validator__.validate(d, from_dict=True)
129+
res.raise_for_err()
130+
return res.unwrap()
131+
132+
@classmethod
133+
def exact_from_bytes(cls, raw: bytes) -> Self:
134+
"""(exacting) Get this model from raw bytes."""
135+
d = bytes_to_py(raw)
104136
res = cls.__validator__.validate(d, from_dict=True)
105137
res.raise_for_err()
106138
return res.unwrap()

python/exacting/fields.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import dataclasses as std_dc
2+
from dataclasses import MISSING
3+
4+
from typing import Any, Callable, List, Type, TypeVar, Union
5+
6+
from .validators import Validator, MinMaxV, RegexV
7+
from .types import _Optional
8+
9+
T = TypeVar("T")
10+
11+
12+
class ExactField:
13+
validators: List[Validator]
14+
15+
def __init__(self, validators: List[Validator]):
16+
self.validators = validators
17+
18+
19+
def field(
20+
typ: _Optional[Type[T]] = MISSING,
21+
*,
22+
default: _Optional[T] = MISSING,
23+
default_factory: _Optional[Callable[[], T]] = MISSING,
24+
hash: _Optional[bool] = MISSING,
25+
regex: _Optional[str] = MISSING,
26+
minv: _Optional[Union[int, float]] = MISSING,
27+
maxv: _Optional[Union[int, float]] = MISSING,
28+
) -> Any:
29+
validators = []
30+
if regex is not MISSING:
31+
validators.append(RegexV(regex))
32+
33+
if minv is not MISSING or maxv is not MISSING:
34+
validators.append(MinMaxV(minv, maxv))
35+
36+
if default is not MISSING:
37+
return std_dc.field(
38+
default=default,
39+
metadata={"exact": ExactField(validators)},
40+
hash=None if hash is MISSING else hash,
41+
)
42+
elif default_factory is not MISSING:
43+
return std_dc.field(
44+
default_factory=default_factory,
45+
metadata={"exact": ExactField(validators)},
46+
hash=None if hash is MISSING else hash,
47+
)
48+
else:
49+
return std_dc.field(
50+
metadata={"exact": ExactField(validators)},
51+
hash=None if hash is MISSING else hash,
52+
)

python/exacting/types.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import dataclasses as std_dc
22

3-
from typing import Any, Dict, Protocol, Type, Union
3+
from typing import Any, Dict, Protocol, Type, TypeVar, Union
44

55

66
class Dataclass(Protocol):
@@ -13,7 +13,7 @@ class Dataclass(Protocol):
1313
class _Indexable:
1414
def __getitem__(self, k: str): ...
1515
def __setitem__(self, k: str, data: Any): ...
16-
def get(self, k: str): ...
16+
def get(self, k: str) -> Any: ...
1717
def as_dict(self) -> dict: ...
1818
def as_dc(self) -> Dataclass: ...
1919

@@ -67,3 +67,7 @@ def indexable(item: Any) -> "_Indexable":
6767

6868
class ValidationError(RuntimeError):
6969
"""Validation error for `exacting`."""
70+
71+
72+
T = TypeVar("T")
73+
_Optional = Union[T, std_dc._MISSING_TYPE]

python/exacting/validator_map.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import dataclasses
22
from dataclasses import is_dataclass
3+
from weakref import ref
4+
35
from types import NoneType, UnionType
46
from typing import Annotated, Any, Dict, Literal, Union, get_origin, get_type_hints
5-
from weakref import ref
67

78
from .validators import (
89
AnnotatedV,

python/exacting/validators.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from abc import ABC
2-
from dataclasses import is_dataclass
3-
from typing import Any, Dict, List, Type, TypeVar
2+
from dataclasses import MISSING, is_dataclass
3+
from typing import Any, Dict, List, Type, TypeVar, Union
44
from weakref import ReferenceType
55

6-
7-
from .types import DataclassType, indexable
6+
from .types import DataclassType, indexable, _Optional
87
from .result import Result
98
from .utils import get_field_value, unsafe
9+
from .exacting import Regex
1010

1111
T = TypeVar("T")
1212

@@ -256,7 +256,18 @@ def validate(self, value: Any, **options) -> Result:
256256
f"During validation of dataclass {self!r} at field {name!r}, got:"
257257
)
258258

259-
data[name] = field_res.unwrap()
259+
field_value = field_res.unwrap()
260+
261+
ef = field.metadata.get("exact")
262+
if ef:
263+
validator_items: List[Validator] = ef.validators
264+
for item in validator_items:
265+
fv_res = item.validate(field_value)
266+
if not fv_res.is_ok():
267+
return fv_res
268+
field_value = fv_res.unwrap()
269+
270+
data[name] = field_value
260271

261272
if options.get("from_dict"):
262273
with unsafe():
@@ -271,3 +282,66 @@ def __repr__(self):
271282
if dc is None:
272283
raise RuntimeError("Weakref is gone")
273284
return dc.__name__
285+
286+
287+
class RegexV(Validator):
288+
regex: Regex
289+
pattern: str
290+
291+
def __init__(self, pattern: str):
292+
self.regex = Regex(pattern)
293+
self.pattern = pattern
294+
295+
def validate(self, value: Any, **options) -> "Result":
296+
res = expect(str, value)
297+
if not res.is_ok():
298+
return res
299+
300+
data = res.unwrap()
301+
if not self.regex.validate(data):
302+
return Result.Err(f"Regex validation {self.pattern!r} on str failed")
303+
304+
return Result.Ok(data)
305+
306+
307+
class MinMaxV(Validator):
308+
minv: _Optional[Union[int, float]]
309+
maxv: _Optional[Union[int, float]]
310+
311+
def __init__(
312+
self,
313+
minv: _Optional[Union[int, float]] = MISSING,
314+
maxv: _Optional[Union[int, float]] = MISSING,
315+
):
316+
self.minv = minv
317+
self.maxv = maxv
318+
319+
def validate(self, value: Any, **options) -> "Result":
320+
if hasattr(value, "__len__"):
321+
ln = len(value)
322+
if self.minv is not MISSING:
323+
if ln < self.minv:
324+
return Result.Err(f"Expected min length of {self.minv}, got {ln}")
325+
if self.maxv is not MISSING:
326+
if ln > self.maxv:
327+
return Result.Err(f"Expected max length of {self.minv}, got {ln}")
328+
329+
elif hasattr(value, "__lt__") and hasattr(value, "__gt__"):
330+
if self.minv is not MISSING:
331+
if value < self.minv:
332+
return Result.Err(
333+
f"Expected min value of {self.minv}, got {value!r}"
334+
)
335+
336+
if self.maxv is not MISSING:
337+
if value > self.maxv:
338+
return Result.Err(
339+
f"Expected max value of {self.minv}, got {value!r}"
340+
)
341+
342+
else:
343+
return Result.Err(
344+
f"Neither len(), >, or < can be tested for type {type(value)}"
345+
)
346+
347+
return Result.Ok(value)

test.py

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,16 @@
1-
from typing import Annotated, Literal
2-
from exacting import Exact, unsafe
1+
from exacting import Exact, field
32

43

5-
class Comment(Exact):
6-
user: str
7-
title: str
8-
stars: int
9-
body: str | None = None
4+
class Stuff(Exact):
5+
cool: bool
106

117

12-
class Place(Exact):
13-
name: str
14-
location: str
15-
comments: list[Comment]
8+
class Comment(Exact):
9+
user: str
10+
stars: int = field(minv=1, maxv=5)
11+
stuff: Stuff
1612

1713

18-
with unsafe():
19-
d = Place.__unsafe_init__(
20-
name="McDonald's",
21-
location="McDonald's Rd.",
22-
comments=[Comment.__unsafe_init__(user="Waltuh", title="ITBOY", stars=2)],
23-
).exact_as_dict()
24-
print(Place.exact_from_dict(d))
14+
b = Comment(user="Waltuh", stars=3, stuff=Stuff(cool=True)).exact_as_bytes()
15+
print(b)
16+
print(Comment.exact_from_bytes(b))

0 commit comments

Comments
 (0)