base-typed-id 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.
- base_typed_id-0.1.0/LICENSE +9 -0
- base_typed_id-0.1.0/PKG-INFO +225 -0
- base_typed_id-0.1.0/README.md +176 -0
- base_typed_id-0.1.0/pyproject.toml +143 -0
- base_typed_id-0.1.0/setup.cfg +4 -0
- base_typed_id-0.1.0/src/base_typed_id/__init__.py +15 -0
- base_typed_id-0.1.0/src/base_typed_id/_base_typed_id.py +153 -0
- base_typed_id-0.1.0/src/base_typed_id/_exceptions.py +10 -0
- base_typed_id-0.1.0/src/base_typed_id/factories.py +76 -0
- base_typed_id-0.1.0/src/base_typed_id/py.typed +0 -0
- base_typed_id-0.1.0/src/base_typed_id.egg-info/PKG-INFO +225 -0
- base_typed_id-0.1.0/src/base_typed_id.egg-info/SOURCES.txt +17 -0
- base_typed_id-0.1.0/src/base_typed_id.egg-info/dependency_links.txt +1 -0
- base_typed_id-0.1.0/src/base_typed_id.egg-info/requires.txt +30 -0
- base_typed_id-0.1.0/src/base_typed_id.egg-info/top_level.txt +1 -0
- base_typed_id-0.1.0/tests/test_base_typed_id.py +78 -0
- base_typed_id-0.1.0/tests/test_factories.py +98 -0
- base_typed_id-0.1.0/tests/test_pickle_roundtrip.py +21 -0
- base_typed_id-0.1.0/tests/test_pydantic_integration.py +82 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eldeniz Guseinli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: base-typed-id
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Strict typed UUID identifier base class with exact runtime subtype preservation and optional Pydantic v2 support.
|
|
5
|
+
Author-email: Eldeniz Guseinli <eldenizfamilyanskicode@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/eldenizfamilyanskicode/base-typed-id
|
|
8
|
+
Project-URL: Repository, https://github.com/eldenizfamilyanskicode/base-typed-id
|
|
9
|
+
Project-URL: Issues, https://github.com/eldenizfamilyanskicode/base-typed-id/issues
|
|
10
|
+
Keywords: typing,uuid,typed-id,value-object,pydantic,domain-model
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: pydantic
|
|
25
|
+
Requires-Dist: pydantic<3,>=2.6; extra == "pydantic"
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
29
|
+
Requires-Dist: pydantic<3,>=2.6; extra == "test"
|
|
30
|
+
Provides-Extra: lint
|
|
31
|
+
Requires-Dist: ruff>=0.5; extra == "lint"
|
|
32
|
+
Provides-Extra: typecheck
|
|
33
|
+
Requires-Dist: mypy>=1.10; extra == "typecheck"
|
|
34
|
+
Requires-Dist: pyright>=1.1; extra == "typecheck"
|
|
35
|
+
Requires-Dist: pydantic<3,>=2.6; extra == "typecheck"
|
|
36
|
+
Provides-Extra: build
|
|
37
|
+
Requires-Dist: build>=1.2; extra == "build"
|
|
38
|
+
Requires-Dist: twine>=5.1; extra == "build"
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
41
|
+
Requires-Dist: twine>=5.1; extra == "dev"
|
|
42
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
43
|
+
Requires-Dist: pyright>=1.1; extra == "dev"
|
|
44
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
46
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
47
|
+
Requires-Dist: pydantic<3,>=2.6; extra == "dev"
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
# base-typed-id
|
|
51
|
+
|
|
52
|
+
Strict typed UUID identifier base class with exact runtime subtype preservation and optional Pydantic v2 support.
|
|
53
|
+
|
|
54
|
+
## Why
|
|
55
|
+
|
|
56
|
+
`BaseTypedId` lets you define domain-specific UUID-backed string subtypes such as `UserId`, `OrderId`, or `ExternalEventId`.
|
|
57
|
+
|
|
58
|
+
Goals:
|
|
59
|
+
|
|
60
|
+
- exact runtime subtype preservation
|
|
61
|
+
- plain `str` compatibility
|
|
62
|
+
- plain string serialization
|
|
63
|
+
- pickle roundtrip support
|
|
64
|
+
- Pydantic v2 support
|
|
65
|
+
- OpenAPI `format: uuid`
|
|
66
|
+
|
|
67
|
+
## Why not `NewType`, `Annotated[str, ...]`, or wrapper value objects?
|
|
68
|
+
|
|
69
|
+
There are several ways to model typed identifiers in Python. This library focuses on one specific trade-off: keeping a domain-specific runtime subtype while staying fully compatible with plain `str`.
|
|
70
|
+
|
|
71
|
+
### Why not `typing.NewType`?
|
|
72
|
+
|
|
73
|
+
`NewType` is excellent when you only need a static type distinction for type checkers.
|
|
74
|
+
|
|
75
|
+
However, at runtime a `NewType` value is still just a plain `str`. It does not:
|
|
76
|
+
|
|
77
|
+
- preserve an exact runtime subtype
|
|
78
|
+
- validate UUID format on construction
|
|
79
|
+
- provide subtype-preserving pickle behavior
|
|
80
|
+
- integrate as a real runtime subtype inside containers and model fields
|
|
81
|
+
|
|
82
|
+
Use `NewType` when static typing alone is enough.
|
|
83
|
+
|
|
84
|
+
### Why not `Annotated[str, ...]`?
|
|
85
|
+
|
|
86
|
+
`Annotated` is useful for attaching metadata to a type, especially for validators and frameworks.
|
|
87
|
+
|
|
88
|
+
But it still does not create a distinct runtime type. If you need runtime identity such as `type(user_id) is UserId`, `Annotated[str, ...]` is not enough.
|
|
89
|
+
|
|
90
|
+
### Why not wrapper value objects?
|
|
91
|
+
|
|
92
|
+
A wrapper class such as `UserId(value: str)` gives a stronger domain boundary and is often a good choice in rich domain models.
|
|
93
|
+
|
|
94
|
+
The trade-off is interoperability friction:
|
|
95
|
+
|
|
96
|
+
- it is no longer a plain string
|
|
97
|
+
- JSON serialization usually needs custom handling
|
|
98
|
+
- dictionary key compatibility is less transparent
|
|
99
|
+
- many integrations require explicit `.value` extraction
|
|
100
|
+
|
|
101
|
+
Use a wrapper when you want additional domain behavior beyond typed identity.
|
|
102
|
+
|
|
103
|
+
### What this library optimizes for
|
|
104
|
+
|
|
105
|
+
`BaseTypedId` is for the narrower case where you want all of the following at once:
|
|
106
|
+
|
|
107
|
+
- exact runtime subtype preservation
|
|
108
|
+
- plain `str` compatibility
|
|
109
|
+
- UUID parsing and version checks at the boundary
|
|
110
|
+
- plain string serialization
|
|
111
|
+
- Pydantic v2 / OpenAPI compatibility
|
|
112
|
+
- pickle roundtrip support
|
|
113
|
+
|
|
114
|
+
## When not to use this library
|
|
115
|
+
|
|
116
|
+
This library is not the best fit if:
|
|
117
|
+
|
|
118
|
+
- static-only type distinction is enough for you (`NewType` may be simpler)
|
|
119
|
+
- you want rich domain behavior on the identifier itself (a wrapper value object may be better)
|
|
120
|
+
- your identifiers are not UUID-based
|
|
121
|
+
|
|
122
|
+
## Installation
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pip install base-typed-id
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
With Pydantic support:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pip install "base-typed-id[pydantic]"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Basic usage
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from base_typed_id import BaseTypedId
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class UserId(BaseTypedId):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
|
|
145
|
+
generated_user_id: UserId = UserId()
|
|
146
|
+
|
|
147
|
+
assert type(user_id) is UserId
|
|
148
|
+
assert isinstance(user_id, str)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## UUID version control
|
|
152
|
+
|
|
153
|
+
By default, `BaseTypedId` expects UUID v4.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from base_typed_id import BaseTypedId
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ExternalEventId(BaseTypedId):
|
|
160
|
+
uuid_version = 5
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`uuid_version = None` disables version restriction.
|
|
164
|
+
|
|
165
|
+
## Pydantic v2
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from pydantic import BaseModel
|
|
169
|
+
|
|
170
|
+
from base_typed_id import BaseTypedId
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class UserId(BaseTypedId):
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class UserModel(BaseModel):
|
|
178
|
+
user_id: UserId
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Behavior:
|
|
182
|
+
|
|
183
|
+
* inside model: exact subtype is preserved
|
|
184
|
+
* after `model_dump()` / `model_dump_json()`: plain string is exported
|
|
185
|
+
* generated schema keeps `type: string` and `format: uuid`
|
|
186
|
+
|
|
187
|
+
## Deterministic identifiers
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from base_typed_id import BaseTypedId
|
|
191
|
+
from base_typed_id.factories import deterministically_from_words
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ExternalEventId(BaseTypedId):
|
|
195
|
+
uuid_version = 5
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
event_id: ExternalEventId = deterministically_from_words(
|
|
199
|
+
ExternalEventId,
|
|
200
|
+
words=[
|
|
201
|
+
"workspace:house-of-ai",
|
|
202
|
+
"provider:telegram",
|
|
203
|
+
"event:message-created",
|
|
204
|
+
"message:42",
|
|
205
|
+
],
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Rules:
|
|
210
|
+
|
|
211
|
+
* same words -> same identifier
|
|
212
|
+
* order matters
|
|
213
|
+
* deterministic generation requires `uuid_version = 5` or `uuid_version = None`
|
|
214
|
+
|
|
215
|
+
## Guarantees
|
|
216
|
+
|
|
217
|
+
* exact subtype is preserved in runtime objects
|
|
218
|
+
* exact subtype is preserved in containers
|
|
219
|
+
* exact subtype is preserved through pickle roundtrip
|
|
220
|
+
* serialized/exported representation is plain string
|
|
221
|
+
|
|
222
|
+
## Non-goals
|
|
223
|
+
|
|
224
|
+
* no extra domain behavior beyond typed identity
|
|
225
|
+
* no automatic semantic validation beyond UUID parsing/version checks
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# base-typed-id
|
|
2
|
+
|
|
3
|
+
Strict typed UUID identifier base class with exact runtime subtype preservation and optional Pydantic v2 support.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
`BaseTypedId` lets you define domain-specific UUID-backed string subtypes such as `UserId`, `OrderId`, or `ExternalEventId`.
|
|
8
|
+
|
|
9
|
+
Goals:
|
|
10
|
+
|
|
11
|
+
- exact runtime subtype preservation
|
|
12
|
+
- plain `str` compatibility
|
|
13
|
+
- plain string serialization
|
|
14
|
+
- pickle roundtrip support
|
|
15
|
+
- Pydantic v2 support
|
|
16
|
+
- OpenAPI `format: uuid`
|
|
17
|
+
|
|
18
|
+
## Why not `NewType`, `Annotated[str, ...]`, or wrapper value objects?
|
|
19
|
+
|
|
20
|
+
There are several ways to model typed identifiers in Python. This library focuses on one specific trade-off: keeping a domain-specific runtime subtype while staying fully compatible with plain `str`.
|
|
21
|
+
|
|
22
|
+
### Why not `typing.NewType`?
|
|
23
|
+
|
|
24
|
+
`NewType` is excellent when you only need a static type distinction for type checkers.
|
|
25
|
+
|
|
26
|
+
However, at runtime a `NewType` value is still just a plain `str`. It does not:
|
|
27
|
+
|
|
28
|
+
- preserve an exact runtime subtype
|
|
29
|
+
- validate UUID format on construction
|
|
30
|
+
- provide subtype-preserving pickle behavior
|
|
31
|
+
- integrate as a real runtime subtype inside containers and model fields
|
|
32
|
+
|
|
33
|
+
Use `NewType` when static typing alone is enough.
|
|
34
|
+
|
|
35
|
+
### Why not `Annotated[str, ...]`?
|
|
36
|
+
|
|
37
|
+
`Annotated` is useful for attaching metadata to a type, especially for validators and frameworks.
|
|
38
|
+
|
|
39
|
+
But it still does not create a distinct runtime type. If you need runtime identity such as `type(user_id) is UserId`, `Annotated[str, ...]` is not enough.
|
|
40
|
+
|
|
41
|
+
### Why not wrapper value objects?
|
|
42
|
+
|
|
43
|
+
A wrapper class such as `UserId(value: str)` gives a stronger domain boundary and is often a good choice in rich domain models.
|
|
44
|
+
|
|
45
|
+
The trade-off is interoperability friction:
|
|
46
|
+
|
|
47
|
+
- it is no longer a plain string
|
|
48
|
+
- JSON serialization usually needs custom handling
|
|
49
|
+
- dictionary key compatibility is less transparent
|
|
50
|
+
- many integrations require explicit `.value` extraction
|
|
51
|
+
|
|
52
|
+
Use a wrapper when you want additional domain behavior beyond typed identity.
|
|
53
|
+
|
|
54
|
+
### What this library optimizes for
|
|
55
|
+
|
|
56
|
+
`BaseTypedId` is for the narrower case where you want all of the following at once:
|
|
57
|
+
|
|
58
|
+
- exact runtime subtype preservation
|
|
59
|
+
- plain `str` compatibility
|
|
60
|
+
- UUID parsing and version checks at the boundary
|
|
61
|
+
- plain string serialization
|
|
62
|
+
- Pydantic v2 / OpenAPI compatibility
|
|
63
|
+
- pickle roundtrip support
|
|
64
|
+
|
|
65
|
+
## When not to use this library
|
|
66
|
+
|
|
67
|
+
This library is not the best fit if:
|
|
68
|
+
|
|
69
|
+
- static-only type distinction is enough for you (`NewType` may be simpler)
|
|
70
|
+
- you want rich domain behavior on the identifier itself (a wrapper value object may be better)
|
|
71
|
+
- your identifiers are not UUID-based
|
|
72
|
+
|
|
73
|
+
## Installation
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install base-typed-id
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With Pydantic support:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install "base-typed-id[pydantic]"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Basic usage
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from base_typed_id import BaseTypedId
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class UserId(BaseTypedId):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
|
|
96
|
+
generated_user_id: UserId = UserId()
|
|
97
|
+
|
|
98
|
+
assert type(user_id) is UserId
|
|
99
|
+
assert isinstance(user_id, str)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## UUID version control
|
|
103
|
+
|
|
104
|
+
By default, `BaseTypedId` expects UUID v4.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from base_typed_id import BaseTypedId
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ExternalEventId(BaseTypedId):
|
|
111
|
+
uuid_version = 5
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`uuid_version = None` disables version restriction.
|
|
115
|
+
|
|
116
|
+
## Pydantic v2
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from pydantic import BaseModel
|
|
120
|
+
|
|
121
|
+
from base_typed_id import BaseTypedId
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class UserId(BaseTypedId):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class UserModel(BaseModel):
|
|
129
|
+
user_id: UserId
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Behavior:
|
|
133
|
+
|
|
134
|
+
* inside model: exact subtype is preserved
|
|
135
|
+
* after `model_dump()` / `model_dump_json()`: plain string is exported
|
|
136
|
+
* generated schema keeps `type: string` and `format: uuid`
|
|
137
|
+
|
|
138
|
+
## Deterministic identifiers
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from base_typed_id import BaseTypedId
|
|
142
|
+
from base_typed_id.factories import deterministically_from_words
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ExternalEventId(BaseTypedId):
|
|
146
|
+
uuid_version = 5
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
event_id: ExternalEventId = deterministically_from_words(
|
|
150
|
+
ExternalEventId,
|
|
151
|
+
words=[
|
|
152
|
+
"workspace:house-of-ai",
|
|
153
|
+
"provider:telegram",
|
|
154
|
+
"event:message-created",
|
|
155
|
+
"message:42",
|
|
156
|
+
],
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Rules:
|
|
161
|
+
|
|
162
|
+
* same words -> same identifier
|
|
163
|
+
* order matters
|
|
164
|
+
* deterministic generation requires `uuid_version = 5` or `uuid_version = None`
|
|
165
|
+
|
|
166
|
+
## Guarantees
|
|
167
|
+
|
|
168
|
+
* exact subtype is preserved in runtime objects
|
|
169
|
+
* exact subtype is preserved in containers
|
|
170
|
+
* exact subtype is preserved through pickle roundtrip
|
|
171
|
+
* serialized/exported representation is plain string
|
|
172
|
+
|
|
173
|
+
## Non-goals
|
|
174
|
+
|
|
175
|
+
* no extra domain behavior beyond typed identity
|
|
176
|
+
* no automatic semantic validation beyond UUID parsing/version checks
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "base-typed-id"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Strict typed UUID identifier base class with exact runtime subtype preservation and optional Pydantic v2 support."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Eldeniz Guseinli", email = "eldenizfamilyanskicode@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
license = "MIT"
|
|
15
|
+
keywords = [
|
|
16
|
+
"typing",
|
|
17
|
+
"uuid",
|
|
18
|
+
"typed-id",
|
|
19
|
+
"value-object",
|
|
20
|
+
"pydantic",
|
|
21
|
+
"domain-model",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 3 - Alpha",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
33
|
+
"Typing :: Typed",
|
|
34
|
+
]
|
|
35
|
+
dependencies = []
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
pydantic = [
|
|
39
|
+
"pydantic>=2.6,<3",
|
|
40
|
+
]
|
|
41
|
+
test = [
|
|
42
|
+
"pytest>=8.0",
|
|
43
|
+
"pytest-cov>=5.0",
|
|
44
|
+
"pydantic>=2.6,<3",
|
|
45
|
+
]
|
|
46
|
+
lint = [
|
|
47
|
+
"ruff>=0.5",
|
|
48
|
+
]
|
|
49
|
+
typecheck = [
|
|
50
|
+
"mypy>=1.10",
|
|
51
|
+
"pyright>=1.1",
|
|
52
|
+
"pydantic>=2.6,<3",
|
|
53
|
+
]
|
|
54
|
+
build = [
|
|
55
|
+
"build>=1.2",
|
|
56
|
+
"twine>=5.1",
|
|
57
|
+
]
|
|
58
|
+
dev = [
|
|
59
|
+
"build>=1.2",
|
|
60
|
+
"twine>=5.1",
|
|
61
|
+
"mypy>=1.10",
|
|
62
|
+
"pyright>=1.1",
|
|
63
|
+
"pytest>=8.0",
|
|
64
|
+
"pytest-cov>=5.0",
|
|
65
|
+
"ruff>=0.5",
|
|
66
|
+
"pydantic>=2.6,<3",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[project.urls]
|
|
70
|
+
Homepage = "https://github.com/eldenizfamilyanskicode/base-typed-id"
|
|
71
|
+
Repository = "https://github.com/eldenizfamilyanskicode/base-typed-id"
|
|
72
|
+
Issues = "https://github.com/eldenizfamilyanskicode/base-typed-id/issues"
|
|
73
|
+
|
|
74
|
+
[tool.setuptools]
|
|
75
|
+
package-dir = { "" = "src" }
|
|
76
|
+
include-package-data = true
|
|
77
|
+
|
|
78
|
+
[tool.setuptools.packages.find]
|
|
79
|
+
where = ["src"]
|
|
80
|
+
include = ["base_typed_id*"]
|
|
81
|
+
|
|
82
|
+
[tool.setuptools.package-data]
|
|
83
|
+
base_typed_id = ["py.typed"]
|
|
84
|
+
|
|
85
|
+
[tool.pytest.ini_options]
|
|
86
|
+
minversion = "8.0"
|
|
87
|
+
testpaths = ["tests"]
|
|
88
|
+
addopts = [
|
|
89
|
+
"--strict-config",
|
|
90
|
+
"--strict-markers",
|
|
91
|
+
"--cov=base_typed_id",
|
|
92
|
+
"--cov-report=term-missing",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
[tool.coverage.run]
|
|
96
|
+
branch = true
|
|
97
|
+
source = ["base_typed_id"]
|
|
98
|
+
|
|
99
|
+
[tool.coverage.report]
|
|
100
|
+
show_missing = true
|
|
101
|
+
skip_covered = false
|
|
102
|
+
fail_under = 100
|
|
103
|
+
|
|
104
|
+
[tool.ruff]
|
|
105
|
+
line-length = 88
|
|
106
|
+
target-version = "py310"
|
|
107
|
+
src = ["src", "tests"]
|
|
108
|
+
|
|
109
|
+
[tool.ruff.lint]
|
|
110
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
111
|
+
|
|
112
|
+
[tool.ruff.lint.isort]
|
|
113
|
+
known-first-party = ["base_typed_id"]
|
|
114
|
+
|
|
115
|
+
[tool.ruff.format]
|
|
116
|
+
quote-style = "double"
|
|
117
|
+
|
|
118
|
+
[tool.mypy]
|
|
119
|
+
python_version = "3.10"
|
|
120
|
+
mypy_path = "src"
|
|
121
|
+
strict = true
|
|
122
|
+
disallow_untyped_defs = true
|
|
123
|
+
check_untyped_defs = true
|
|
124
|
+
warn_redundant_casts = true
|
|
125
|
+
warn_unused_configs = true
|
|
126
|
+
warn_return_any = true
|
|
127
|
+
strict_equality = true
|
|
128
|
+
no_implicit_optional = true
|
|
129
|
+
allow_redefinition = false
|
|
130
|
+
show_error_codes = true
|
|
131
|
+
packages = ["base_typed_id"]
|
|
132
|
+
|
|
133
|
+
[tool.pyright]
|
|
134
|
+
pythonVersion = "3.10"
|
|
135
|
+
typeCheckingMode = "strict"
|
|
136
|
+
include = ["src", "tests"]
|
|
137
|
+
exclude = [
|
|
138
|
+
"**/.*",
|
|
139
|
+
"**/__pycache__/**",
|
|
140
|
+
"venv/**",
|
|
141
|
+
".venv/**",
|
|
142
|
+
"**/node_modules/**",
|
|
143
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from ._base_typed_id import BaseTypedId
|
|
2
|
+
from ._exceptions import (
|
|
3
|
+
BaseTypedIdError,
|
|
4
|
+
BaseTypedIdInvalidInputValueError,
|
|
5
|
+
BaseTypedIdInvariantViolationError,
|
|
6
|
+
)
|
|
7
|
+
from .factories import deterministically_from_words
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BaseTypedId",
|
|
11
|
+
"BaseTypedIdError",
|
|
12
|
+
"BaseTypedIdInvalidInputValueError",
|
|
13
|
+
"BaseTypedIdInvariantViolationError",
|
|
14
|
+
"deterministically_from_words",
|
|
15
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar, Literal, TypeVar
|
|
4
|
+
from uuid import UUID, uuid4
|
|
5
|
+
|
|
6
|
+
from ._exceptions import (
|
|
7
|
+
BaseTypedIdInvalidInputValueError,
|
|
8
|
+
BaseTypedIdInvariantViolationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
BaseTypedIdType = TypeVar(
|
|
12
|
+
"BaseTypedIdType",
|
|
13
|
+
bound="BaseTypedId",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseTypedId(str):
|
|
18
|
+
"""
|
|
19
|
+
Transparent domain-typed identifier based on UUID.
|
|
20
|
+
|
|
21
|
+
Design rules:
|
|
22
|
+
- stores an exact runtime subtype
|
|
23
|
+
- serializes as plain str
|
|
24
|
+
- preserves subtype in containers, pickle, and Pydantic model fields
|
|
25
|
+
- uses native pydantic-core uuid schema for OpenAPI format "uuid"
|
|
26
|
+
- defaults to UUID v4 and auto-generates on None
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__slots__ = ()
|
|
30
|
+
|
|
31
|
+
uuid_version: ClassVar[Literal[1, 3, 4, 5, 6, 7, 8] | None] = 4
|
|
32
|
+
|
|
33
|
+
def __new__(
|
|
34
|
+
cls: type[BaseTypedIdType],
|
|
35
|
+
value: str | UUID | None = None,
|
|
36
|
+
) -> BaseTypedIdType:
|
|
37
|
+
parsed_uuid_value: UUID = cls._parse_uuid_value(value=value)
|
|
38
|
+
cls._validate_uuid_version(parsed_uuid_value=parsed_uuid_value)
|
|
39
|
+
return str.__new__(cls, str(parsed_uuid_value))
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def _parse_uuid_value(
|
|
43
|
+
cls,
|
|
44
|
+
value: str | UUID | None,
|
|
45
|
+
) -> UUID:
|
|
46
|
+
if value is None:
|
|
47
|
+
if cls.uuid_version not in (None, 4):
|
|
48
|
+
raise BaseTypedIdInvalidInputValueError(
|
|
49
|
+
f"{cls.__name__} cannot auto-generate from None when "
|
|
50
|
+
f"uuid_version is {cls.uuid_version!r}. "
|
|
51
|
+
"Provide an explicit UUID value."
|
|
52
|
+
)
|
|
53
|
+
return uuid4()
|
|
54
|
+
|
|
55
|
+
if isinstance(value, UUID):
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
# Runtime guard is intentional because the library is callable from untyped code
|
|
59
|
+
if not isinstance(value, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
60
|
+
raise BaseTypedIdInvalidInputValueError(
|
|
61
|
+
"BaseTypedId must be initialized with None, str, or uuid.UUID. "
|
|
62
|
+
f"Got: {type(value).__name__}."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
return UUID(value)
|
|
67
|
+
except ValueError as validation_error:
|
|
68
|
+
raise BaseTypedIdInvalidInputValueError(
|
|
69
|
+
"BaseTypedId must be initialized with a valid UUID string. "
|
|
70
|
+
f"Got: {value!r}."
|
|
71
|
+
) from validation_error
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _validate_uuid_version(
|
|
75
|
+
cls,
|
|
76
|
+
parsed_uuid_value: UUID,
|
|
77
|
+
) -> None:
|
|
78
|
+
expecteduuid_version: int | None = cls.uuid_version
|
|
79
|
+
if expecteduuid_version is None:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
actual_uuid_version: int | None = parsed_uuid_value.version
|
|
83
|
+
if actual_uuid_version != expecteduuid_version:
|
|
84
|
+
raise BaseTypedIdInvalidInputValueError(
|
|
85
|
+
f"{cls.__name__} expects UUID v{expecteduuid_version}. "
|
|
86
|
+
f"Got UUID v{actual_uuid_version}: {parsed_uuid_value}."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return f"{self.__class__.__name__}({str(self)!r})"
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def __get_pydantic_core_schema__(
|
|
94
|
+
cls,
|
|
95
|
+
source_type: Any,
|
|
96
|
+
handler: Any,
|
|
97
|
+
) -> Any:
|
|
98
|
+
"""
|
|
99
|
+
Provide Pydantic v2 validation and serialization.
|
|
100
|
+
|
|
101
|
+
Validation:
|
|
102
|
+
- accepts UUID objects
|
|
103
|
+
- accepts strict UUID strings
|
|
104
|
+
- rejects bytes and other non-declared runtime inputs
|
|
105
|
+
- returns the exact subclass instance
|
|
106
|
+
|
|
107
|
+
Serialization:
|
|
108
|
+
- serializes as plain str in both python and json dump modes
|
|
109
|
+
|
|
110
|
+
Schema:
|
|
111
|
+
- uses native uuid schema for JSON/OpenAPI, so format stays `uuid`
|
|
112
|
+
"""
|
|
113
|
+
del source_type
|
|
114
|
+
del handler
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
from pydantic_core import core_schema
|
|
118
|
+
except ImportError as import_error: # pragma: no cover
|
|
119
|
+
raise BaseTypedIdInvariantViolationError(
|
|
120
|
+
"pydantic-core is required to build BaseTypedId schema."
|
|
121
|
+
) from import_error
|
|
122
|
+
|
|
123
|
+
def serialize_to_plain_string(value: BaseTypedId) -> str:
|
|
124
|
+
return str(value)
|
|
125
|
+
|
|
126
|
+
python_input_schema = core_schema.union_schema(
|
|
127
|
+
[
|
|
128
|
+
core_schema.is_instance_schema(UUID),
|
|
129
|
+
core_schema.str_schema(strict=True),
|
|
130
|
+
]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
json_input_schema = core_schema.uuid_schema(version=cls.uuid_version)
|
|
134
|
+
|
|
135
|
+
return core_schema.json_or_python_schema(
|
|
136
|
+
json_schema=core_schema.no_info_after_validator_function(
|
|
137
|
+
cls,
|
|
138
|
+
json_input_schema,
|
|
139
|
+
),
|
|
140
|
+
python_schema=core_schema.no_info_after_validator_function(
|
|
141
|
+
cls,
|
|
142
|
+
python_input_schema,
|
|
143
|
+
),
|
|
144
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
145
|
+
serialize_to_plain_string,
|
|
146
|
+
return_schema=core_schema.str_schema(),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def __reduce__(
|
|
151
|
+
self,
|
|
152
|
+
) -> tuple[type[BaseTypedId], tuple[str]]:
|
|
153
|
+
return (self.__class__, (str(self),))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class BaseTypedIdError(Exception):
|
|
2
|
+
"""Root exception for all base_typed_id errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BaseTypedIdInvalidInputValueError(BaseTypedIdError, ValueError):
|
|
6
|
+
"""Raised when an invalid input value is provided."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseTypedIdInvariantViolationError(BaseTypedIdError):
|
|
10
|
+
"""Raised when an internal invariant or contract is violated."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
from uuid import UUID, uuid5
|
|
7
|
+
|
|
8
|
+
from ._base_typed_id import BaseTypedId
|
|
9
|
+
from ._exceptions import (
|
|
10
|
+
BaseTypedIdInvalidInputValueError,
|
|
11
|
+
BaseTypedIdInvariantViolationError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
BaseTypedIdType = TypeVar("BaseTypedIdType", bound=BaseTypedId)
|
|
15
|
+
|
|
16
|
+
_DEFAULT_DETERMINISTIC_NAMESPACE: UUID = UUID("0b8d2f0f-5c07-4fd9-a7d3-2c9d9d7c0f52")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def deterministically_from_words(
|
|
20
|
+
typed_id_type: type[BaseTypedIdType],
|
|
21
|
+
*,
|
|
22
|
+
words: Iterable[str],
|
|
23
|
+
) -> BaseTypedIdType:
|
|
24
|
+
"""
|
|
25
|
+
Build a stable typed identifier from ordered semantic words.
|
|
26
|
+
|
|
27
|
+
Rules:
|
|
28
|
+
- same words -> same identifier
|
|
29
|
+
- order matters
|
|
30
|
+
- words are serialized as canonical JSON before UUID v5 generation
|
|
31
|
+
|
|
32
|
+
Important:
|
|
33
|
+
- intended only for idempotent identifiers
|
|
34
|
+
- requires `uuid_version = 5` or `uuid_version = None`
|
|
35
|
+
"""
|
|
36
|
+
# Runtime guard is intentional because the library is callable from untyped code.
|
|
37
|
+
if not isinstance(typed_id_type, type) or not issubclass( # pyright: ignore[reportUnnecessaryIsInstance]
|
|
38
|
+
typed_id_type,
|
|
39
|
+
BaseTypedId,
|
|
40
|
+
):
|
|
41
|
+
raise BaseTypedIdInvalidInputValueError(
|
|
42
|
+
"typed_id_type must inherit from BaseTypedId."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
expected_uuid_version: int | None = typed_id_type.uuid_version
|
|
46
|
+
if expected_uuid_version not in (None, 5):
|
|
47
|
+
raise BaseTypedIdInvariantViolationError(
|
|
48
|
+
"deterministically_from_words requires a BaseTypedId subclass with "
|
|
49
|
+
"uuid_version = 5 or uuid_version = None."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
normalized_words: list[str] = []
|
|
53
|
+
for word in words:
|
|
54
|
+
# Runtime guard is intentional because the library is callable from untyped code
|
|
55
|
+
if not isinstance(word, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
56
|
+
raise BaseTypedIdInvalidInputValueError(
|
|
57
|
+
"deterministically_from_words accepts only str items in words. "
|
|
58
|
+
f"Got: {type(word).__name__}."
|
|
59
|
+
)
|
|
60
|
+
normalized_words.append(word)
|
|
61
|
+
|
|
62
|
+
if len(normalized_words) == 0:
|
|
63
|
+
raise BaseTypedIdInvalidInputValueError(
|
|
64
|
+
"deterministically_from_words requires at least one word."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
canonical_payload: str = json.dumps(
|
|
68
|
+
normalized_words,
|
|
69
|
+
ensure_ascii=False,
|
|
70
|
+
separators=(",", ":"),
|
|
71
|
+
)
|
|
72
|
+
deterministic_uuid_value: UUID = uuid5(
|
|
73
|
+
_DEFAULT_DETERMINISTIC_NAMESPACE,
|
|
74
|
+
canonical_payload,
|
|
75
|
+
)
|
|
76
|
+
return typed_id_type(deterministic_uuid_value)
|
|
File without changes
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: base-typed-id
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Strict typed UUID identifier base class with exact runtime subtype preservation and optional Pydantic v2 support.
|
|
5
|
+
Author-email: Eldeniz Guseinli <eldenizfamilyanskicode@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/eldenizfamilyanskicode/base-typed-id
|
|
8
|
+
Project-URL: Repository, https://github.com/eldenizfamilyanskicode/base-typed-id
|
|
9
|
+
Project-URL: Issues, https://github.com/eldenizfamilyanskicode/base-typed-id/issues
|
|
10
|
+
Keywords: typing,uuid,typed-id,value-object,pydantic,domain-model
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: pydantic
|
|
25
|
+
Requires-Dist: pydantic<3,>=2.6; extra == "pydantic"
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
29
|
+
Requires-Dist: pydantic<3,>=2.6; extra == "test"
|
|
30
|
+
Provides-Extra: lint
|
|
31
|
+
Requires-Dist: ruff>=0.5; extra == "lint"
|
|
32
|
+
Provides-Extra: typecheck
|
|
33
|
+
Requires-Dist: mypy>=1.10; extra == "typecheck"
|
|
34
|
+
Requires-Dist: pyright>=1.1; extra == "typecheck"
|
|
35
|
+
Requires-Dist: pydantic<3,>=2.6; extra == "typecheck"
|
|
36
|
+
Provides-Extra: build
|
|
37
|
+
Requires-Dist: build>=1.2; extra == "build"
|
|
38
|
+
Requires-Dist: twine>=5.1; extra == "build"
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
41
|
+
Requires-Dist: twine>=5.1; extra == "dev"
|
|
42
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
43
|
+
Requires-Dist: pyright>=1.1; extra == "dev"
|
|
44
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
46
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
47
|
+
Requires-Dist: pydantic<3,>=2.6; extra == "dev"
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
# base-typed-id
|
|
51
|
+
|
|
52
|
+
Strict typed UUID identifier base class with exact runtime subtype preservation and optional Pydantic v2 support.
|
|
53
|
+
|
|
54
|
+
## Why
|
|
55
|
+
|
|
56
|
+
`BaseTypedId` lets you define domain-specific UUID-backed string subtypes such as `UserId`, `OrderId`, or `ExternalEventId`.
|
|
57
|
+
|
|
58
|
+
Goals:
|
|
59
|
+
|
|
60
|
+
- exact runtime subtype preservation
|
|
61
|
+
- plain `str` compatibility
|
|
62
|
+
- plain string serialization
|
|
63
|
+
- pickle roundtrip support
|
|
64
|
+
- Pydantic v2 support
|
|
65
|
+
- OpenAPI `format: uuid`
|
|
66
|
+
|
|
67
|
+
## Why not `NewType`, `Annotated[str, ...]`, or wrapper value objects?
|
|
68
|
+
|
|
69
|
+
There are several ways to model typed identifiers in Python. This library focuses on one specific trade-off: keeping a domain-specific runtime subtype while staying fully compatible with plain `str`.
|
|
70
|
+
|
|
71
|
+
### Why not `typing.NewType`?
|
|
72
|
+
|
|
73
|
+
`NewType` is excellent when you only need a static type distinction for type checkers.
|
|
74
|
+
|
|
75
|
+
However, at runtime a `NewType` value is still just a plain `str`. It does not:
|
|
76
|
+
|
|
77
|
+
- preserve an exact runtime subtype
|
|
78
|
+
- validate UUID format on construction
|
|
79
|
+
- provide subtype-preserving pickle behavior
|
|
80
|
+
- integrate as a real runtime subtype inside containers and model fields
|
|
81
|
+
|
|
82
|
+
Use `NewType` when static typing alone is enough.
|
|
83
|
+
|
|
84
|
+
### Why not `Annotated[str, ...]`?
|
|
85
|
+
|
|
86
|
+
`Annotated` is useful for attaching metadata to a type, especially for validators and frameworks.
|
|
87
|
+
|
|
88
|
+
But it still does not create a distinct runtime type. If you need runtime identity such as `type(user_id) is UserId`, `Annotated[str, ...]` is not enough.
|
|
89
|
+
|
|
90
|
+
### Why not wrapper value objects?
|
|
91
|
+
|
|
92
|
+
A wrapper class such as `UserId(value: str)` gives a stronger domain boundary and is often a good choice in rich domain models.
|
|
93
|
+
|
|
94
|
+
The trade-off is interoperability friction:
|
|
95
|
+
|
|
96
|
+
- it is no longer a plain string
|
|
97
|
+
- JSON serialization usually needs custom handling
|
|
98
|
+
- dictionary key compatibility is less transparent
|
|
99
|
+
- many integrations require explicit `.value` extraction
|
|
100
|
+
|
|
101
|
+
Use a wrapper when you want additional domain behavior beyond typed identity.
|
|
102
|
+
|
|
103
|
+
### What this library optimizes for
|
|
104
|
+
|
|
105
|
+
`BaseTypedId` is for the narrower case where you want all of the following at once:
|
|
106
|
+
|
|
107
|
+
- exact runtime subtype preservation
|
|
108
|
+
- plain `str` compatibility
|
|
109
|
+
- UUID parsing and version checks at the boundary
|
|
110
|
+
- plain string serialization
|
|
111
|
+
- Pydantic v2 / OpenAPI compatibility
|
|
112
|
+
- pickle roundtrip support
|
|
113
|
+
|
|
114
|
+
## When not to use this library
|
|
115
|
+
|
|
116
|
+
This library is not the best fit if:
|
|
117
|
+
|
|
118
|
+
- static-only type distinction is enough for you (`NewType` may be simpler)
|
|
119
|
+
- you want rich domain behavior on the identifier itself (a wrapper value object may be better)
|
|
120
|
+
- your identifiers are not UUID-based
|
|
121
|
+
|
|
122
|
+
## Installation
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pip install base-typed-id
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
With Pydantic support:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pip install "base-typed-id[pydantic]"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Basic usage
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from base_typed_id import BaseTypedId
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class UserId(BaseTypedId):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
|
|
145
|
+
generated_user_id: UserId = UserId()
|
|
146
|
+
|
|
147
|
+
assert type(user_id) is UserId
|
|
148
|
+
assert isinstance(user_id, str)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## UUID version control
|
|
152
|
+
|
|
153
|
+
By default, `BaseTypedId` expects UUID v4.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from base_typed_id import BaseTypedId
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ExternalEventId(BaseTypedId):
|
|
160
|
+
uuid_version = 5
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`uuid_version = None` disables version restriction.
|
|
164
|
+
|
|
165
|
+
## Pydantic v2
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from pydantic import BaseModel
|
|
169
|
+
|
|
170
|
+
from base_typed_id import BaseTypedId
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class UserId(BaseTypedId):
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class UserModel(BaseModel):
|
|
178
|
+
user_id: UserId
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Behavior:
|
|
182
|
+
|
|
183
|
+
* inside model: exact subtype is preserved
|
|
184
|
+
* after `model_dump()` / `model_dump_json()`: plain string is exported
|
|
185
|
+
* generated schema keeps `type: string` and `format: uuid`
|
|
186
|
+
|
|
187
|
+
## Deterministic identifiers
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from base_typed_id import BaseTypedId
|
|
191
|
+
from base_typed_id.factories import deterministically_from_words
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ExternalEventId(BaseTypedId):
|
|
195
|
+
uuid_version = 5
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
event_id: ExternalEventId = deterministically_from_words(
|
|
199
|
+
ExternalEventId,
|
|
200
|
+
words=[
|
|
201
|
+
"workspace:house-of-ai",
|
|
202
|
+
"provider:telegram",
|
|
203
|
+
"event:message-created",
|
|
204
|
+
"message:42",
|
|
205
|
+
],
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Rules:
|
|
210
|
+
|
|
211
|
+
* same words -> same identifier
|
|
212
|
+
* order matters
|
|
213
|
+
* deterministic generation requires `uuid_version = 5` or `uuid_version = None`
|
|
214
|
+
|
|
215
|
+
## Guarantees
|
|
216
|
+
|
|
217
|
+
* exact subtype is preserved in runtime objects
|
|
218
|
+
* exact subtype is preserved in containers
|
|
219
|
+
* exact subtype is preserved through pickle roundtrip
|
|
220
|
+
* serialized/exported representation is plain string
|
|
221
|
+
|
|
222
|
+
## Non-goals
|
|
223
|
+
|
|
224
|
+
* no extra domain behavior beyond typed identity
|
|
225
|
+
* no automatic semantic validation beyond UUID parsing/version checks
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/base_typed_id/__init__.py
|
|
5
|
+
src/base_typed_id/_base_typed_id.py
|
|
6
|
+
src/base_typed_id/_exceptions.py
|
|
7
|
+
src/base_typed_id/factories.py
|
|
8
|
+
src/base_typed_id/py.typed
|
|
9
|
+
src/base_typed_id.egg-info/PKG-INFO
|
|
10
|
+
src/base_typed_id.egg-info/SOURCES.txt
|
|
11
|
+
src/base_typed_id.egg-info/dependency_links.txt
|
|
12
|
+
src/base_typed_id.egg-info/requires.txt
|
|
13
|
+
src/base_typed_id.egg-info/top_level.txt
|
|
14
|
+
tests/test_base_typed_id.py
|
|
15
|
+
tests/test_factories.py
|
|
16
|
+
tests/test_pickle_roundtrip.py
|
|
17
|
+
tests/test_pydantic_integration.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
|
|
2
|
+
[build]
|
|
3
|
+
build>=1.2
|
|
4
|
+
twine>=5.1
|
|
5
|
+
|
|
6
|
+
[dev]
|
|
7
|
+
build>=1.2
|
|
8
|
+
twine>=5.1
|
|
9
|
+
mypy>=1.10
|
|
10
|
+
pyright>=1.1
|
|
11
|
+
pytest>=8.0
|
|
12
|
+
pytest-cov>=5.0
|
|
13
|
+
ruff>=0.5
|
|
14
|
+
pydantic<3,>=2.6
|
|
15
|
+
|
|
16
|
+
[lint]
|
|
17
|
+
ruff>=0.5
|
|
18
|
+
|
|
19
|
+
[pydantic]
|
|
20
|
+
pydantic<3,>=2.6
|
|
21
|
+
|
|
22
|
+
[test]
|
|
23
|
+
pytest>=8.0
|
|
24
|
+
pytest-cov>=5.0
|
|
25
|
+
pydantic<3,>=2.6
|
|
26
|
+
|
|
27
|
+
[typecheck]
|
|
28
|
+
mypy>=1.10
|
|
29
|
+
pyright>=1.1
|
|
30
|
+
pydantic<3,>=2.6
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
base_typed_id
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from base_typed_id import BaseTypedId
|
|
8
|
+
from base_typed_id import BaseTypedIdInvalidInputValueError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UserId(BaseTypedId):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExternalEventId(BaseTypedId):
|
|
16
|
+
uuid_version = 5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_none_generates_uuid_v4_typed_id() -> None:
|
|
20
|
+
generated_user_id: UserId = UserId()
|
|
21
|
+
parsed_uuid_value: UUID = UUID(str(generated_user_id))
|
|
22
|
+
|
|
23
|
+
assert type(generated_user_id) is UserId
|
|
24
|
+
assert parsed_uuid_value.version == 4
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_string_input_preserves_exact_subtype() -> None:
|
|
28
|
+
raw_uuid_string: str = "123e4567-e89b-42d3-a456-426614174000"
|
|
29
|
+
|
|
30
|
+
user_id: UserId = UserId(raw_uuid_string)
|
|
31
|
+
|
|
32
|
+
assert type(user_id) is UserId
|
|
33
|
+
assert user_id == raw_uuid_string
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_uuid_input_preserves_exact_subtype() -> None:
|
|
37
|
+
raw_uuid_value: UUID = UUID("123e4567-e89b-42d3-a456-426614174000")
|
|
38
|
+
|
|
39
|
+
user_id: UserId = UserId(raw_uuid_value)
|
|
40
|
+
|
|
41
|
+
assert type(user_id) is UserId
|
|
42
|
+
assert user_id == str(raw_uuid_value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_invalid_uuid_string_raises_error() -> None:
|
|
46
|
+
with pytest.raises(BaseTypedIdInvalidInputValueError):
|
|
47
|
+
UserId("not-a-uuid")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_unsupported_input_type_raises_error() -> None:
|
|
51
|
+
with pytest.raises(BaseTypedIdInvalidInputValueError):
|
|
52
|
+
UserId(123) # type: ignore[arg-type]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def testuuid_version_mismatch_raises_error() -> None:
|
|
56
|
+
with pytest.raises(BaseTypedIdInvalidInputValueError):
|
|
57
|
+
UserId("123e4567-e89b-52d3-a456-426614174000")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_non_v4_subclass_cannot_auto_generate_from_none() -> None:
|
|
61
|
+
with pytest.raises(BaseTypedIdInvalidInputValueError):
|
|
62
|
+
ExternalEventId()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_normal_string_operations_return_plain_str() -> None:
|
|
66
|
+
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
|
|
67
|
+
|
|
68
|
+
uppercased_value: str = user_id.upper()
|
|
69
|
+
concatenated_value: str = user_id + "_debug"
|
|
70
|
+
|
|
71
|
+
assert type(uppercased_value) is str
|
|
72
|
+
assert type(concatenated_value) is str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_repr_contains_exact_subtype_name() -> None:
|
|
76
|
+
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
|
|
77
|
+
|
|
78
|
+
assert repr(user_id) == "UserId('123e4567-e89b-42d3-a456-426614174000')"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from base_typed_id import (
|
|
8
|
+
BaseTypedId,
|
|
9
|
+
BaseTypedIdInvalidInputValueError,
|
|
10
|
+
BaseTypedIdInvariantViolationError,
|
|
11
|
+
)
|
|
12
|
+
from base_typed_id.factories import deterministically_from_words
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StableEventId(BaseTypedId):
|
|
16
|
+
uuid_version = 5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FlexibleEventId(BaseTypedId):
|
|
20
|
+
uuid_version = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UserId(BaseTypedId):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_same_words_produce_same_typed_id() -> None:
|
|
28
|
+
first_event_id: StableEventId = deterministically_from_words(
|
|
29
|
+
StableEventId,
|
|
30
|
+
words=["workspace", "provider", "message", "42"],
|
|
31
|
+
)
|
|
32
|
+
second_event_id: StableEventId = deterministically_from_words(
|
|
33
|
+
StableEventId,
|
|
34
|
+
words=["workspace", "provider", "message", "42"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
assert type(first_event_id) is StableEventId
|
|
38
|
+
assert type(second_event_id) is StableEventId
|
|
39
|
+
assert first_event_id == second_event_id
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_word_order_changes_identifier() -> None:
|
|
43
|
+
first_event_id: StableEventId = deterministically_from_words(
|
|
44
|
+
StableEventId,
|
|
45
|
+
words=["workspace", "provider", "message", "42"],
|
|
46
|
+
)
|
|
47
|
+
second_event_id: StableEventId = deterministically_from_words(
|
|
48
|
+
StableEventId,
|
|
49
|
+
words=["42", "message", "provider", "workspace"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
assert first_event_id != second_event_id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_factory_supports_version_agnostic_subclass() -> None:
|
|
56
|
+
flexible_event_id: FlexibleEventId = deterministically_from_words(
|
|
57
|
+
FlexibleEventId,
|
|
58
|
+
words=["workspace", "provider", "message", "42"],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
assert type(flexible_event_id) is FlexibleEventId
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_factory_rejects_version_four_subclass() -> None:
|
|
65
|
+
with pytest.raises(BaseTypedIdInvariantViolationError):
|
|
66
|
+
deterministically_from_words(
|
|
67
|
+
UserId,
|
|
68
|
+
words=["workspace", "provider", "message", "42"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_factory_rejects_empty_words() -> None:
|
|
73
|
+
with pytest.raises(BaseTypedIdInvalidInputValueError):
|
|
74
|
+
deterministically_from_words(
|
|
75
|
+
StableEventId,
|
|
76
|
+
words=[],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_factory_rejects_non_string_words() -> None:
|
|
81
|
+
with pytest.raises(BaseTypedIdInvalidInputValueError):
|
|
82
|
+
deterministically_from_words(
|
|
83
|
+
StableEventId,
|
|
84
|
+
words=["workspace", "provider", 42], # type: ignore[list-item]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_deterministically_from_words_rejects_non_type_typed_id_type() -> None:
|
|
89
|
+
invalid_typed_id_type: Any = "UserId"
|
|
90
|
+
|
|
91
|
+
with pytest.raises(
|
|
92
|
+
BaseTypedIdInvalidInputValueError,
|
|
93
|
+
match="typed_id_type must inherit from BaseTypedId.",
|
|
94
|
+
):
|
|
95
|
+
deterministically_from_words(
|
|
96
|
+
invalid_typed_id_type,
|
|
97
|
+
words=["workspace:house-of-ai"],
|
|
98
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
|
|
5
|
+
from base_typed_id import BaseTypedId
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UserId(BaseTypedId):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_pickle_roundtrip_preserves_exact_subtype() -> None:
|
|
13
|
+
source_user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
|
|
14
|
+
|
|
15
|
+
serialized_user_id: bytes = pickle.dumps(source_user_id)
|
|
16
|
+
restored_user_id: object = pickle.loads(serialized_user_id)
|
|
17
|
+
|
|
18
|
+
assert restored_user_id == "123e4567-e89b-42d3-a456-426614174000"
|
|
19
|
+
assert type(restored_user_id) is UserId
|
|
20
|
+
assert isinstance(restored_user_id, str)
|
|
21
|
+
assert isinstance(restored_user_id, UserId)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
7
|
+
|
|
8
|
+
from base_typed_id import BaseTypedId
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UserId(BaseTypedId):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UserModel(BaseModel):
|
|
16
|
+
user_id: UserId
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GeneratedUserModel(BaseModel):
|
|
20
|
+
user_id: UserId = Field(default_factory=UserId)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_model_validation_builds_exact_subtype() -> None:
|
|
24
|
+
user_model: UserModel = UserModel.model_validate(
|
|
25
|
+
{"user_id": "123e4567-e89b-42d3-a456-426614174000"}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
assert type(user_model.user_id) is UserId
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_model_dump_flattens_to_plain_string() -> None:
|
|
32
|
+
user_model: UserModel = UserModel.model_validate(
|
|
33
|
+
{"user_id": "123e4567-e89b-42d3-a456-426614174000"}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
dumped_python: dict[str, object] = user_model.model_dump()
|
|
37
|
+
|
|
38
|
+
assert type(dumped_python["user_id"]) is str
|
|
39
|
+
assert dumped_python["user_id"] == "123e4567-e89b-42d3-a456-426614174000"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_model_dump_json_flattens_to_plain_string() -> None:
|
|
43
|
+
user_model: UserModel = UserModel.model_validate(
|
|
44
|
+
{"user_id": "123e4567-e89b-42d3-a456-426614174000"}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
dumped_json: str = user_model.model_dump_json()
|
|
48
|
+
loaded_json_payload: dict[str, object] = json.loads(dumped_json)
|
|
49
|
+
|
|
50
|
+
assert type(loaded_json_payload["user_id"]) is str
|
|
51
|
+
assert loaded_json_payload["user_id"] == "123e4567-e89b-42d3-a456-426614174000"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_model_validate_from_dump_reconstructs_exact_subtype() -> None:
|
|
55
|
+
source_model: UserModel = UserModel.model_validate(
|
|
56
|
+
{"user_id": "123e4567-e89b-42d3-a456-426614174000"}
|
|
57
|
+
)
|
|
58
|
+
dumped_python: dict[str, object] = source_model.model_dump()
|
|
59
|
+
|
|
60
|
+
restored_model: UserModel = UserModel.model_validate(dumped_python)
|
|
61
|
+
|
|
62
|
+
assert type(restored_model.user_id) is UserId
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_json_schema_uses_native_uuid_format() -> None:
|
|
66
|
+
json_schema: dict[str, object] = UserModel.model_json_schema()
|
|
67
|
+
properties_schema: dict[str, object] = json_schema["properties"] # type: ignore[assignment]
|
|
68
|
+
user_id_schema: dict[str, object] = properties_schema["user_id"] # type: ignore[assignment]
|
|
69
|
+
|
|
70
|
+
assert user_id_schema["type"] == "string"
|
|
71
|
+
assert user_id_schema["format"] == "uuid"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_default_factory_generates_typed_id() -> None:
|
|
75
|
+
generated_user_model: GeneratedUserModel = GeneratedUserModel.model_validate({})
|
|
76
|
+
|
|
77
|
+
assert type(generated_user_model.user_id) is UserId
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_none_is_rejected_for_required_field() -> None:
|
|
81
|
+
with pytest.raises(ValidationError):
|
|
82
|
+
UserModel.model_validate({"user_id": None})
|