Skip to content

Commit 1b2720a

Browse files
committed
ooga booga
1 parent f5bbc62 commit 1b2720a

9 files changed

Lines changed: 186 additions & 38 deletions

File tree

README.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
44
`exacting` is a picky dataclass runtime utility collection, making sure all type annotations are followed.
55

6-
Yes, **type hints are always on.** No hidden stuff.
6+
Essentially... **THE** go-to option for dataclasses. heh.
7+
8+
**Key features**:
9+
10+
- **100% static typing.** Because I hate nothing too.
11+
- Up to **10x faster** than [`pydantic`](https://pydantic.dev)! (Them: 60ms, us: 6~9ms)
712

813
## Examples
914
```python
10-
from exacting import exact
15+
from exacting import Exact
1116

12-
@exact
13-
class Person:
17+
class Person(Exact):
1418
name: str
1519
age: int
1620

@@ -26,18 +30,17 @@ Person(name="John", age=1.23)
2630
# Thankfully, `exacting` gives us an error message:
2731
#
2832
# TypeError:
29-
# During validation of dataclass Person, a type error occurred: -
33+
# During validation of dataclass 'Person' at attribute 'age', a type error occurred:
3034
# (isinstance) Expected <class 'int'>, got: <class 'float'>
31-
# ...at attribute 'age'
3235
```
3336

34-
Also, you can type anything! (Almost.) Cool.
37+
Also, you can type anything! Almost.
3538

3639
```python
3740
@exact
3841
class Stuff:
39-
banana: str | int | bool
4042
apple: Optional[str]
43+
banana: str | int | bool
4144

4245
# ...they all work!
4346
```

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "exacting"
7+
dependencies = ["typing-extensions"]
78
requires-python = ">=3.8"
89
classifiers = [
910
"Programming Language :: Rust",

python/exacting/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
DataclassType,
1919
NoneType,
2020
AnyType,
21+
AnnotatedType,
2122
)
2223
from .dc import get_etypes_for_dc, get_etype_from_type
2324

@@ -41,6 +42,7 @@
4142
"DictType",
4243
"UnionType",
4344
"DataclassType",
45+
"AnnotatedType",
4446
"NoneType",
4547
"AnyType",
4648
"get_etypes_for_dc",

python/exacting/core.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import TYPE_CHECKING, Callable, List, Type, TypeVar
2+
from typing_extensions import dataclass_transform
23

34
import dataclasses
45
from dataclasses import asdict, dataclass, is_dataclass
@@ -28,6 +29,7 @@ def get_exact_error_message(errors: List[str]) -> str:
2829

2930
def get_exact_init(dc: Type) -> Callable:
3031
etypes = get_etypes_for_dc(dc)
32+
dc.__exact_types__ = etypes
3133

3234
def init(self, *args, **kwargs):
3335
covered = len(args) + len(kwargs)
@@ -115,16 +117,17 @@ def wrapper(t):
115117
exact = _patch()
116118

117119

118-
class Exact:
119-
"""This `Exact` model appends additional functionalities to the current dataclass.
120+
@dataclass_transform()
121+
class _ModelKwOnly: ...
120122

123+
124+
class Exact(_ModelKwOnly):
125+
"""
121126
All the APIs are prefixed with `exact_` to add clarity.
122127
"""
123128

124-
def __init__(self):
125-
raise NotImplementedError(
126-
"The class `Exact` should not be initialized directly, but inherited."
127-
)
129+
def __init_subclass__(cls) -> None:
130+
setattr(cls, "__init__", get_exact_init(dataclass(cls)))
128131

129132
def exact_as_dict(self):
130133
"""Creates a dictionary representation of this dataclass instance.

python/exacting/dc.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from .types import Dataclass
99
from .etypes import (
10+
AnnotatedType,
1011
BaseType,
1112
DataclassType,
1213
DictType,
@@ -51,6 +52,10 @@ def get_etype_from_type(typ: Type) -> BaseType:
5152
return DictType(
5253
get_etype_from_type(typ.__args__[0]), get_etype_from_type(typ.__args__[1])
5354
)
55+
elif origin is typing.Annotated:
56+
return AnnotatedType(
57+
get_etype_from_type(typ.__args__[0]), list(typ.__metadata__)
58+
)
5459

5560
raise TypeError(f"Unknown type: {typ!r}")
5661

python/exacting/etypes.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,9 @@ def __init__(self, name: str, target: typing.Dict[str, BaseType]):
292292

293293
def validate(self, x: Any) -> TypeResult[Any]:
294294
if not is_dataclass(x):
295-
raise TypeError(f"Expected dataclass instance, got: {x!r}")
295+
return TypeResult(
296+
errors=[f"Expected dataclass instance ({self.name!r}), got: {x!r}"]
297+
)
296298

297299
for name, etype in self.target.items():
298300
item = getattr(x, name)
@@ -314,3 +316,15 @@ def validate(self, x: Any) -> TypeResult[Any]:
314316

315317
def __repr__(self) -> str:
316318
return self.name
319+
320+
321+
class AnnotatedType(BaseType[Any]):
322+
target: BaseType
323+
items: typing.List[Any]
324+
325+
def __init__(self, target: BaseType, items: typing.List[Any]):
326+
self.target = target
327+
self.items = items
328+
329+
def validate(self, x: Any) -> TypeResult[Any]:
330+
return self.target.validate(x)

setup.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from setuptools import setup
2+
from Cython.Build import cythonize
3+
4+
setup(
5+
name="python/exacting/",
6+
ext_modules=cythonize(
7+
[
8+
"python/exacting/etypes.py",
9+
"python/exacting/dc.py",
10+
"python/exacting/core.py",
11+
]
12+
),
13+
package_dir={"": "python"},
14+
)

test.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +0,0 @@
1-
from dataclasses import dataclass
2-
from typing import Optional
3-
from exacting import Exact, exact
4-
5-
6-
@exact
7-
class Experience(Exact):
8-
title: str
9-
years: Optional[int] = None
10-
11-
12-
@exact
13-
class Person(Exact):
14-
name: str
15-
age: int
16-
experiences: list[Experience]
17-
18-
19-
Person(
20-
name="John",
21-
age=123,
22-
experiences=[Experience(title=b"WHEN I POOPPED OFF", years=123)],
23-
)

test_pydantic.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import time
2+
from typing import Union
3+
from exacting import Exact
4+
5+
start = time.perf_counter()
6+
7+
8+
class AnotherDataclass(Exact):
9+
id: int
10+
payload: str
11+
flag: bool
12+
checksum: bytes
13+
14+
15+
class Config(Exact):
16+
retries: int
17+
timeout: float
18+
use_ssl: bool
19+
endpoint: str
20+
21+
22+
class Metadata(Exact):
23+
tags: list[str]
24+
created_at: str
25+
version: Union[str, int]
26+
notes: Union[None, str, bytes]
27+
28+
29+
class NestedLevelThree(Exact):
30+
data: list[AnotherDataclass]
31+
metadata: Metadata
32+
value: Union[int, float, str, bool]
33+
binary: bytes
34+
35+
36+
class NestedLevelTwo(Exact):
37+
name: str
38+
configs: list[Config]
39+
children: list[NestedLevelThree]
40+
stats: dict[str, Union[int, float]]
41+
optional_info: Union[str, None, AnotherDataclass]
42+
43+
44+
class NestedLevelOne(Exact):
45+
key: str
46+
nested: NestedLevelTwo
47+
mixed_list: list[Union[str, int, AnotherDataclass]]
48+
fallback: Union[AnotherDataclass, bool]
49+
retry_limit: int
50+
51+
52+
class UltimateRoot(Exact):
53+
identifier: str
54+
deep: list[NestedLevelOne]
55+
extra: dict[str, list[AnotherDataclass]]
56+
settings: Config
57+
blob: Union[bytes, str, list[Union[str, bytes]]]
58+
flags: list[bool]
59+
60+
61+
test_data = UltimateRoot(
62+
identifier="root-001",
63+
deep=[
64+
NestedLevelOne(
65+
key="level-one-a",
66+
nested=NestedLevelTwo(
67+
name="level-two-a",
68+
configs=[
69+
Config(
70+
retries=3,
71+
timeout=1.5,
72+
use_ssl=True,
73+
endpoint="https://api.service",
74+
),
75+
Config(
76+
retries=5,
77+
timeout=2.0,
78+
use_ssl=False,
79+
endpoint="http://backup.service",
80+
),
81+
],
82+
children=[
83+
NestedLevelThree(
84+
data=[
85+
AnotherDataclass(
86+
id=1, payload="data-1", flag=True, checksum=b"\x01\x02"
87+
),
88+
AnotherDataclass(
89+
id=2, payload="data-2", flag=False, checksum=b"\x03\x04"
90+
),
91+
],
92+
metadata=Metadata(
93+
tags=["alpha", "beta"],
94+
created_at="2023-04-01T12:00:00Z",
95+
version="v1.0",
96+
notes=b"Some binary notes",
97+
),
98+
value=True,
99+
binary=b"\x99\x88",
100+
)
101+
],
102+
stats={"requests": 1234, "load": 0.85},
103+
optional_info=AnotherDataclass(
104+
id=99, payload="optional", flag=True, checksum=b"\xff"
105+
),
106+
),
107+
mixed_list=[
108+
"string",
109+
42,
110+
AnotherDataclass(id=77, payload="mixed", flag=False, checksum=b"\xab"),
111+
],
112+
fallback=True,
113+
retry_limit=10,
114+
)
115+
],
116+
extra={
117+
"groupA": [
118+
AnotherDataclass(id=3, payload="x", flag=True, checksum=b"\xde"),
119+
AnotherDataclass(id=4, payload="y", flag=False, checksum=b"\xad"),
120+
]
121+
},
122+
settings=Config(
123+
retries=1, timeout=0.5, use_ssl=True, endpoint="https://fast.service"
124+
),
125+
blob=[b"chunk1", b"chunk2", "fallback text"],
126+
flags=[True, False, True],
127+
)
128+
129+
print((time.perf_counter() - start) * 1000, "ms")

0 commit comments

Comments
 (0)