Skip to content

Commit 3fba795

Browse files
committed
up
1 parent b6f55fa commit 3fba795

21 files changed

Lines changed: 823 additions & 229 deletions

Cargo.lock

Lines changed: 311 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ crate-type = ["cdylib"]
1010

1111
[dependencies]
1212
anyhow = "1.0.98"
13+
fancy-regex = "0.14.0"
1314
ijson = "0.1.4"
1415
pyo3 = { version = "0.25.0", features = ["anyhow"] }
16+
rkyv = "0.8.10"
1517
serde = "1.0.219"
1618
serde_json = "1.0.140"
1719
serde_json5 = "0.2.1"

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 AWeirdDev
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
File renamed without changes.

docs/index.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Welcome to MkDocs
2+
3+
For full documentation visit [mkdocs.org](https://www.mkdocs.org).
4+
5+
## Commands
6+
7+
* `mkdocs new [dir-name]` - Create a new project.
8+
* `mkdocs serve` - Start the live-reloading docs server.
9+
* `mkdocs build` - Build the documentation site.
10+
* `mkdocs -h` - Print help message and exit.
11+
12+
## Project layout
13+
14+
mkdocs.yml # The configuration file.
15+
docs/
16+
index.md # The documentation homepage.
17+
... # Other markdown pages, images and other files.

mkdocs.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
site_name: Exacting
2+
site_url: https://github.com/AWeirdDev/exacting
3+
repo_url: https://github.com/AWeirdDev/exacting
4+
theme:
5+
palette:
6+
scheme: slate
7+
name: material
8+
icon:
9+
logo: material/filter
10+
font:
11+
text: Inter
12+
code: Roboto Mono
13+
heading: Inter
14+
palette:
15+
- scheme: default
16+
toggle:
17+
icon: material/brightness-7
18+
name: Switch to dark mode
19+
- scheme: slate
20+
media: "(prefers-color-scheme: dark)"
21+
toggle:
22+
icon: material/brightness-4
23+
name: Switch to light mode
24+
features:
25+
- content.code.copy
26+
- content.code.annotate
27+
extra:
28+
generator: false
29+
social:
30+
- icon: fontawesome/brands/github
31+
link: https://github.com/AWeirdDev
32+
name: AWeirdDev on GitHub
33+
markdown_extensions:
34+
- attr_list
35+
- pymdownx.emoji:
36+
emoji_index: !!python/name:material.extensions.emoji.twemoji
37+
emoji_generator: !!python/name:material.extensions.emoji.to_svg
38+
- pymdownx.highlight:
39+
anchor_linenums: true
40+
- pymdownx.superfences

pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ build-backend = "maturin"
44

55
[project]
66
name = "exacting"
7+
description = "A picky dataclass runtime utility collection, enforcing strict types and validations."
8+
keywords = ["schema", "validation", "dataclasses", "json", "data-structure"]
9+
authors = [
10+
{ name = "AWeirdDev", email = "awdjared@gmail.com" },
11+
]
12+
license = { file = "LICENSE" }
13+
readme = "README.md"
714
dependencies = ["typing-extensions"]
815
requires-python = ">=3.8"
916
classifiers = [
@@ -12,10 +19,15 @@ classifiers = [
1219
"Programming Language :: Python :: Implementation :: PyPy",
1320
]
1421
dynamic = ["version"]
22+
23+
[project.urls]
24+
"Source" = "https://github.com/AWeirdDev/exacting"
25+
1526
[project.optional-dependencies]
1627
tests = [
1728
"pytest",
1829
]
30+
1931
[tool.maturin]
2032
python-source = "python"
2133
features = ["pyo3/extension-module"]

python/exacting/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
AnnotatedType,
2222
)
2323
from .dc import get_etypes_for_dc, get_etype_from_type
24-
25-
from .core import Exact, exact
24+
from .core import Exact
25+
from .fields import field
2626

2727
__all__ = [
2828
"expect",
@@ -48,5 +48,5 @@
4848
"get_etypes_for_dc",
4949
"get_etype_from_type",
5050
"Exact",
51-
"exact",
51+
"field",
5252
]

python/exacting/core.py

Lines changed: 58 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
from typing import TYPE_CHECKING, Callable, List, Type, TypeVar
1+
from typing import Callable, List, Type, TypeVar
22
from typing_extensions import Self, dataclass_transform
33

44
import dataclasses
55
from dataclasses import asdict, dataclass, is_dataclass
66

77
from .dc import get_etypes_for_dc
8-
from .exacting import json as ejson # type: ignore
8+
from .exacting import bytes_to_py, json_to_py, jsonc_to_py, py_to_bytes
9+
from .types import NOTHING
910

1011
T = TypeVar("T", bound=Type)
1112

@@ -32,93 +33,58 @@ def get_exact_init(dc: Type) -> Callable:
3233
etypes = get_etypes_for_dc(dc)
3334
dc.__exact_types__ = etypes
3435

35-
def init(self, *args, **kwargs):
36-
covered = len(args) + len(kwargs)
37-
38-
if covered != len(etypes):
39-
raise ValueError(
40-
f"(dataclass {dc.__name__!r}) Expected to cover {len(etypes)} parameter(s), but got {covered}"
41-
)
42-
43-
if args:
44-
keys_map = list(etypes) # keys
45-
46-
for idx, value in enumerate(args):
47-
k = keys_map[idx]
48-
res = etypes[keys_map[idx]].validate(value)
49-
if res.has_error():
50-
raise TypeError(
51-
get_exact_error_message(
52-
[
53-
f"During validation of dataclass {dc.__name__!r} at attribute {k!r}, a type error occurred:",
54-
*res.errors,
55-
]
56-
)
36+
def init(self, **kwargs):
37+
for key, value in etypes.items():
38+
provided_value = kwargs.get(key, NOTHING)
39+
if provided_value is NOTHING:
40+
field = dc.__dataclass_fields__[key]
41+
if field.default is not dataclasses.MISSING:
42+
provided_value = field.default
43+
elif field.default_factory is not dataclasses.MISSING:
44+
provided_value = field.default_factory()
45+
else:
46+
raise ValueError(
47+
f"Error while validating dataclass {dc.__name__!r} at attribute {key!r}:\n"
48+
"Field has no value provided."
5749
)
58-
setattr(self, k, res.ok)
59-
60-
for key, value in kwargs.items():
61-
res = etypes[key].validate(value)
62-
if res.has_error():
63-
raise TypeError(
64-
get_exact_error_message(
65-
[
66-
f"During validation of dataclass {dc.__name__!r} at attribute {key!r}, a type error occurred:",
67-
*res.errors,
68-
]
69-
)
50+
51+
res = value.validate(provided_value)
52+
if res.has_error():
53+
raise TypeError(
54+
get_exact_error_message(
55+
[
56+
f"Error while validating dataclass {dc.__name__!r} at attribute {key!r}:",
57+
*res.errors,
58+
]
7059
)
71-
setattr(self, key, res.ok)
60+
)
7261

73-
else:
74-
for key, value in kwargs.items():
75-
res = etypes[key].validate(value)
76-
if res.has_error():
77-
raise TypeError(
78-
get_exact_error_message(
79-
[
80-
f"During validation of dataclass {dc.__name__!r} at attribute {key!r}, a type error occurred:",
81-
*res.errors,
82-
]
62+
target = res.ok
63+
validators = dc.__dataclass_fields__[key].metadata.get(
64+
"exacting_validators"
65+
)
66+
if validators is not None:
67+
for validator in validators:
68+
res_t = validator.validate(target)
69+
if res_t.has_error():
70+
raise TypeError(
71+
get_exact_error_message(
72+
[
73+
f"Error while validating dataclass {dc.__name__!r} at attribute {key!r} (field validator):",
74+
*res_t.errors,
75+
]
76+
)
8377
)
84-
)
85-
setattr(self, key, res.ok)
78+
target = res_t.ok
79+
80+
setattr(self, key, target)
8681

8782
return None # required!
8883

8984
return init
9085

9186

92-
def _patch():
93-
original = dataclasses.dataclass
94-
95-
def new(item=None, **kwargs):
96-
if item is None:
97-
98-
def wrapper(t):
99-
x = original(t)
100-
init = get_exact_init(x)
101-
setattr(x, "__init__", init)
102-
return x
103-
104-
return wrapper
105-
else:
106-
x = dataclass(item)
107-
init = get_exact_init(x)
108-
setattr(x, "__init__", init)
109-
return x
110-
111-
return new
112-
113-
114-
# sneaky trick
115-
if TYPE_CHECKING:
116-
exact = dataclasses.dataclass
117-
else:
118-
exact = _patch()
119-
120-
121-
@dataclass_transform()
87+
@dataclass_transform(kw_only_default=True)
12288
class _ModelKwOnly: ...
12389

12490

@@ -128,7 +94,8 @@ class Exact(_ModelKwOnly):
12894
"""
12995

13096
def __init_subclass__(cls) -> None:
131-
setattr(cls, "__init__", get_exact_init(dataclass(cls)))
97+
init = get_exact_init(dataclass(cls))
98+
setattr(cls, "__init__", init)
13299

133100
def exact_as_dict(self):
134101
"""(exacting) Creates a dictionary representation of this dataclass instance.
@@ -139,6 +106,10 @@ def exact_as_dict(self):
139106
assert is_dataclass(self)
140107
return asdict(self)
141108

109+
def exact_as_bytes(self) -> bytes:
110+
"""(exacting) Convert this instance of dataclass model into bytes."""
111+
return py_to_bytes(self.exact_as_dict())
112+
142113
@classmethod
143114
def exact_from_json(cls, raw: str, *, strict: bool = True) -> Self:
144115
"""(exacting) Initialize this dataclass model from JSON.
@@ -178,8 +149,13 @@ class Person(Exact):
178149
strict (bool): Whether to use strict mode.
179150
"""
180151
if strict:
181-
data = ejson.json_to_py(raw)
152+
data = json_to_py(raw)
182153
else:
183-
data = ejson.jsonc_to_py(raw)
154+
data = jsonc_to_py(raw)
184155

185156
return cls(**data)
157+
158+
@classmethod
159+
def exact_from_bytes(cls, raw: bytes) -> Self:
160+
data = bytes_to_py(raw)
161+
return cls(**data)

python/exacting/dc.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DataclassType,
1313
DictType,
1414
ListType,
15+
LiteralType,
1516
eany,
1617
enone,
1718
estr,
@@ -56,6 +57,8 @@ def get_etype_from_type(typ: Type) -> BaseType:
5657
return AnnotatedType(
5758
get_etype_from_type(typ.__args__[0]), list(typ.__metadata__)
5859
)
60+
elif origin is typing.Literal:
61+
return LiteralType(list(typ.__args__))
5962

6063
raise TypeError(f"Unknown type: {typ!r}")
6164

0 commit comments

Comments
 (0)