Skip to content

Commit baa8db5

Browse files
committed
fix(pydantic): support Literal prefixes in TypeIDField and simplify parsing
- allow TypeIDField[Literal["prefix"]] (in addition to string/tuple forms) - use TypeID.from_string for parsing and simplify serialization/prefix checks - apply minor formatting/cleanup in cli init and errors import
1 parent 40294fe commit baa8db5

5 files changed

Lines changed: 199 additions & 65 deletions

File tree

tests/integrations/test_pydantic.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Literal
12
import pytest
23
from pydantic import BaseModel, ValidationError
34

@@ -10,7 +11,7 @@
1011

1112

1213
class M(BaseModel):
13-
id: TypeIDField["user"]
14+
id: TypeIDField[Literal["user"]]
1415

1516

1617
def test_accepts_str():

typeid/cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from typeid.cli.main import cli
22

33

4-
if __name__ == "__main__":
4+
if __name__ == "__main__":
55
cli()

typeid/errors.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
# New code should prefer importing from the canonical modules, but
88
# existing imports will continue to work.
99

10-
from typeid.core.errors import TypeIDException, PrefixValidationException, SuffixValidationException, InvalidTypeIDStringException
10+
from typeid.core.errors import (
11+
TypeIDException,
12+
PrefixValidationException,
13+
SuffixValidationException,
14+
InvalidTypeIDStringException,
15+
)
1116

1217

1318
__all__ = ("TypeIDException", "PrefixValidationException", "SuffixValidationException", "InvalidTypeIDStringException")

typeid/integrations/pydantic/v2.py

Lines changed: 31 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from dataclasses import dataclass
2-
from typing import Any, ClassVar, Generic, Optional, TypeVar, overload
2+
from typing import Any, ClassVar, Generic, Literal, Optional, TypeVar, get_args, get_origin, overload
33

44
from pydantic_core import core_schema
55
from pydantic.json_schema import JsonSchemaValue
@@ -17,64 +17,19 @@ def _parse_typeid(value: Any) -> TypeID:
1717
Supports:
1818
- TypeID -> TypeID
1919
- 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.
2320
"""
2421
if isinstance(value, TypeID):
2522
return value
2623

2724
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
25+
return TypeID.from_string(value)
4326

4427
raise TypeError(f"TypeID must be str or TypeID, got {type(value).__name__}")
4528

4629

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-
7330
@dataclass(frozen=True)
7431
class _TypeIDMeta:
7532
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
7833
pattern: Optional[str] = None
7934
example: Optional[str] = None
8035

@@ -93,9 +48,8 @@ def _validate(cls, v: Any) -> TypeID:
9348

9449
exp = cls._typeid_meta.expected_prefix
9550
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}'")
51+
if tid.prefix != exp:
52+
raise ValueError(f"TypeID prefix mismatch: expected '{exp}', got '{tid.prefix}'")
9953

10054
return tid
10155

@@ -112,7 +66,7 @@ def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_sc
11266
return core_schema.no_info_plain_validator_function(
11367
cls._validate,
11468
serialization=core_schema.plain_serializer_function_ser_schema(
115-
lambda v: _to_str(v),
69+
lambda v: str(v),
11670
when_used="json",
11771
),
11872
)
@@ -156,26 +110,42 @@ class User(BaseModel):
156110
"""
157111

158112
@overload
159-
def __class_getitem__(cls, prefix: str) -> type[TypeID]: ...
113+
def __class_getitem__(cls, prefix: str) -> type[TypeID]:
114+
...
115+
160116
@overload
161-
def __class_getitem__(cls, prefix: tuple[str]) -> type[TypeID]: ...
117+
def __class_getitem__(cls, prefix: tuple[str]) -> type[TypeID]:
118+
...
162119

163120
def __class_getitem__(cls, item: Any) -> type[TypeID]:
164-
# Support TypeIDField["user"] or TypeIDField[("user",)]
121+
# Support:
122+
# - TypeIDField["user"]
123+
# - TypeIDField[Literal["user"]]
124+
# - TypeIDField[("user",)]
165125
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']")
126+
if len(item) != 1:
127+
raise TypeError("TypeIDField[...] expects a single prefix")
128+
item = item[0]
129+
130+
# Literal["user"]
131+
if get_origin(item) is Literal:
132+
args = get_args(item)
133+
if len(args) != 1 or not isinstance(args[0], str):
134+
raise TypeError("TypeIDField[Literal['prefix']] expects a single string literal")
135+
prefix = args[0]
136+
137+
# Plain "user"
138+
elif isinstance(item, str):
172139
prefix = item
173140

141+
else:
142+
raise TypeError("TypeIDField[...] expects a string prefix or Literal['prefix']")
143+
174144
name = f"TypeIDField_{prefix}"
175145

176146
# Optionally add a simple example that looks like TypeID format
177147
# You can improve this to a real example generator if your core has one.
178-
example = f"{prefix}_01hxxxxxxxxxxxxxxxxxxxxxxxxx"
148+
example = f"{prefix}_01hxxxxxxxxxxxxxxxxxxxxxxx"
179149

180150
# Create a new subclass of _TypeIDFieldBase with fixed meta
181151
field_cls = type(

0 commit comments

Comments
 (0)