Skip to content

Commit 40294fe

Browse files
committed
feat(pydantic): add Pydantic v2 integration with TypeIDField
- introduce optional pydantic dependency (v2) - add TypeIDField for validating and serializing TypeID values - enforce TypeID prefix validation via generic field syntax - ensure JSON serialization outputs canonical string form - add integration tests covering parsing, validation, and serialization
1 parent 7ec8280 commit 40294fe

6 files changed

Lines changed: 236 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies = ["uuid-utils>=0.12.0"]
2222
[project.optional-dependencies]
2323
cli = ["click"]
2424
yaml = ["PyYAML"]
25+
pydantic = ["pydantic>=2,<3"]
2526

2627
[project.urls]
2728
Homepage = "https://github.com/akhundMurad/typeid-python"

tests/integrations/__init__.py

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import pytest
2+
from pydantic import BaseModel, ValidationError
3+
4+
from typeid.integrations.pydantic import TypeIDField
5+
from typeid import TypeID
6+
7+
8+
USER_TYPEID_STR = str(TypeID("user"))
9+
ORDER_TYPEID_STR = str(TypeID("order"))
10+
11+
12+
class M(BaseModel):
13+
id: TypeIDField["user"]
14+
15+
16+
def test_accepts_str():
17+
m = M(id=USER_TYPEID_STR)
18+
assert isinstance(m.id, TypeID)
19+
20+
21+
def test_accepts_typeid_instance():
22+
tid = TypeID.from_string(USER_TYPEID_STR)
23+
m = M(id=tid)
24+
assert m.id == tid
25+
26+
27+
def test_prefix_mismatch():
28+
with pytest.raises(ValidationError):
29+
M(id=ORDER_TYPEID_STR)
30+
31+
32+
def test_json_serializes_as_string():
33+
m = M(id=USER_TYPEID_STR)
34+
data = m.model_dump_json()
35+
assert '"id":"' in data

typeid/integrations/__init__.py

Whitespace-only changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .v2 import TypeIDField
2+
3+
__all__ = ["TypeIDField"]

