pydantic-construct 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydantic-construct
3
+ Version: 0.1.0
4
+ Summary: Interfaces between construct and Pydantic to allow typed serializing to bytes.
5
+ Keywords: pydantic,construct,binary,serializing
6
+ Author: Jay184
7
+ Author-email: Jay184 <me@jay0.mozmail.com>
8
+ License-Expression: 0BSD
9
+ Requires-Dist: construct>=2.10.70
10
+ Requires-Dist: construct-typing>=0.7.0
11
+ Requires-Dist: pydantic>=2.12.5
12
+ Maintainer: Jay184
13
+ Maintainer-email: Jay184 <me@jay0.mozmail.com>
14
+ Requires-Python: >=3.11
15
+ Project-URL: Issues, https://github.com/Jay184/pydantic-construct/issues
16
+ Project-URL: Repository, https://github.com/Jay184/pydantic-construct.git
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Pydantic-Construct
20
+
21
+ **Pydantic-Construct** integrates Pydantic with `construct` to provide **typed binary serialization and parsing** using standard Pydantic models.
22
+
23
+ Define binary layouts declaratively with type annotations, while keeping Pydantic’s validation and serialization.
24
+
25
+ ---
26
+
27
+ ## Features
28
+
29
+ * Declarative binary schemas via `Annotated`
30
+ * `model_dump_bytes()` / `model_validate_bytes()`
31
+ * Nested models
32
+ * Computed fields with ordering control
33
+ * Mode-aware field omission (`json`, `python`, `binary`)
34
+ * Async parsing from streams
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install pydantic-construct
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Quick Start
47
+
48
+ Minimal example:
49
+
50
+ ```python
51
+ from typing import Annotated
52
+ from construct import Int32ul
53
+ from pydantic_construct import ConstructModel
54
+
55
+ class Model(ConstructModel):
56
+ x: Annotated[int, Int32ul]
57
+
58
+ m = Model(x=123)
59
+
60
+ data = m.model_dump_bytes()
61
+ parsed = Model.model_validate_bytes(data)
62
+
63
+ assert data == b"\x7B\x00\x00\x00"
64
+ assert parsed.x == 123
65
+ ```
66
+
67
+ With padding (ignored outside binary mode):
68
+
69
+ ```python
70
+ from typing import Annotated
71
+ from pydantic_construct import ConstructModel, OmitInMode
72
+ from construct import Padding, Int32ul
73
+
74
+ class Model(ConstructModel):
75
+ x: Annotated[int, Int32ul]
76
+ pad: Annotated[bytes | None, Padding(4), OmitInMode({"json", "python"})] = None
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Core Concepts
82
+
83
+ ### Binary Fields
84
+
85
+ Each field must define a `construct` type via `Annotated`:
86
+
87
+ ```python
88
+ x: Annotated[int, Int32ul]
89
+ ```
90
+
91
+ ---
92
+
93
+ ### Mode-Based Omission
94
+
95
+ Exclude fields depending on serialization mode:
96
+
97
+ ```python
98
+ from typing import Annotated
99
+ from pydantic_construct import OmitInMode
100
+ from construct import Padding
101
+
102
+ pad: Annotated[
103
+ bytes | None,
104
+ Padding(4),
105
+ OmitInMode({"json", "python"})
106
+ ]
107
+ ```
108
+
109
+ Modes:
110
+
111
+ * `"python"`
112
+ * `"json"`
113
+ * `"binary"`
114
+
115
+ ---
116
+
117
+ ### Nested Models
118
+
119
+ ```python
120
+ from typing import Annotated
121
+ from pydantic_construct import ConstructModel
122
+ from construct import Int32ul
123
+
124
+ class Header(ConstructModel):
125
+ length: Annotated[int, Int32ul]
126
+
127
+ class Packet(ConstructModel):
128
+ header: Header
129
+ ```
130
+
131
+ ---
132
+
133
+ ### Computed Fields (Binary)
134
+
135
+ Computed fields can participate in binary serialization if they return `Annotated[..., Construct]`.
136
+
137
+ ```python
138
+ from typing import Annotated
139
+ from pydantic_construct import ConstructModel, binary_after
140
+ from pydantic import computed_field
141
+ from construct import Int32ul
142
+
143
+ class Example(ConstructModel):
144
+ x: Annotated[int, Int32ul]
145
+
146
+ @computed_field
147
+ @property
148
+ @binary_after("x")
149
+ def checksum(self) -> Annotated[int, Int32ul]:
150
+ return self.x ^ 0xFFFFFFFF
151
+ ```
152
+
153
+ Positioning:
154
+
155
+ * `@binary_after("field")`
156
+ * `@binary_before("field")`
157
+
158
+ ---
159
+
160
+ ## API Overview
161
+
162
+ ### Serialize to Binary
163
+
164
+ ```python
165
+ data = model.model_dump_bytes()
166
+ ```
167
+
168
+ ---
169
+
170
+ ### Parse from Binary
171
+
172
+ ```python
173
+ model = Model.model_validate_bytes(data)
174
+ ```
175
+
176
+ ---
177
+
178
+ ### Async Stream Parsing
179
+
180
+ ```python
181
+ model = await Model.model_validate_reader(reader)
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Design Notes
187
+
188
+ * A `construct.Struct` is generated at class creation time
189
+ * Field order is deterministic and includes computed fields
190
+ * Multiple `ConstructModel` roots in inheritance are disallowed
191
+
192
+ ---
193
+
194
+ ## Constraints
195
+
196
+ * Every field must define a `construct` type
197
+ * Computed fields must return `Annotated[..., Construct]`
198
+ * Binary layout must be deterministic
199
+
200
+ ---
201
+
202
+ ## License
203
+
204
+ 0BSD
@@ -0,0 +1,186 @@
1
+ # Pydantic-Construct
2
+
3
+ **Pydantic-Construct** integrates Pydantic with `construct` to provide **typed binary serialization and parsing** using standard Pydantic models.
4
+
5
+ Define binary layouts declaratively with type annotations, while keeping Pydantic’s validation and serialization.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ * Declarative binary schemas via `Annotated`
12
+ * `model_dump_bytes()` / `model_validate_bytes()`
13
+ * Nested models
14
+ * Computed fields with ordering control
15
+ * Mode-aware field omission (`json`, `python`, `binary`)
16
+ * Async parsing from streams
17
+
18
+ ---
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install pydantic-construct
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Quick Start
29
+
30
+ Minimal example:
31
+
32
+ ```python
33
+ from typing import Annotated
34
+ from construct import Int32ul
35
+ from pydantic_construct import ConstructModel
36
+
37
+ class Model(ConstructModel):
38
+ x: Annotated[int, Int32ul]
39
+
40
+ m = Model(x=123)
41
+
42
+ data = m.model_dump_bytes()
43
+ parsed = Model.model_validate_bytes(data)
44
+
45
+ assert data == b"\x7B\x00\x00\x00"
46
+ assert parsed.x == 123
47
+ ```
48
+
49
+ With padding (ignored outside binary mode):
50
+
51
+ ```python
52
+ from typing import Annotated
53
+ from pydantic_construct import ConstructModel, OmitInMode
54
+ from construct import Padding, Int32ul
55
+
56
+ class Model(ConstructModel):
57
+ x: Annotated[int, Int32ul]
58
+ pad: Annotated[bytes | None, Padding(4), OmitInMode({"json", "python"})] = None
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Core Concepts
64
+
65
+ ### Binary Fields
66
+
67
+ Each field must define a `construct` type via `Annotated`:
68
+
69
+ ```python
70
+ x: Annotated[int, Int32ul]
71
+ ```
72
+
73
+ ---
74
+
75
+ ### Mode-Based Omission
76
+
77
+ Exclude fields depending on serialization mode:
78
+
79
+ ```python
80
+ from typing import Annotated
81
+ from pydantic_construct import OmitInMode
82
+ from construct import Padding
83
+
84
+ pad: Annotated[
85
+ bytes | None,
86
+ Padding(4),
87
+ OmitInMode({"json", "python"})
88
+ ]
89
+ ```
90
+
91
+ Modes:
92
+
93
+ * `"python"`
94
+ * `"json"`
95
+ * `"binary"`
96
+
97
+ ---
98
+
99
+ ### Nested Models
100
+
101
+ ```python
102
+ from typing import Annotated
103
+ from pydantic_construct import ConstructModel
104
+ from construct import Int32ul
105
+
106
+ class Header(ConstructModel):
107
+ length: Annotated[int, Int32ul]
108
+
109
+ class Packet(ConstructModel):
110
+ header: Header
111
+ ```
112
+
113
+ ---
114
+
115
+ ### Computed Fields (Binary)
116
+
117
+ Computed fields can participate in binary serialization if they return `Annotated[..., Construct]`.
118
+
119
+ ```python
120
+ from typing import Annotated
121
+ from pydantic_construct import ConstructModel, binary_after
122
+ from pydantic import computed_field
123
+ from construct import Int32ul
124
+
125
+ class Example(ConstructModel):
126
+ x: Annotated[int, Int32ul]
127
+
128
+ @computed_field
129
+ @property
130
+ @binary_after("x")
131
+ def checksum(self) -> Annotated[int, Int32ul]:
132
+ return self.x ^ 0xFFFFFFFF
133
+ ```
134
+
135
+ Positioning:
136
+
137
+ * `@binary_after("field")`
138
+ * `@binary_before("field")`
139
+
140
+ ---
141
+
142
+ ## API Overview
143
+
144
+ ### Serialize to Binary
145
+
146
+ ```python
147
+ data = model.model_dump_bytes()
148
+ ```
149
+
150
+ ---
151
+
152
+ ### Parse from Binary
153
+
154
+ ```python
155
+ model = Model.model_validate_bytes(data)
156
+ ```
157
+
158
+ ---
159
+
160
+ ### Async Stream Parsing
161
+
162
+ ```python
163
+ model = await Model.model_validate_reader(reader)
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Design Notes
169
+
170
+ * A `construct.Struct` is generated at class creation time
171
+ * Field order is deterministic and includes computed fields
172
+ * Multiple `ConstructModel` roots in inheritance are disallowed
173
+
174
+ ---
175
+
176
+ ## Constraints
177
+
178
+ * Every field must define a `construct` type
179
+ * Computed fields must return `Annotated[..., Construct]`
180
+ * Binary layout must be deterministic
181
+
182
+ ---
183
+
184
+ ## License
185
+
186
+ 0BSD
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "pydantic-construct"
3
+ version = "0.1.0"
4
+ description = "Interfaces between construct and Pydantic to allow typed serializing to bytes."
5
+ keywords = ["pydantic", "construct", "binary", "serializing"]
6
+ authors = [
7
+ {name = "Jay184", email = "me@jay0.mozmail.com"},
8
+ ]
9
+ maintainers = [
10
+ {name = "Jay184", email = "me@jay0.mozmail.com"},
11
+ ]
12
+ license = "0BSD"
13
+ readme = "README.md"
14
+ requires-python = ">=3.11"
15
+ dependencies = [
16
+ "construct>=2.10.70",
17
+ "construct-typing>=0.7.0",
18
+ "pydantic>=2.12.5",
19
+ ]
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/Jay184/pydantic-construct.git"
23
+ Issues = "https://github.com/Jay184/pydantic-construct/issues"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "coverage>=7.13.5",
28
+ "invoke>=2.2.1",
29
+ "mypy>=1.19.1",
30
+ "pytest>=9.0.2",
31
+ "pytest-randomly>=4.0.1",
32
+ "hypothesis>=6.151.10",
33
+ "ruff>=0.15.8",
34
+ ]
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.8.12,<0.9.0"]
38
+ build-backend = "uv_build"
@@ -0,0 +1,15 @@
1
+ from .base import (
2
+ ConstructModel,
3
+ OmitInMode,
4
+ Mode,
5
+ binary_before,
6
+ binary_after,
7
+ )
8
+
9
+ __all__ = [
10
+ "ConstructModel",
11
+ "OmitInMode",
12
+ "Mode",
13
+ "binary_before",
14
+ "binary_after",
15
+ ]
@@ -0,0 +1,304 @@
1
+ from typing import ClassVar, Any, Self, Literal, Callable, Iterable
2
+ from typing import Annotated, get_origin, get_args
3
+ from typing_extensions import Buffer
4
+ from functools import lru_cache
5
+ from asyncio import StreamReader
6
+
7
+ import dataclasses
8
+
9
+ from pydantic import BaseModel, model_serializer, main
10
+ from construct import Construct, Container, Struct
11
+ from pydantic_core.core_schema import SerializerFunctionWrapHandler, SerializationInfo
12
+
13
+
14
+ def extract_construct(annotation):
15
+ """Extract Construct instance from Annotated[...]"""
16
+ if get_origin(annotation) is Annotated:
17
+ base_type, *metadata = get_args(annotation)
18
+
19
+ for meta in metadata:
20
+ if isinstance(meta, Construct):
21
+ return meta
22
+
23
+ return None
24
+
25
+
26
+ def binary_after(field_name: str):
27
+ def decorator(func):
28
+ setattr(func, "__binary_after__", field_name)
29
+ return func
30
+ return decorator
31
+
32
+
33
+ def binary_before(field_name: str):
34
+ def decorator(func):
35
+ setattr(func, "__binary_before__", field_name)
36
+ return func
37
+ return decorator
38
+
39
+
40
+ Mode = Literal["python", "json", "binary"]
41
+
42
+
43
+ @dataclasses.dataclass
44
+ class OmitInMode:
45
+ modes: set[Mode] = dataclasses.field(default_factory=lambda: {"json"})
46
+
47
+ def __init__(self, modes: Mode | Iterable[Mode] = "json"):
48
+ if isinstance(modes, str):
49
+ self.modes = {modes}
50
+ else:
51
+ self.modes = set(modes)
52
+
53
+ def matches(self, current_mode: str) -> bool:
54
+ return current_mode in self.modes
55
+
56
+
57
+ class ConstructModel(BaseModel):
58
+ # Dynamically generated struct
59
+ struct: ClassVar[Struct]
60
+ _computed_subcons: ClassVar[dict[str, Construct]]
61
+
62
+ # Cached order for computed fields
63
+ _binary_final_order: ClassVar[list[str]]
64
+ _binary_ordered_struct: ClassVar[Struct]
65
+
66
+ @model_serializer(mode="wrap")
67
+ def exclude_omissions(
68
+ self,
69
+ handler: SerializerFunctionWrapHandler,
70
+ info: SerializationInfo,
71
+ ) -> dict[str, object]:
72
+ serialized = handler(self)
73
+ cls = type(self)
74
+
75
+ return {
76
+ name: value
77
+ for name, value in serialized.items()
78
+ if not cls._is_omitted_in_mode(name, info.mode)
79
+ }
80
+
81
+ @classmethod
82
+ def __pydantic_init_subclass__(cls, **kwargs):
83
+ """Hook for Pydantic subclass initialization."""
84
+ super().__pydantic_init_subclass__(**kwargs)
85
+
86
+ roots = cls._get_construct_roots()
87
+
88
+ if len(roots) > 1:
89
+ raise TypeError(
90
+ f"{cls.__name__} has multiple ConstructModel roots: "
91
+ f"{[r.__name__ for r in roots]}. "
92
+ "This leads to ambiguous binary layout. Use composition instead."
93
+ )
94
+
95
+ subcons = {}
96
+ computed_subcons = {}
97
+
98
+ for name, field in cls.model_fields.items():
99
+ if cls._is_omitted_in_mode(name, mode="binary"):
100
+ continue
101
+
102
+ construct_type = None
103
+
104
+ if isinstance(field.annotation, type) and issubclass(field.annotation, ConstructModel):
105
+ construct_type = field.annotation.struct
106
+ else:
107
+ for meta in field.metadata:
108
+ if isinstance(meta, Construct):
109
+ construct_type = meta
110
+ break
111
+
112
+ if construct_type is None:
113
+ raise TypeError(
114
+ f"Field '{name}' must be Annotated with a Construct type"
115
+ )
116
+
117
+ subcons[name] = construct_type
118
+
119
+ for name, field in cls.model_computed_fields.items():
120
+ if cls._is_omitted_in_mode(name, mode="binary"):
121
+ continue
122
+
123
+ construct_type = extract_construct(field.return_type)
124
+
125
+ if construct_type is None:
126
+ raise TypeError(
127
+ f"Computed field '{name}' must return Annotated[..., Construct]"
128
+ )
129
+
130
+ computed_subcons[name] = construct_type
131
+
132
+ cls.struct = Struct(**subcons)
133
+ cls._computed_subcons = computed_subcons
134
+
135
+ base_order = list(subcons.keys())
136
+ final_order = base_order.copy()
137
+
138
+ for name, cons in computed_subcons.items():
139
+ func = getattr(cls, name).fget
140
+ after = getattr(func, "__binary_after__", None)
141
+ before = getattr(func, "__binary_before__", None)
142
+
143
+ if after and before:
144
+ raise TypeError(f"{name} cannot have both before and after")
145
+ if after:
146
+ idx = final_order.index(after) + 1
147
+ elif before:
148
+ idx = final_order.index(before)
149
+ else:
150
+ idx = len(final_order)
151
+ final_order.insert(idx, name)
152
+
153
+ cls._binary_final_order = final_order
154
+
155
+ # Build a cached Struct for serialization
156
+ ordered_subcons = []
157
+ for name in final_order:
158
+ if name in subcons:
159
+ ordered_subcons.append(name / subcons[name])
160
+ elif name in computed_subcons:
161
+ ordered_subcons.append(name / computed_subcons[name])
162
+
163
+ cls._binary_ordered_struct = Struct(*ordered_subcons)
164
+
165
+ @classmethod
166
+ def _get_construct_roots(cls: type):
167
+ roots = set()
168
+
169
+ for base in cls.__mro__:
170
+ if (
171
+ isinstance(base, type)
172
+ and issubclass(base, ConstructModel)
173
+ and base is not ConstructModel
174
+ ):
175
+ # Check if this base has a ConstructModel parent (excluding base class)
176
+ has_construct_parent = any(
177
+ issubclass(parent, ConstructModel)
178
+ and parent is not ConstructModel
179
+ for parent in base.__bases__
180
+ )
181
+
182
+ if not has_construct_parent:
183
+ roots.add(base)
184
+
185
+ return roots
186
+
187
+ @classmethod
188
+ def _is_omitted_in_mode(cls, name: str, mode: Mode | str) -> bool:
189
+ print(name, mode)
190
+ return any(
191
+ isinstance(meta, OmitInMode) and meta.matches(mode)
192
+ for meta in cls._get_field_metadata(name)
193
+ )
194
+
195
+ @classmethod
196
+ @lru_cache
197
+ def _get_field_metadata(cls, name: str):
198
+ # Regular field
199
+ if name in cls.model_fields:
200
+ return cls.model_fields[name].metadata
201
+
202
+ # Computed field
203
+ if name in cls.model_computed_fields:
204
+ annotation = cls.model_computed_fields[name].return_type
205
+ if get_origin(annotation) is Annotated:
206
+ return get_args(annotation)[1:]
207
+ return ()
208
+
209
+ # Unknown field
210
+ return ()
211
+
212
+ @classmethod
213
+ def model_validate_bytes(
214
+ cls,
215
+ obj: bytes | bytearray | Buffer,
216
+ *,
217
+ strict: bool | None = None,
218
+ extra: main.ExtraValues | None = None,
219
+ from_attributes: bool | None = None,
220
+ context: Any | None = None,
221
+ by_alias: bool | None = None,
222
+ by_name: bool | None = None,
223
+ ) -> Self:
224
+ parsed: Container = cls.struct.parse(obj)
225
+
226
+ filtered = {
227
+ k: v for k, v in dict(parsed).items()
228
+ if not k.startswith("_")
229
+ }
230
+
231
+ return cls.model_validate(
232
+ filtered,
233
+ strict=strict,
234
+ extra=extra,
235
+ from_attributes=from_attributes,
236
+ context=context,
237
+ by_alias=by_alias,
238
+ by_name=by_name,
239
+ )
240
+
241
+ def model_dump_bytes(
242
+ self,
243
+ *,
244
+ mode: Literal["json", "python", "binary"] | str = "binary",
245
+ include: main.IncEx | None = None,
246
+ exclude: main.IncEx | None = None,
247
+ context: Any | None = None,
248
+ by_alias: bool | None = None,
249
+ exclude_unset: bool = False,
250
+ exclude_defaults: bool = False,
251
+ exclude_none: bool = False,
252
+ exclude_computed_fields: bool = False,
253
+ round_trip: bool = False,
254
+ warnings: bool | Literal["none", "warn", "error"] = True,
255
+ fallback: Callable[[Any], Any] | None = None,
256
+ serialize_as_any: bool = False,
257
+ ) -> bytes:
258
+ data = self.model_dump(
259
+ mode=mode,
260
+ include=include,
261
+ exclude=exclude,
262
+ context=context,
263
+ by_alias=by_alias,
264
+ exclude_unset=exclude_unset,
265
+ exclude_defaults=exclude_defaults,
266
+ exclude_none=exclude_none,
267
+ exclude_computed_fields=exclude_computed_fields,
268
+ round_trip=round_trip,
269
+ warnings=warnings,
270
+ fallback=fallback,
271
+ serialize_as_any=serialize_as_any,
272
+ )
273
+
274
+ # Only include fields that exist in struct
275
+ # noinspection PyProtectedMember
276
+ values = {
277
+ k: v for k, v in data.items()
278
+ if k in self.struct._subcons or k in self._computed_subcons
279
+ }
280
+
281
+ return self._binary_ordered_struct.build(values)
282
+
283
+ @classmethod
284
+ async def model_validate_reader(
285
+ cls,
286
+ obj: StreamReader,
287
+ *,
288
+ strict: bool | None = None,
289
+ extra: main.ExtraValues | None = None,
290
+ from_attributes: bool | None = None,
291
+ context: Any | None = None,
292
+ by_alias: bool | None = None,
293
+ by_name: bool | None = None,
294
+ ) -> Self:
295
+ data = await obj.readexactly(cls.struct.sizeof())
296
+ return cls.model_validate_bytes(
297
+ data,
298
+ strict=strict,
299
+ extra=extra,
300
+ from_attributes=from_attributes,
301
+ context=context,
302
+ by_alias=by_alias,
303
+ by_name=by_name,
304
+ )