uplid 1.0.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.
uplid-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: uplid
3
+ Version: 1.0.0
4
+ Summary: Universal Prefixed Literal IDs - type-safe, human-readable identifiers
5
+ Keywords: uuid,id,identifier,pydantic,type-safe,uuid7
6
+ Author: ZVS
7
+ Author-email: ZVS <zvs@daswolf.dev>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Framework :: Pydantic :: 2
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Typing :: Typed
16
+ Requires-Dist: pydantic>=2.10
17
+ Requires-Python: >=3.14
18
+ Project-URL: Homepage, https://github.com/zvsdev/uplid
19
+ Project-URL: Repository, https://github.com/zvsdev/uplid
20
+ Description-Content-Type: text/markdown
21
+
22
+ # UPLID
23
+
24
+ Universal Prefixed Literal IDs - type-safe, human-readable identifiers for Python 3.14+.
25
+
26
+ [![CI](https://github.com/zvsdev/uplid/actions/workflows/ci.yml/badge.svg)](https://github.com/zvsdev/uplid/actions/workflows/ci.yml)
27
+ [![PyPI](https://img.shields.io/pypi/v/uplid)](https://pypi.org/project/uplid/)
28
+ [![Python](https://img.shields.io/pypi/pyversions/uplid)](https://pypi.org/project/uplid/)
29
+
30
+ ## Features
31
+
32
+ - **Type-safe prefixes**: `UPLID[Literal["usr"]]` prevents mixing user IDs with org IDs
33
+ - **Human-readable**: `usr_4mJ9k2L8nP3qR7sT1vW5xY` (Stripe-style)
34
+ - **Time-sortable**: Built on UUIDv7 for natural ordering
35
+ - **Compact**: 22-character base62 encoding
36
+ - **Zero external deps**: Uses Python 3.14's stdlib `uuid7()`
37
+ - **Pydantic 2 native**: Full validation and serialization support
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install uplid
43
+ # or
44
+ uv add uplid
45
+ ```
46
+
47
+ Requires Python 3.14+.
48
+
49
+ ## Quick Start
50
+
51
+ ```python
52
+ from typing import Literal
53
+ from pydantic import BaseModel, Field
54
+ from uplid import UPLID, factory
55
+
56
+ # Define typed ID aliases
57
+ UserId = UPLID[Literal["usr"]]
58
+ OrgId = UPLID[Literal["org"]]
59
+
60
+ # Use in Pydantic models
61
+ class User(BaseModel):
62
+ id: UserId = Field(default_factory=factory(UserId))
63
+ org_id: OrgId
64
+
65
+ # Generate IDs
66
+ user_id = UPLID.generate("usr")
67
+ print(user_id) # usr_4mJ9k2L8nP3qR7sT1vW5xY
68
+
69
+ # Parse from string
70
+ parsed = UPLID.from_string("usr_4mJ9k2L8nP3qR7sT1vW5xY", "usr")
71
+
72
+ # Type safety - these are compile-time errors with ty/mypy:
73
+ # user.org_id = user_id # Error: UserId != OrgId
74
+ ```
75
+
76
+ ## Prefix Rules
77
+
78
+ Prefixes must be snake_case:
79
+ - Lowercase letters and underscores only
80
+ - Cannot start or end with underscore
81
+ - Examples: `usr`, `api_key`, `org_member`
82
+
83
+ ## API Reference
84
+
85
+ ### `UPLID[PREFIX]`
86
+
87
+ Generic class for prefixed IDs.
88
+
89
+ ```python
90
+ # Generate new ID
91
+ uid = UPLID.generate("usr")
92
+
93
+ # Parse from string
94
+ uid = UPLID.from_string("usr_abc123...", "usr")
95
+
96
+ # Properties
97
+ uid.prefix # "usr"
98
+ uid.uid # UUID object
99
+ uid.datetime # datetime from UUIDv7
100
+ uid.timestamp # float (Unix timestamp)
101
+ uid.base62_uid # "abc123..." (22 chars)
102
+ ```
103
+
104
+ ### `factory(UPLIDType)`
105
+
106
+ Creates a factory function for Pydantic's `default_factory`.
107
+
108
+ ```python
109
+ UserId = UPLID[Literal["usr"]]
110
+
111
+ class User(BaseModel):
112
+ id: UserId = Field(default_factory=factory(UserId))
113
+ ```
114
+
115
+ ### `parse(UPLIDType)`
116
+
117
+ Creates a parser function that raises `UPLIDError` on invalid input.
118
+
119
+ ```python
120
+ from uplid import UPLID, parse, UPLIDError
121
+
122
+ UserId = UPLID[Literal["usr"]]
123
+ parse_user_id = parse(UserId)
124
+
125
+ try:
126
+ uid = parse_user_id("usr_abc123...")
127
+ except UPLIDError as e:
128
+ print(e)
129
+ ```
130
+
131
+ ### `UPLIDType`
132
+
133
+ Protocol for generic functions accepting any UPLID:
134
+
135
+ ```python
136
+ from uplid import UPLIDType
137
+
138
+ def log_entity(id: UPLIDType) -> None:
139
+ print(f"{id.prefix} created at {id.datetime}")
140
+ ```
141
+
142
+ ### `UPLIDError`
143
+
144
+ Exception raised for invalid IDs. Subclasses `ValueError`.
145
+
146
+ ```python
147
+ from uplid import UPLIDError
148
+
149
+ try:
150
+ UPLID.from_string("invalid", "usr")
151
+ except UPLIDError as e:
152
+ print(e)
153
+ except ValueError: # Also works
154
+ pass
155
+ ```
156
+
157
+ ## License
158
+
159
+ MIT
uplid-1.0.0/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # UPLID
2
+
3
+ Universal Prefixed Literal IDs - type-safe, human-readable identifiers for Python 3.14+.
4
+
5
+ [![CI](https://github.com/zvsdev/uplid/actions/workflows/ci.yml/badge.svg)](https://github.com/zvsdev/uplid/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/uplid)](https://pypi.org/project/uplid/)
7
+ [![Python](https://img.shields.io/pypi/pyversions/uplid)](https://pypi.org/project/uplid/)
8
+
9
+ ## Features
10
+
11
+ - **Type-safe prefixes**: `UPLID[Literal["usr"]]` prevents mixing user IDs with org IDs
12
+ - **Human-readable**: `usr_4mJ9k2L8nP3qR7sT1vW5xY` (Stripe-style)
13
+ - **Time-sortable**: Built on UUIDv7 for natural ordering
14
+ - **Compact**: 22-character base62 encoding
15
+ - **Zero external deps**: Uses Python 3.14's stdlib `uuid7()`
16
+ - **Pydantic 2 native**: Full validation and serialization support
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install uplid
22
+ # or
23
+ uv add uplid
24
+ ```
25
+
26
+ Requires Python 3.14+.
27
+
28
+ ## Quick Start
29
+
30
+ ```python
31
+ from typing import Literal
32
+ from pydantic import BaseModel, Field
33
+ from uplid import UPLID, factory
34
+
35
+ # Define typed ID aliases
36
+ UserId = UPLID[Literal["usr"]]
37
+ OrgId = UPLID[Literal["org"]]
38
+
39
+ # Use in Pydantic models
40
+ class User(BaseModel):
41
+ id: UserId = Field(default_factory=factory(UserId))
42
+ org_id: OrgId
43
+
44
+ # Generate IDs
45
+ user_id = UPLID.generate("usr")
46
+ print(user_id) # usr_4mJ9k2L8nP3qR7sT1vW5xY
47
+
48
+ # Parse from string
49
+ parsed = UPLID.from_string("usr_4mJ9k2L8nP3qR7sT1vW5xY", "usr")
50
+
51
+ # Type safety - these are compile-time errors with ty/mypy:
52
+ # user.org_id = user_id # Error: UserId != OrgId
53
+ ```
54
+
55
+ ## Prefix Rules
56
+
57
+ Prefixes must be snake_case:
58
+ - Lowercase letters and underscores only
59
+ - Cannot start or end with underscore
60
+ - Examples: `usr`, `api_key`, `org_member`
61
+
62
+ ## API Reference
63
+
64
+ ### `UPLID[PREFIX]`
65
+
66
+ Generic class for prefixed IDs.
67
+
68
+ ```python
69
+ # Generate new ID
70
+ uid = UPLID.generate("usr")
71
+
72
+ # Parse from string
73
+ uid = UPLID.from_string("usr_abc123...", "usr")
74
+
75
+ # Properties
76
+ uid.prefix # "usr"
77
+ uid.uid # UUID object
78
+ uid.datetime # datetime from UUIDv7
79
+ uid.timestamp # float (Unix timestamp)
80
+ uid.base62_uid # "abc123..." (22 chars)
81
+ ```
82
+
83
+ ### `factory(UPLIDType)`
84
+
85
+ Creates a factory function for Pydantic's `default_factory`.
86
+
87
+ ```python
88
+ UserId = UPLID[Literal["usr"]]
89
+
90
+ class User(BaseModel):
91
+ id: UserId = Field(default_factory=factory(UserId))
92
+ ```
93
+
94
+ ### `parse(UPLIDType)`
95
+
96
+ Creates a parser function that raises `UPLIDError` on invalid input.
97
+
98
+ ```python
99
+ from uplid import UPLID, parse, UPLIDError
100
+
101
+ UserId = UPLID[Literal["usr"]]
102
+ parse_user_id = parse(UserId)
103
+
104
+ try:
105
+ uid = parse_user_id("usr_abc123...")
106
+ except UPLIDError as e:
107
+ print(e)
108
+ ```
109
+
110
+ ### `UPLIDType`
111
+
112
+ Protocol for generic functions accepting any UPLID:
113
+
114
+ ```python
115
+ from uplid import UPLIDType
116
+
117
+ def log_entity(id: UPLIDType) -> None:
118
+ print(f"{id.prefix} created at {id.datetime}")
119
+ ```
120
+
121
+ ### `UPLIDError`
122
+
123
+ Exception raised for invalid IDs. Subclasses `ValueError`.
124
+
125
+ ```python
126
+ from uplid import UPLIDError
127
+
128
+ try:
129
+ UPLID.from_string("invalid", "usr")
130
+ except UPLIDError as e:
131
+ print(e)
132
+ except ValueError: # Also works
133
+ pass
134
+ ```
135
+
136
+ ## License
137
+
138
+ MIT
@@ -0,0 +1,83 @@
1
+ [project]
2
+ name = "uplid"
3
+ version = "1.0.0"
4
+ description = "Universal Prefixed Literal IDs - type-safe, human-readable identifiers"
5
+ authors = [{ name = "ZVS", email = "zvs@daswolf.dev" }]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ requires-python = ">=3.14"
9
+ keywords = ["uuid", "id", "identifier", "pydantic", "type-safe", "uuid7"]
10
+ dependencies = ["pydantic>=2.10"]
11
+ classifiers = [
12
+ "Development Status :: 5 - Production/Stable",
13
+ "Framework :: Pydantic :: 2",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/zvsdev/uplid"
23
+ Repository = "https://github.com/zvsdev/uplid"
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.9.28,<0.10"]
27
+ build-backend = "uv_build"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=9.0.2,<10",
32
+ "pytest-cov>=6.1.1,<7",
33
+ "hypothesis>=6.151.4,<7",
34
+ "ty>=0.0.14,<0.1",
35
+ "ruff>=0.14.14,<0.15",
36
+ ]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py314"
41
+ src = ["src", "tests"]
42
+
43
+ [tool.ruff.lint]
44
+ select = [
45
+ "E", "W", "F", "I", "B", "S", "A", "T20", "RET", "SLF", "SLOT", "TRY", "FBT",
46
+ "ANN", "TCH", "C4", "SIM", "ARG", "ERA", "PIE", "PERF", "FURB", "UP", "N", "Q", "RUF", "D",
47
+ ]
48
+ ignore = ["D100", "D104", "D107", "TRY003"]
49
+
50
+ [tool.ruff.lint.isort]
51
+ known-first-party = ["uplid"]
52
+ known-third-party = ["pydantic", "pydantic_core"]
53
+ required-imports = ["from __future__ import annotations"]
54
+ force-single-line = false
55
+ lines-after-imports = 2
56
+
57
+ [tool.ruff.lint.pydocstyle]
58
+ convention = "google"
59
+
60
+ [tool.ruff.lint.per-file-ignores]
61
+ "tests/**/*.py" = ["S101", "S301", "ANN", "D", "ARG001", "SLF001", "N806"]
62
+
63
+ [tool.ruff.format]
64
+ quote-style = "double"
65
+ docstring-code-format = true
66
+
67
+ [tool.ty.environment]
68
+ python-version = "3.14"
69
+
70
+ [tool.pytest.ini_options]
71
+ testpaths = ["tests"]
72
+ addopts = ["--cov=uplid", "--cov-report=term-missing", "--cov-fail-under=95"]
73
+
74
+ [tool.coverage.run]
75
+ branch = true
76
+ source = ["src/uplid"]
77
+
78
+ [tool.coverage.report]
79
+ exclude_lines = [
80
+ "pragma: no cover",
81
+ "if TYPE_CHECKING:",
82
+ "\\.\\.\\.",
83
+ ]
@@ -0,0 +1,8 @@
1
+ """Universal Prefixed Literal IDs - type-safe, human-readable identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uplid.uplid import UPLID, UPLIDError, UPLIDType, factory, parse
6
+
7
+
8
+ __all__ = ["UPLID", "UPLIDError", "UPLIDType", "factory", "parse"]
File without changes
@@ -0,0 +1,453 @@
1
+ """Universal Prefixed Literal IDs - type-safe, human-readable identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC
7
+ from datetime import datetime as dt_datetime
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ Any,
11
+ LiteralString,
12
+ Protocol,
13
+ Self,
14
+ get_args,
15
+ get_origin,
16
+ runtime_checkable,
17
+ )
18
+ from uuid import UUID, uuid7
19
+
20
+ from pydantic_core import CoreSchema, core_schema
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ import datetime as dt
25
+ from collections.abc import Callable
26
+
27
+
28
+ # Base62 encoding: 0-9, A-Z, a-z (62 characters)
29
+ # IMPORTANT: '0' must be first character for zfill padding to work correctly
30
+ _BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
31
+ _BASE62_DECODE_MAP = {c: i for i, c in enumerate(_BASE62_ALPHABET)}
32
+
33
+ # A 128-bit UUID requires ceiling(128 / log2(62)) = 22 base62 characters
34
+ _BASE62_UUID_LENGTH = 22
35
+
36
+ # UUIDv7 timestamp extraction (RFC 9562):
37
+ # Bits 0-47 contain 48-bit Unix timestamp in milliseconds
38
+ _UUIDV7_TIMESTAMP_SHIFT = 80 # 128 - 48 = shift to extract timestamp
39
+ _MS_PER_SECOND = 1000
40
+
41
+ # Prefix validation: snake_case (lowercase letters and single underscores)
42
+ # - Must start and end with a letter
43
+ # - Cannot have consecutive underscores
44
+ # - Single character prefixes are allowed
45
+ # - Maximum 64 characters to prevent DoS via regex on huge inputs
46
+ _PREFIX_PATTERN = re.compile(r"^[a-z]([a-z]*(_[a-z]+)*)?$")
47
+ _PREFIX_MAX_LENGTH = 64
48
+
49
+
50
+ class UPLIDError(ValueError):
51
+ """Raised when UPLID parsing or validation fails."""
52
+
53
+
54
+ @runtime_checkable
55
+ class UPLIDType(Protocol):
56
+ """Protocol for any UPLID, useful for generic function signatures.
57
+
58
+ Example:
59
+ def log_entity(entity_id: UPLIDType) -> None:
60
+ print(f"{entity_id.prefix} created at {entity_id.datetime}")
61
+ """
62
+
63
+ __slots__ = ()
64
+
65
+ @property
66
+ def prefix(self) -> str:
67
+ """The prefix identifier (e.g., 'usr', 'api_key')."""
68
+ ...
69
+
70
+ @property
71
+ def uid(self) -> UUID:
72
+ """The underlying UUIDv7."""
73
+ ...
74
+
75
+ @property
76
+ def datetime(self) -> dt.datetime:
77
+ """The timestamp extracted from the UUIDv7."""
78
+ ...
79
+
80
+ @property
81
+ def timestamp(self) -> float:
82
+ """The Unix timestamp (seconds) from the UUIDv7."""
83
+ ...
84
+
85
+ @property
86
+ def base62_uid(self) -> str:
87
+ """The base62-encoded UID (22 characters)."""
88
+ ...
89
+
90
+ def __str__(self) -> str:
91
+ """String representation as '<prefix>_<base62uid>'."""
92
+ ...
93
+
94
+
95
+ def _int_to_base62(num: int) -> str:
96
+ """Convert integer to base62 string, padded to 22 chars for UUIDv7."""
97
+ if num == 0:
98
+ return "0" * _BASE62_UUID_LENGTH
99
+
100
+ result: list[str] = []
101
+ while num > 0:
102
+ num, remainder = divmod(num, 62)
103
+ result.append(_BASE62_ALPHABET[remainder])
104
+
105
+ return "".join(reversed(result)).zfill(_BASE62_UUID_LENGTH)
106
+
107
+
108
+ def _base62_to_int(s: str) -> int:
109
+ """Convert base62 string to integer.
110
+
111
+ Raises:
112
+ ValueError: If input exceeds expected UUID length.
113
+ KeyError: If input contains invalid base62 characters.
114
+ """
115
+ if len(s) > _BASE62_UUID_LENGTH:
116
+ raise ValueError(f"Input exceeds maximum length of {_BASE62_UUID_LENGTH}")
117
+ result = 0
118
+ for char in s:
119
+ result = result * 62 + _BASE62_DECODE_MAP[char]
120
+ return result
121
+
122
+
123
+ def _validate_prefix(prefix: str) -> None:
124
+ """Validate that prefix follows snake_case rules.
125
+
126
+ Raises:
127
+ UPLIDError: If prefix is invalid.
128
+ """
129
+ if len(prefix) > _PREFIX_MAX_LENGTH:
130
+ raise UPLIDError(
131
+ f"Prefix must be at most {_PREFIX_MAX_LENGTH} characters, got {len(prefix)}"
132
+ )
133
+ if not _PREFIX_PATTERN.match(prefix):
134
+ raise UPLIDError(
135
+ f"Prefix must be snake_case (lowercase letters, single underscores, "
136
+ f"cannot start/end with underscore or have consecutive underscores), "
137
+ f"got {prefix!r}"
138
+ )
139
+
140
+
141
+ class UPLID[PREFIX: LiteralString]:
142
+ """Universal Prefixed Literal ID with type-safe prefix validation.
143
+
144
+ A UPLID combines a string prefix (like 'usr', 'api_key') with a UUIDv7,
145
+ encoded in base62 for compactness. The prefix enables runtime and static
146
+ type checking to prevent mixing IDs from different domains.
147
+
148
+ Example:
149
+ >>> from typing import Literal
150
+ >>> UserId = UPLID[Literal["usr"]]
151
+ >>> user_id = UPLID.generate("usr")
152
+ >>> print(user_id) # usr_1a2B3c4D5e6F7g8H9i0J1k
153
+
154
+ Note:
155
+ The `datetime` and `timestamp` properties assume the underlying UUID
156
+ is a valid UUIDv7. If you construct a UPLID with a non-UUIDv7 UUID
157
+ (e.g., UUIDv4), these properties will return meaningless values.
158
+ """
159
+
160
+ __slots__ = ("_base62_uid", "_prefix", "_uid")
161
+
162
+ def __init__(self, prefix: PREFIX, uid: UUID) -> None:
163
+ """Initialize a UPLID with a prefix and UUID.
164
+
165
+ Args:
166
+ prefix: The string prefix (must be snake_case).
167
+ uid: The UUID (should be UUIDv7 for datetime/timestamp to be meaningful).
168
+
169
+ Raises:
170
+ UPLIDError: If prefix is not valid snake_case.
171
+ """
172
+ _validate_prefix(prefix)
173
+ self._prefix = prefix
174
+ self._uid = uid
175
+ self._base62_uid: str | None = None
176
+
177
+ @property
178
+ def prefix(self) -> PREFIX:
179
+ """The prefix identifier (e.g., 'usr', 'api_key')."""
180
+ return self._prefix
181
+
182
+ @property
183
+ def uid(self) -> UUID:
184
+ """The underlying UUID (typically UUIDv7)."""
185
+ return self._uid
186
+
187
+ @property
188
+ def base62_uid(self) -> str:
189
+ """The base62-encoded UID (22 characters)."""
190
+ if self._base62_uid is None:
191
+ self._base62_uid = _int_to_base62(self._uid.int)
192
+ return self._base62_uid
193
+
194
+ @property
195
+ def datetime(self) -> dt_datetime:
196
+ """The timestamp extracted from the UUIDv7.
197
+
198
+ Note:
199
+ This assumes the UUID is a valid UUIDv7. For non-UUIDv7 UUIDs,
200
+ the returned datetime will be meaningless.
201
+ """
202
+ ms = self._uid.int >> _UUIDV7_TIMESTAMP_SHIFT
203
+ return dt_datetime.fromtimestamp(ms / _MS_PER_SECOND, tz=UTC)
204
+
205
+ @property
206
+ def timestamp(self) -> float:
207
+ """The Unix timestamp (seconds) from the UUIDv7.
208
+
209
+ Note:
210
+ This assumes the UUID is a valid UUIDv7. For non-UUIDv7 UUIDs,
211
+ the returned timestamp will be meaningless.
212
+ """
213
+ ms = self._uid.int >> _UUIDV7_TIMESTAMP_SHIFT
214
+ return ms / _MS_PER_SECOND
215
+
216
+ def __str__(self) -> str:
217
+ """Return the string representation as '<prefix>_<base62uid>'."""
218
+ return f"{self._prefix}_{self.base62_uid}"
219
+
220
+ def __repr__(self) -> str:
221
+ """Return a detailed representation."""
222
+ return f"UPLID({self._prefix!r}, {self.base62_uid!r})"
223
+
224
+ def __hash__(self) -> int:
225
+ """Return hash for use in sets and dict keys."""
226
+ return hash((self._prefix, self._uid))
227
+
228
+ def __eq__(self, other: object) -> bool:
229
+ """Check equality with another UPLID."""
230
+ if isinstance(other, UPLID):
231
+ return self._prefix == other._prefix and self._uid == other._uid
232
+ return NotImplemented
233
+
234
+ def __lt__(self, other: object) -> bool:
235
+ """Compare for sorting (by prefix, then by uid)."""
236
+ if isinstance(other, UPLID):
237
+ return (self._prefix, self._uid) < (other._prefix, other._uid) # type: ignore[operator]
238
+ return NotImplemented
239
+
240
+ def __le__(self, other: object) -> bool:
241
+ """Compare for sorting (by prefix, then by uid)."""
242
+ if isinstance(other, UPLID):
243
+ return (self._prefix, self._uid) <= (other._prefix, other._uid) # type: ignore[operator]
244
+ return NotImplemented
245
+
246
+ def __gt__(self, other: object) -> bool:
247
+ """Compare for sorting (by prefix, then by uid)."""
248
+ if isinstance(other, UPLID):
249
+ return (self._prefix, self._uid) > (other._prefix, other._uid) # type: ignore[operator]
250
+ return NotImplemented
251
+
252
+ def __ge__(self, other: object) -> bool:
253
+ """Compare for sorting (by prefix, then by uid)."""
254
+ if isinstance(other, UPLID):
255
+ return (self._prefix, self._uid) >= (other._prefix, other._uid) # type: ignore[operator]
256
+ return NotImplemented
257
+
258
+ def __copy__(self) -> Self:
259
+ """Return self (UPLIDs are immutable)."""
260
+ return self
261
+
262
+ def __deepcopy__(self, memo: dict[int, Any]) -> Self:
263
+ """Return self (UPLIDs are immutable)."""
264
+ return self
265
+
266
+ def __reduce__(self) -> tuple[type[Self], tuple[str, UUID]]:
267
+ """Support pickling for multiprocessing, caching, etc."""
268
+ return (type(self), (self._prefix, self._uid))
269
+
270
+ @classmethod
271
+ def generate(cls, prefix: PREFIX) -> Self:
272
+ """Generate a new UPLID with the given prefix.
273
+
274
+ Args:
275
+ prefix: The string prefix (must be snake_case: lowercase letters
276
+ and single underscores, cannot start/end with underscore).
277
+
278
+ Returns:
279
+ A new UPLID instance.
280
+
281
+ Raises:
282
+ UPLIDError: If the prefix is not valid snake_case.
283
+ """
284
+ _validate_prefix(prefix)
285
+ instance = cls.__new__(cls)
286
+ instance._prefix = prefix # noqa: SLF001
287
+ instance._uid = uuid7() # noqa: SLF001
288
+ instance._base62_uid = None # noqa: SLF001
289
+ return instance
290
+
291
+ @classmethod
292
+ def from_string(cls, string: str, prefix: PREFIX) -> Self:
293
+ """Parse a UPLID from its string representation.
294
+
295
+ Args:
296
+ string: The string to parse (format: '<prefix>_<base62uid>').
297
+ prefix: The expected prefix.
298
+
299
+ Returns:
300
+ A UPLID instance.
301
+
302
+ Raises:
303
+ UPLIDError: If the string format is invalid or prefix doesn't match.
304
+
305
+ Note:
306
+ This method does not validate that the decoded UUID is a valid
307
+ UUIDv7. The datetime/timestamp properties may return meaningless
308
+ values if the original ID was not created with a UUIDv7.
309
+ """
310
+ if "_" not in string:
311
+ raise UPLIDError(f"UPLID must be in format '<prefix>_<uid>', got {string!r}")
312
+
313
+ last_underscore = string.rfind("_")
314
+ parsed_prefix = string[:last_underscore]
315
+ encoded_uid = string[last_underscore + 1 :]
316
+
317
+ _validate_prefix(parsed_prefix)
318
+
319
+ if parsed_prefix != prefix:
320
+ raise UPLIDError(f"Expected prefix {prefix!r}, got {parsed_prefix!r}")
321
+
322
+ if len(encoded_uid) != _BASE62_UUID_LENGTH:
323
+ raise UPLIDError(
324
+ f"UID must be {_BASE62_UUID_LENGTH} characters, got {len(encoded_uid)}"
325
+ )
326
+
327
+ try:
328
+ uid_int = _base62_to_int(encoded_uid)
329
+ uid = UUID(int=uid_int)
330
+ except (KeyError, ValueError) as e:
331
+ raise UPLIDError(f"Invalid base62 UID: {encoded_uid!r}") from e
332
+
333
+ instance = cls.__new__(cls)
334
+ instance._prefix = prefix # noqa: SLF001
335
+ instance._uid = uid # noqa: SLF001
336
+ instance._base62_uid = encoded_uid # noqa: SLF001
337
+ return instance
338
+
339
+ @classmethod
340
+ def __get_pydantic_core_schema__(
341
+ cls,
342
+ source_type: Any, # noqa: ANN401
343
+ handler: Any, # noqa: ANN401
344
+ ) -> CoreSchema:
345
+ """Pydantic integration for validation and serialization.
346
+
347
+ Note:
348
+ This method accesses typing internals (__args__, __value__) which
349
+ may change between Python versions. Integration tests should verify
350
+ compatibility with supported Python versions.
351
+ """
352
+ origin = get_origin(source_type)
353
+ if origin is None:
354
+ raise UPLIDError(
355
+ "UPLID must be parameterized with a prefix literal, e.g. UPLID[Literal['usr']]"
356
+ )
357
+
358
+ args = get_args(source_type)
359
+ if not args: # pragma: no cover
360
+ raise UPLIDError("UPLID requires a Literal prefix type argument")
361
+
362
+ prefix_type = args[0]
363
+ prefix_args = get_args(prefix_type)
364
+
365
+ # Handle TypeVar case (Python 3.12+ type parameter syntax)
366
+ if not prefix_args: # pragma: no cover
367
+ if hasattr(prefix_type, "__value__"):
368
+ prefix_args = get_args(prefix_type.__value__)
369
+ if not prefix_args:
370
+ raise UPLIDError(f"Could not extract prefix from {prefix_type}")
371
+
372
+ prefix_str: str = prefix_args[0]
373
+
374
+ def validate(v: UPLID[Any] | str) -> UPLID[Any]:
375
+ if isinstance(v, str):
376
+ return cls.from_string(v, prefix_str)
377
+ if isinstance(v, UPLID):
378
+ if v.prefix != prefix_str:
379
+ raise UPLIDError(f"Expected prefix {prefix_str!r}, got {v.prefix!r}")
380
+ return v
381
+ raise UPLIDError(f"Expected UPLID or str, got {type(v).__name__}")
382
+
383
+ return core_schema.json_or_python_schema(
384
+ json_schema=core_schema.chain_schema(
385
+ [
386
+ core_schema.str_schema(),
387
+ core_schema.no_info_plain_validator_function(validate),
388
+ ]
389
+ ),
390
+ python_schema=core_schema.no_info_plain_validator_function(validate),
391
+ serialization=core_schema.plain_serializer_function_ser_schema(str),
392
+ )
393
+
394
+
395
+ def _get_prefix[PREFIX: LiteralString](uplid_type: type[UPLID[PREFIX]]) -> str:
396
+ """Extract the prefix string from a parameterized UPLID type."""
397
+ args = get_args(uplid_type)
398
+ if not args:
399
+ raise UPLIDError("UPLID type must be parameterized with a Literal prefix")
400
+ literal_type = args[0]
401
+ literal_args = get_args(literal_type)
402
+ # Handle TypeVar case (Python 3.12+ type parameter syntax)
403
+ if not literal_args and hasattr(literal_type, "__value__"): # pragma: no cover
404
+ literal_args = get_args(literal_type.__value__)
405
+ if not literal_args: # pragma: no cover
406
+ raise UPLIDError(f"Could not extract prefix from {literal_type}")
407
+ return literal_args[0]
408
+
409
+
410
+ def factory[PREFIX: LiteralString](
411
+ uplid_type: type[UPLID[PREFIX]],
412
+ ) -> Callable[[], UPLID[PREFIX]]:
413
+ """Create a factory function for generating new UPLIDs of a specific type.
414
+
415
+ This is useful with Pydantic's Field(default_factory=...).
416
+
417
+ Example:
418
+ UserId = UPLID[Literal["usr"]]
419
+
420
+ class User(BaseModel):
421
+ id: UserId = Field(default_factory=factory(UserId))
422
+ """
423
+ prefix = _get_prefix(uplid_type)
424
+
425
+ def _factory() -> UPLID[PREFIX]:
426
+ return UPLID.generate(prefix)
427
+
428
+ return _factory
429
+
430
+
431
+ def parse[PREFIX: LiteralString](
432
+ uplid_type: type[UPLID[PREFIX]],
433
+ ) -> Callable[[str], UPLID[PREFIX]]:
434
+ """Create a parse function for converting strings to UPLIDs.
435
+
436
+ This is useful for parsing user input outside of Pydantic models.
437
+ Raises UPLIDError on invalid input.
438
+
439
+ Example:
440
+ UserId = UPLID[Literal["usr"]]
441
+ parse_user_id = parse(UserId)
442
+
443
+ try:
444
+ user_id = parse_user_id("usr_1a2B3c4D5e6F7g8H9i0J1k")
445
+ except UPLIDError as e:
446
+ print(f"Invalid ID: {e}")
447
+ """
448
+ prefix = _get_prefix(uplid_type)
449
+
450
+ def _parse(v: str) -> UPLID[PREFIX]:
451
+ return UPLID.from_string(v, prefix)
452
+
453
+ return _parse