uplid 0.0.4__py3-none-any.whl → 1.0.1__py3-none-any.whl
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/__init__.py +7 -3
- uplid/uplid.py +400 -153
- uplid-1.0.1.dist-info/METADATA +296 -0
- uplid-1.0.1.dist-info/RECORD +6 -0
- {uplid-0.0.4.dist-info → uplid-1.0.1.dist-info}/WHEEL +1 -1
- uplid-0.0.4.dist-info/LICENSE +0 -21
- uplid-0.0.4.dist-info/METADATA +0 -144
- uplid-0.0.4.dist-info/RECORD +0 -7
uplid/__init__.py
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
"""Universal Prefixed Literal IDs - type-safe, human-readable identifiers."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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"]
|
uplid/uplid.py
CHANGED
|
@@ -1,206 +1,453 @@
|
|
|
1
|
-
|
|
2
|
-
from typing import Any, Callable, Generic, Type, TypeVar, Union, get_args, get_origin
|
|
1
|
+
"""Universal Prefixed Literal IDs - type-safe, human-readable identifiers."""
|
|
3
2
|
|
|
4
|
-
from
|
|
5
|
-
from pydantic import GetCoreSchemaHandler, ValidationError
|
|
6
|
-
from pydantic_core import CoreSchema, PydanticCustomError, core_schema
|
|
7
|
-
from typing_extensions import LiteralString, Self
|
|
3
|
+
from __future__ import annotations
|
|
8
4
|
|
|
9
|
-
|
|
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
|
|
10
19
|
|
|
20
|
+
from pydantic_core import CoreSchema, core_schema
|
|
11
21
|
|
|
12
|
-
|
|
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}")
|
|
13
61
|
"""
|
|
14
|
-
A class representing an ID with a prefixed lteral string identifier. The UID portion is managed using the KSUID
|
|
15
|
-
format encoded via base62.
|
|
16
62
|
|
|
17
|
-
|
|
18
|
-
prefix (PREFIX): A string literal prefix for the ID. Can be specified as a type param or infered from args
|
|
19
|
-
uid (KsuidMs): The KsuidMs object representing the unique identifier.
|
|
63
|
+
__slots__ = ()
|
|
20
64
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
26
125
|
|
|
27
126
|
Raises:
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
30
153
|
|
|
31
154
|
Note:
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
34
158
|
"""
|
|
35
159
|
|
|
36
|
-
|
|
37
|
-
uid: KsuidMs
|
|
160
|
+
__slots__ = ("_base62_uid", "_prefix", "_uid")
|
|
38
161
|
|
|
39
|
-
def __init__(self, prefix: PREFIX, uid:
|
|
40
|
-
|
|
41
|
-
if isinstance(uid, str):
|
|
42
|
-
self.uid = self._validate_uid(uid)
|
|
43
|
-
else:
|
|
44
|
-
self.uid = uid
|
|
162
|
+
def __init__(self, prefix: PREFIX, uid: UUID) -> None:
|
|
163
|
+
"""Initialize a UPLID with a prefix and UUID.
|
|
45
164
|
|
|
46
|
-
|
|
47
|
-
|
|
165
|
+
Args:
|
|
166
|
+
prefix: The string prefix (must be snake_case).
|
|
167
|
+
uid: The UUID (should be UUIDv7 for datetime/timestamp to be meaningful).
|
|
48
168
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def __gt__(self, other: Self) -> bool:
|
|
58
|
-
if isinstance(other, self.__class__):
|
|
59
|
-
if self.prefix == other.prefix:
|
|
60
|
-
return self.uid > other.uid
|
|
61
|
-
return self.prefix > other.prefix
|
|
62
|
-
raise TypeError(f"Cannot compare {self.__class__} with {other.__class__}")
|
|
63
|
-
|
|
64
|
-
def __lt__(self, other: Self) -> bool:
|
|
65
|
-
if isinstance(other, self.__class__):
|
|
66
|
-
if self.prefix == other.prefix:
|
|
67
|
-
return self.uid < other.uid
|
|
68
|
-
return self.prefix < other.prefix
|
|
69
|
-
raise TypeError(f"Cannot compare {self.__class__} with {other.__class__}")
|
|
70
|
-
|
|
71
|
-
def __gte__(self, other: Self) -> bool:
|
|
72
|
-
if isinstance(other, self.__class__):
|
|
73
|
-
if self == other:
|
|
74
|
-
return True
|
|
75
|
-
return self > other
|
|
76
|
-
raise TypeError(f"Cannot compare {self.__class__} with {other.__class__}")
|
|
77
|
-
|
|
78
|
-
def __lte__(self, other: Self) -> bool:
|
|
79
|
-
if isinstance(other, self.__class__):
|
|
80
|
-
if self == other:
|
|
81
|
-
return True
|
|
82
|
-
return self < other
|
|
83
|
-
raise TypeError(f"Cannot compare {self.__class__} with {other.__class__}")
|
|
84
|
-
|
|
85
|
-
@staticmethod
|
|
86
|
-
def _validate_uid(v: str) -> KsuidMs:
|
|
87
|
-
if len(v) != 27:
|
|
88
|
-
raise ValueError(f"Expected encoded_uid to be 27 characters long, got {len(v)}")
|
|
89
|
-
try:
|
|
90
|
-
return KsuidMs.from_base62(v)
|
|
91
|
-
except OverflowError as e:
|
|
92
|
-
raise ValueError(f"Invalid encoded_uid: {v}") from e
|
|
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
|
|
93
176
|
|
|
94
177
|
@property
|
|
95
|
-
def
|
|
96
|
-
|
|
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)
|
|
97
204
|
|
|
98
205
|
@property
|
|
99
206
|
def timestamp(self) -> float:
|
|
100
|
-
|
|
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
|
|
101
290
|
|
|
102
291
|
@classmethod
|
|
103
292
|
def from_string(cls, string: str, prefix: PREFIX) -> Self:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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)}"
|
|
108
325
|
)
|
|
109
|
-
_prefix, encoded_uid = split_content
|
|
110
|
-
if not _prefix.isalpha():
|
|
111
|
-
raise ValueError(f"Prefix can only contain alphabetic characters, got {_prefix}")
|
|
112
|
-
if not _prefix.islower():
|
|
113
|
-
raise ValueError(f"Prefix must be lowercase, got {_prefix}")
|
|
114
|
-
if _prefix != prefix:
|
|
115
|
-
raise ValueError(f"Expected prefix to be {prefix}, got {_prefix}")
|
|
116
|
-
if not encoded_uid:
|
|
117
|
-
raise ValueError("Expected encoded_uid to be a non-empty string")
|
|
118
|
-
uid = cls._validate_uid(encoded_uid)
|
|
119
|
-
return cls(prefix, uid)
|
|
120
326
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
124
338
|
|
|
125
339
|
@classmethod
|
|
126
340
|
def __get_pydantic_core_schema__(
|
|
127
|
-
cls,
|
|
341
|
+
cls,
|
|
342
|
+
source_type: Any, # noqa: ANN401
|
|
343
|
+
handler: Any, # noqa: ANN401
|
|
128
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
|
+
"""
|
|
129
352
|
origin = get_origin(source_type)
|
|
130
|
-
if origin is None:
|
|
131
|
-
raise
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
raise
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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]:
|
|
151
375
|
if isinstance(v, str):
|
|
152
|
-
|
|
153
|
-
if v
|
|
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}")
|
|
154
380
|
return v
|
|
155
|
-
raise
|
|
381
|
+
raise UPLIDError(f"Expected UPLID or str, got {type(v).__name__}")
|
|
156
382
|
|
|
157
|
-
python_schema = core_schema.chain_schema(
|
|
158
|
-
[
|
|
159
|
-
core_schema.no_info_plain_validator_function(val_prefix),
|
|
160
|
-
]
|
|
161
|
-
)
|
|
162
383
|
return core_schema.json_or_python_schema(
|
|
163
384
|
json_schema=core_schema.chain_schema(
|
|
164
385
|
[
|
|
165
386
|
core_schema.str_schema(),
|
|
166
|
-
core_schema.
|
|
387
|
+
core_schema.no_info_plain_validator_function(validate),
|
|
167
388
|
]
|
|
168
389
|
),
|
|
169
|
-
python_schema=
|
|
170
|
-
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
390
|
+
python_schema=core_schema.no_info_plain_validator_function(validate),
|
|
391
|
+
serialization=core_schema.plain_serializer_function_ser_schema(str),
|
|
171
392
|
)
|
|
172
393
|
|
|
173
394
|
|
|
174
|
-
def _get_prefix(uplid_type:
|
|
175
|
-
|
|
176
|
-
|
|
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
|
+
|
|
177
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.
|
|
178
414
|
|
|
179
|
-
|
|
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
|
+
"""
|
|
180
423
|
prefix = _get_prefix(uplid_type)
|
|
181
424
|
|
|
182
|
-
def
|
|
425
|
+
def _factory() -> UPLID[PREFIX]:
|
|
183
426
|
return UPLID.generate(prefix)
|
|
184
427
|
|
|
185
|
-
return
|
|
428
|
+
return _factory
|
|
186
429
|
|
|
187
430
|
|
|
188
|
-
def
|
|
189
|
-
|
|
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)
|
|
190
442
|
|
|
191
|
-
def f(v: str) -> UPLID[PREFIX]:
|
|
192
443
|
try:
|
|
193
|
-
|
|
194
|
-
except
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
],
|
|
204
|
-
) from e
|
|
205
|
-
|
|
206
|
-
return f
|
|
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
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uplid
|
|
3
|
+
Version: 1.0.1
|
|
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
|
+
[](https://github.com/zvsdev/uplid/actions/workflows/ci.yml)
|
|
27
|
+
[](https://pypi.org/project/uplid/)
|
|
28
|
+
[](https://pypi.org/project/uplid/)
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Type-safe prefixes**: `UPLID[Literal["usr"]]` prevents mixing user IDs with org IDs at compile time
|
|
33
|
+
- **Human-readable**: `usr_0M3xL9kQ7vR2nP5wY1jZ4c` (Stripe-style prefixed IDs)
|
|
34
|
+
- **Time-sortable**: Built on UUIDv7 (RFC 9562) for natural chronological ordering
|
|
35
|
+
- **Compact**: 22-character base62 encoding (URL-safe, no special characters)
|
|
36
|
+
- **Stdlib UUIDs**: Uses Python 3.14's native `uuid7()` - no external UUID libraries
|
|
37
|
+
- **Pydantic 2 native**: Full validation and serialization support
|
|
38
|
+
- **Thread-safe**: ID generation is safe for concurrent use
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install uplid
|
|
44
|
+
# or
|
|
45
|
+
uv add uplid
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Requires Python 3.14+ and Pydantic 2.10+.
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from typing import Literal
|
|
54
|
+
from pydantic import BaseModel, Field
|
|
55
|
+
from uplid import UPLID, factory
|
|
56
|
+
|
|
57
|
+
# Define typed aliases and factories
|
|
58
|
+
UserId = UPLID[Literal["usr"]]
|
|
59
|
+
OrgId = UPLID[Literal["org"]]
|
|
60
|
+
UserIdFactory = factory(UserId)
|
|
61
|
+
|
|
62
|
+
# Use in Pydantic models
|
|
63
|
+
class User(BaseModel):
|
|
64
|
+
id: UserId = Field(default_factory=UserIdFactory)
|
|
65
|
+
org_id: OrgId
|
|
66
|
+
|
|
67
|
+
# Generate IDs
|
|
68
|
+
user_id = UPLID.generate("usr")
|
|
69
|
+
print(user_id) # usr_0M3xL9kQ7vR2nP5wY1jZ4c
|
|
70
|
+
|
|
71
|
+
# Parse from string
|
|
72
|
+
parsed = UPLID.from_string("usr_0M3xL9kQ7vR2nP5wY1jZ4c", "usr")
|
|
73
|
+
|
|
74
|
+
# Access properties
|
|
75
|
+
print(parsed.datetime) # 2026-01-30 12:34:56.789000+00:00
|
|
76
|
+
print(parsed.timestamp) # 1738240496.789
|
|
77
|
+
|
|
78
|
+
# Type safety - these are compile-time errors:
|
|
79
|
+
# user.org_id = user_id # Error: UserId is not compatible with OrgId
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Pydantic Serialization
|
|
83
|
+
|
|
84
|
+
UPLIDs serialize to strings and deserialize with validation:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from pydantic import BaseModel, Field
|
|
88
|
+
from uplid import UPLID, factory
|
|
89
|
+
|
|
90
|
+
UserId = UPLID[Literal["usr"]]
|
|
91
|
+
UserIdFactory = factory(UserId)
|
|
92
|
+
|
|
93
|
+
class User(BaseModel):
|
|
94
|
+
id: UserId = Field(default_factory=UserIdFactory)
|
|
95
|
+
name: str
|
|
96
|
+
|
|
97
|
+
user = User(name="Alice")
|
|
98
|
+
|
|
99
|
+
# Serialize to dict - ID becomes string
|
|
100
|
+
user.model_dump()
|
|
101
|
+
# {"id": "usr_0M3xL9kQ7vR2nP5wY1jZ4c", "name": "Alice"}
|
|
102
|
+
|
|
103
|
+
# Serialize to JSON
|
|
104
|
+
json_str = user.model_dump_json()
|
|
105
|
+
|
|
106
|
+
# Deserialize - validates UPLID format and prefix
|
|
107
|
+
restored = User.model_validate_json(json_str)
|
|
108
|
+
assert restored.id == user.id
|
|
109
|
+
|
|
110
|
+
# Wrong prefix raises ValidationError
|
|
111
|
+
User(id="org_xxx...", name="Bad") # ValidationError
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## FastAPI Integration
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from typing import Annotated, Literal
|
|
118
|
+
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException
|
|
119
|
+
from pydantic import BaseModel, Field
|
|
120
|
+
from uplid import UPLID, UPLIDError, factory, parse
|
|
121
|
+
|
|
122
|
+
UserId = UPLID[Literal["usr"]]
|
|
123
|
+
UserIdFactory = factory(UserId)
|
|
124
|
+
parse_user_id = parse(UserId)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class User(BaseModel):
|
|
128
|
+
id: UserId = Field(default_factory=UserIdFactory)
|
|
129
|
+
name: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
app = FastAPI()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Dependency for validating path/query/header/cookie parameters
|
|
136
|
+
def get_user_id(user_id: str) -> UserId:
|
|
137
|
+
try:
|
|
138
|
+
return parse_user_id(user_id)
|
|
139
|
+
except UPLIDError as e:
|
|
140
|
+
raise HTTPException(422, f"Invalid user ID: {e}") from None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Path parameter validation
|
|
144
|
+
@app.get("/users/{user_id}")
|
|
145
|
+
def get_user(user_id: Annotated[UserId, Depends(get_user_id)]) -> User:
|
|
146
|
+
...
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# JSON body - Pydantic validates UPLID fields automatically
|
|
150
|
+
@app.post("/users")
|
|
151
|
+
def create_user(user: User) -> User:
|
|
152
|
+
# user.id validated as UserId, wrong prefix returns 422
|
|
153
|
+
return user
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Header validation
|
|
157
|
+
def get_user_id_from_header(x_user_id: Annotated[str, Header()]) -> UserId:
|
|
158
|
+
try:
|
|
159
|
+
return parse_user_id(x_user_id)
|
|
160
|
+
except UPLIDError as e:
|
|
161
|
+
raise HTTPException(422, f"Invalid X-User-Id header: {e}") from None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.get("/me")
|
|
165
|
+
def get_current_user(user_id: Annotated[UserId, Depends(get_user_id_from_header)]) -> User:
|
|
166
|
+
...
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Cookie validation
|
|
170
|
+
def get_session_user(session_user_id: Annotated[str, Cookie()]) -> UserId:
|
|
171
|
+
try:
|
|
172
|
+
return parse_user_id(session_user_id)
|
|
173
|
+
except UPLIDError as e:
|
|
174
|
+
raise HTTPException(422, f"Invalid session cookie: {e}") from None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.get("/session")
|
|
178
|
+
def get_session(user_id: Annotated[UserId, Depends(get_session_user)]) -> User:
|
|
179
|
+
...
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Database Storage
|
|
183
|
+
|
|
184
|
+
UPLIDs serialize to strings. Store as `VARCHAR(87)` (64 char prefix + 1 underscore + 22 char base62):
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from typing import Literal
|
|
188
|
+
from sqlalchemy import String, create_engine
|
|
189
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
|
|
190
|
+
from uplid import UPLID, factory
|
|
191
|
+
|
|
192
|
+
UserId = UPLID[Literal["usr"]]
|
|
193
|
+
UserIdFactory = factory(UserId)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Base(DeclarativeBase):
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class UserRow(Base):
|
|
201
|
+
__tablename__ = "users"
|
|
202
|
+
|
|
203
|
+
id: Mapped[str] = mapped_column(String(87), primary_key=True)
|
|
204
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# Create with UPLID, store as string
|
|
208
|
+
engine = create_engine("sqlite:///:memory:")
|
|
209
|
+
Base.metadata.create_all(engine)
|
|
210
|
+
|
|
211
|
+
with Session(engine) as session:
|
|
212
|
+
user = UserRow(id=str(UPLID.generate("usr")), name="Alice")
|
|
213
|
+
session.add(user)
|
|
214
|
+
session.commit()
|
|
215
|
+
|
|
216
|
+
# Query and parse back to UPLID
|
|
217
|
+
row = session.query(UserRow).first()
|
|
218
|
+
user_id = UPLID.from_string(row.id, "usr")
|
|
219
|
+
print(user_id.datetime) # When the ID was created
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Prefix Rules
|
|
223
|
+
|
|
224
|
+
Prefixes must be snake_case:
|
|
225
|
+
- Lowercase letters and single underscores only
|
|
226
|
+
- Cannot start or end with underscore
|
|
227
|
+
- Maximum 64 characters
|
|
228
|
+
- Examples: `usr`, `api_key`, `org_member`
|
|
229
|
+
|
|
230
|
+
## API Reference
|
|
231
|
+
|
|
232
|
+
### `UPLID[PREFIX]`
|
|
233
|
+
|
|
234
|
+
Generic class for prefixed IDs.
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
# Generate new ID
|
|
238
|
+
uid = UPLID.generate("usr")
|
|
239
|
+
|
|
240
|
+
# Parse from string
|
|
241
|
+
uid = UPLID.from_string("usr_0M3xL9kQ7vR2nP5wY1jZ4c", "usr")
|
|
242
|
+
|
|
243
|
+
# Properties
|
|
244
|
+
uid.prefix # str: "usr"
|
|
245
|
+
uid.uid # UUID: underlying UUIDv7
|
|
246
|
+
uid.base62_uid # str: 22-char base62 encoding
|
|
247
|
+
uid.datetime # datetime: UTC timestamp from UUIDv7
|
|
248
|
+
uid.timestamp # float: Unix timestamp in seconds
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### `factory(UPLIDType)`
|
|
252
|
+
|
|
253
|
+
Creates a factory function for Pydantic's `default_factory`.
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
UserId = UPLID[Literal["usr"]]
|
|
257
|
+
UserIdFactory = factory(UserId)
|
|
258
|
+
|
|
259
|
+
class User(BaseModel):
|
|
260
|
+
id: UserId = Field(default_factory=UserIdFactory)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### `parse(UPLIDType)`
|
|
264
|
+
|
|
265
|
+
Creates a parser function that raises `UPLIDError` on invalid input.
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
from uplid import UPLID, parse, UPLIDError
|
|
269
|
+
|
|
270
|
+
UserId = UPLID[Literal["usr"]]
|
|
271
|
+
parse_user_id = parse(UserId)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
uid = parse_user_id("usr_0M3xL9kQ7vR2nP5wY1jZ4c")
|
|
275
|
+
except UPLIDError as e:
|
|
276
|
+
print(e)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### `UPLIDType`
|
|
280
|
+
|
|
281
|
+
Protocol for generic functions accepting any UPLID:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
from uplid import UPLIDType
|
|
285
|
+
|
|
286
|
+
def log_entity(id: UPLIDType) -> None:
|
|
287
|
+
print(f"{id.prefix} created at {id.datetime}")
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### `UPLIDError`
|
|
291
|
+
|
|
292
|
+
Exception raised for invalid IDs. Subclasses `ValueError`.
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
uplid/__init__.py,sha256=96PZKnozPOjHfFbWko0jGzynDNcrr6Sm6jPhwAGnQxU,253
|
|
2
|
+
uplid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
uplid/uplid.py,sha256=krCFV7eFOcFn7KV8R1ko6B9lK37AzcrEJlciMmNvgmA,15108
|
|
4
|
+
uplid-1.0.1.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
|
|
5
|
+
uplid-1.0.1.dist-info/METADATA,sha256=OBp_290PWT_NA2dGPyD5TwBcT60QA5Gdb2myQhHaOG8,7704
|
|
6
|
+
uplid-1.0.1.dist-info/RECORD,,
|
uplid-0.0.4.dist-info/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
The MIT License (MIT)
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2024 Zachary V Smith (ZVS)
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in
|
|
13
|
-
all copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
-
THE SOFTWARE.
|
uplid-0.0.4.dist-info/METADATA
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: uplid
|
|
3
|
-
Version: 0.0.4
|
|
4
|
-
Summary: Universal Prefixed Literal Ids, runtime/statically typed via pydanitc, designed for humans
|
|
5
|
-
Home-page: https://github.com/zvsdev/uplid
|
|
6
|
-
License: MIT
|
|
7
|
-
Author: ZVS
|
|
8
|
-
Author-email: zvs@daswolf.dev
|
|
9
|
-
Requires-Python: >=3.9,<4.0
|
|
10
|
-
Classifier: Framework :: Pydantic
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
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 :: Only
|
|
19
|
-
Classifier: Typing :: Typed
|
|
20
|
-
Requires-Dist: pydantic (>=2.6.0,<3.0.0)
|
|
21
|
-
Requires-Dist: svix-ksuid (==0.6.2)
|
|
22
|
-
Project-URL: Documentation, https://github.com/zvsdev/uplid
|
|
23
|
-
Project-URL: Repository, https://github.com/zvsdev/uplid
|
|
24
|
-
Description-Content-Type: text/markdown
|
|
25
|
-
|
|
26
|
-
# UPLID - Universal Prefixed Literal Unique Id
|
|
27
|
-
|
|
28
|
-
A pydantic compatible, human friendly prefixed id.
|
|
29
|
-
|
|
30
|
-
Uses literal string types to enforce typing at both runtime (via pydantic) and during static analysis.
|
|
31
|
-
|
|
32
|
-
UIDs underneath are KSUIDs allowing them to be sorted by time of creation while still being collision resistant.
|
|
33
|
-
|
|
34
|
-
String representations are encoded with base62 keeping them url safe and human friendly.
|
|
35
|
-
|
|
36
|
-
Python 3.9 or higher and at least pydantic 2.6 are required.
|
|
37
|
-
|
|
38
|
-
Can be integrated with FastAPI.
|
|
39
|
-
|
|
40
|
-
## Installation
|
|
41
|
-
|
|
42
|
-
```
|
|
43
|
-
pip install uplid
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
## Usage
|
|
47
|
-
|
|
48
|
-
### With Pydantic
|
|
49
|
-
|
|
50
|
-
```py
|
|
51
|
-
from uplid import UPLID, factory
|
|
52
|
-
from pydantic import BaseModel, Field
|
|
53
|
-
|
|
54
|
-
UserId = UPLID[Literal["usr]]
|
|
55
|
-
WorkspaceId = UPLID[Literal["wrkspace"]]
|
|
56
|
-
|
|
57
|
-
class User(BaseModel):
|
|
58
|
-
id: UserId = Field(default_factory=factory(UserId))
|
|
59
|
-
workspace_id: WorkspaceId
|
|
60
|
-
|
|
61
|
-
user = User(workspace_id = UPLID.generate("wrkspace))
|
|
62
|
-
|
|
63
|
-
user_json = user.model_dump_json()
|
|
64
|
-
|
|
65
|
-
restored_user = User.validate_json(user_json)
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
### Standalone
|
|
69
|
-
|
|
70
|
-
```py
|
|
71
|
-
from uplid import UPLID, validator, factory
|
|
72
|
-
|
|
73
|
-
UserId = UPLID[Literal["usr]]
|
|
74
|
-
WorkspaceId = UPLID[Literal['wrkspace']]
|
|
75
|
-
|
|
76
|
-
UserIdFactory = factory(UserId)
|
|
77
|
-
WorkspaceIdFactory = factory(WorkspaceId)
|
|
78
|
-
|
|
79
|
-
user_id = UserIdFactory()
|
|
80
|
-
workspace_id = WorkspaceIdFactory()
|
|
81
|
-
|
|
82
|
-
def foo(bar: UserId) -> None:
|
|
83
|
-
pass
|
|
84
|
-
|
|
85
|
-
foo(bar=user_id) # passes static check
|
|
86
|
-
foo(bar=workspace_id) # fails static check
|
|
87
|
-
|
|
88
|
-
UserIdValidator = validator(UserId)
|
|
89
|
-
|
|
90
|
-
UserIdValidator(str(workspace_id)) # fails runtime check
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
### With FastAPI
|
|
94
|
-
|
|
95
|
-
#### As a Query or Path Param
|
|
96
|
-
|
|
97
|
-
```py
|
|
98
|
-
from uplid import UPLID, validator
|
|
99
|
-
from fastapi import Request, Response, Depends
|
|
100
|
-
|
|
101
|
-
UserId = UPLID[Literal["usr]]
|
|
102
|
-
|
|
103
|
-
async def endpoint(request: Request, user_id: UserId = Depends(validator(UserId))) -> Response:
|
|
104
|
-
...
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
FastAPI depends does not convert pydantic's ValidationError to RequestValidationError if thrown inside of Depends.
|
|
108
|
-
As a workaround, you can add a global catch at the top level, or wrap your own handler around the UPLID validator.
|
|
109
|
-
|
|
110
|
-
```py
|
|
111
|
-
from fastapi import Request, FastAPI
|
|
112
|
-
from fastapi.exceptions import RequestValidationError
|
|
113
|
-
from pydantic import ValidationError
|
|
114
|
-
|
|
115
|
-
def handle_validation_error(request: Request, exc: Exception):
|
|
116
|
-
if isinstance(exc, ValidationError):
|
|
117
|
-
raise RequestValidationError(errors=exc.errors())
|
|
118
|
-
raise exc
|
|
119
|
-
|
|
120
|
-
app = FastAPI()
|
|
121
|
-
app.add_exception_handler(ValidationError, handle_validation_error)
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
#### As part of a Body
|
|
125
|
-
|
|
126
|
-
```py
|
|
127
|
-
from uplid import UPLID
|
|
128
|
-
from fastapi import Request, Response
|
|
129
|
-
from pydantic import BaseModel
|
|
130
|
-
|
|
131
|
-
class UserRequest(BaseModel):
|
|
132
|
-
user_id: UserId
|
|
133
|
-
|
|
134
|
-
async def endpoint(request: Request, body: UserRequest) -> Response:
|
|
135
|
-
...
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
## Inspirations
|
|
139
|
-
|
|
140
|
-
- https://dev.to/stripe/designing-apis-for-humans-object-ids-3o5a
|
|
141
|
-
- https://pypi.org/project/django-charid-field/
|
|
142
|
-
- https://pypi.org/project/django-prefix-id/
|
|
143
|
-
- https://sudhir.io/uuids-ulids
|
|
144
|
-
|
uplid-0.0.4.dist-info/RECORD
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
uplid/__init__.py,sha256=xgQbrqK1ZimSsm20GHBNmtbuNHJUVs0kPI-gyZRcsH8,88
|
|
2
|
-
uplid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
-
uplid/uplid.py,sha256=8wtD24TdCQ6pGje4HYLzXQ3xXEGbSEigQGxLoksM7SU,7617
|
|
4
|
-
uplid-0.0.4.dist-info/LICENSE,sha256=YStW1Bz6chsLArR4K0O_NpZuLJhLlBInr9zBKD9rQmk,1088
|
|
5
|
-
uplid-0.0.4.dist-info/METADATA,sha256=jR_tZNaNFugESGF1bOmFCMTnpaeevctfDMdPHDJq68k,3807
|
|
6
|
-
uplid-0.0.4.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
|
7
|
-
uplid-0.0.4.dist-info/RECORD,,
|