typeid/integrations/pydantic/v2.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
from dataclasses import dataclass
2+
from typing import Any, ClassVar, Generic, Optional, TypeVar, overload
3+
4+
from pydantic_core import core_schema
5+
from pydantic.json_schema import JsonSchemaValue
6+
7+
from typeid import TypeID
8+
9+
10+
T = TypeVar("T")
11+
12+
13+
def _parse_typeid(value: Any) -> TypeID:
14+
"""
15+
Convert input into a TypeID instance.
16+
17+
Supports:
18+
- TypeID -> TypeID
19+
- str -> parse into TypeID
20+
21+
Tries common parsing APIs to avoid coupling to one exact core method.
22+
If none match, update this function to call your canonical parser.
23+
"""
24+
if isinstance(value, TypeID):
25+
return value
26+
27+
if isinstance(value, str):
28+
# Try the common names
29+
for name in ("from_str", "from_string", "parse"):
30+
fn = getattr(TypeID, name, None)
31+
if callable(fn):
32+
return fn(value) # type: ignore[misc]
33+
# Fallback: constructor accepts string
34+
try:
35+
return TypeID(value) # type: ignore[call-arg]
36+
except Exception as e:
37+
raise TypeError(
38+
"TypeID Pydantic integration couldn't parse a string. "
39+
"Please implement TypeID.from_str(s: str) (or .parse/.from_string), "
40+
"or make TypeID(s: str) work. Original error: "
41+
f"{e!r}"
42+
) from e
43+
44+
raise TypeError(f"TypeID must be str or TypeID, got {type(value).__name__}")
45+
46+
47+
def _get_prefix(tid: TypeID) -> Optional[str]:
48+
"""
49+
Extract prefix from TypeID. Adjust this if your core uses a different attribute.
50+
"""
51+
# Common: tid.prefix
52+
pref = getattr(tid, "prefix", None)
53+
if isinstance(pref, str):
54+
return pref
55+
return None
56+
57+
58+
def _to_str(tid: TypeID) -> str:
59+
"""
60+
Convert TypeID to its canonical string representation.
61+
"""
62+
# Prefer a dedicated method if you have one
63+
for name in ("to_string", "__str__"):
64+
fn = getattr(tid, name, None)
65+
if callable(fn):
66+
try:
67+
return fn() if name == "to_string" else str(tid)
68+
except Exception:
69+
pass
70+
return str(tid)
71+
72+
73+
@dataclass(frozen=True)
74+
class _TypeIDMeta:
75+
expected_prefix: Optional[str] = None
76+
# Optional: if you have a known regex for full string form, set it for JSON schema
77+
# pattern: Optional[str] = None
78+
pattern: Optional[str] = None
79+
example: Optional[str] = None
80+
81+
82+
class _TypeIDFieldBase:
83+
"""
84+
Base class implementing Pydantic v2 hooks.
85+
Subclasses specify _typeid_meta.
86+
"""
87+
88+
_typeid_meta: ClassVar[_TypeIDMeta] = _TypeIDMeta()
89+
90+
@classmethod
91+
def _validate(cls, v: Any) -> TypeID:
92+
tid = _parse_typeid(v)
93+
94+
exp = cls._typeid_meta.expected_prefix
95+
if exp is not None:
96+
got = _get_prefix(tid)
97+
if got != exp:
98+
raise ValueError(f"TypeID prefix mismatch: expected '{exp}', got '{got}'")
99+
100+
return tid
101+
102+
@classmethod
103+
def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema:
104+
"""
105+
Build a schema that:
106+
- accepts TypeID instances
107+
- accepts strings and validates/parses them
108+
- serializes to string in JSON
109+
"""
110+
# Accept either already-parsed TypeID, or a string (or any -> we validate)
111+
# Using a plain validator keeps it simple and fast.
112+
return core_schema.no_info_plain_validator_function(
113+
cls._validate,
114+
serialization=core_schema.plain_serializer_function_ser_schema(
115+
lambda v: _to_str(v),
116+
when_used="json",
117+
),
118+
)
119+
120+
@classmethod
121+
def __get_pydantic_json_schema__(cls, core_schema_: core_schema.CoreSchema, handler: Any) -> JsonSchemaValue:
122+
schema = handler(core_schema_)
123+
124+
# Ensure JSON schema is "string"
125+
schema.update(
126+
{
127+
"type": "string",
128+
"format": "typeid",
129+
}
130+
)
131+
132+
# Add prefix hint in schema
133+
exp = cls._typeid_meta.expected_prefix
134+
if exp is not None:
135+
schema.setdefault("description", f"TypeID with prefix '{exp}'")
136+
137+
# Optional pattern / example
138+
if cls._typeid_meta.pattern:
139+
schema["pattern"] = cls._typeid_meta.pattern
140+
if cls._typeid_meta.example:
141+
schema.setdefault("examples", [cls._typeid_meta.example])
142+
143+
return schema
144+
145+
146+
class TypeIDField(Generic[T]):
147+
"""
148+
Usage:
149+
150+
from typeid.integrations.pydantic import TypeIDField
151+
152+
class User(BaseModel):
153+
id: TypeIDField["user"]
154+
155+
This returns a specialized *type* that Pydantic will validate into your core TypeID.
156+
"""
157+
158+
@overload
159+
def __class_getitem__(cls, prefix: str) -> type[TypeID]: ...
160+
@overload
161+
def __class_getitem__(cls, prefix: tuple[str]) -> type[TypeID]: ...
162+
163+
def __class_getitem__(cls, item: Any) -> type[TypeID]:
164+
# Support TypeIDField["user"] or TypeIDField[("user",)]
165+
if isinstance(item, tuple):
166+
if len(item) != 1 or not isinstance(item[0], str):
167+
raise TypeError("TypeIDField[...] expects a single string prefix, e.g. TypeIDField['user']")
168+
prefix = item[0]
169+
else:
170+
if not isinstance(item, str):
171+
raise TypeError("TypeIDField[...] expects a string prefix, e.g. TypeIDField['user']")
172+
prefix = item
173+
174+
name = f"TypeIDField_{prefix}"
175+
176+
# Optionally add a simple example that looks like TypeID format
177+
# You can improve this to a real example generator if your core has one.
178+
example = f"{prefix}_01hxxxxxxxxxxxxxxxxxxxxxxxxx"
179+
180+
# Create a new subclass of _TypeIDFieldBase with fixed meta
181+
field_cls = type(
182+
name,
183+
(_TypeIDFieldBase,),
184+
{
185+
"_typeid_meta": _TypeIDMeta(
186+
expected_prefix=prefix,
187+
# If you know your precise regex, put it here:
188+
# pattern=rf"^{prefix}_[0-9a-z]{{26}}$",
189+
pattern=None,
190+
example=example,
191+
)
192+
},
193+
)
194+
195+
# IMPORTANT:
196+
# We return `field_cls` as the annotation type, but the runtime validated value is your core TypeID.
197+
return field_cls # type: ignore[return-value]

0 commit comments

Comments
 (0)