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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,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})