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 CHANGED
@@ -1,4 +1,8 @@
1
- from .uplid import UPLID, factory
1
+ """Universal Prefixed Literal IDs - type-safe, human-readable identifiers."""
2
2
 
3
- __version__ = "0.0.4"
4
- __all__ = ["UPLID", "factory"]
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
- from datetime import datetime as Datetime
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 ksuid import KsuidMs
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
- PREFIX = TypeVar("PREFIX", bound=LiteralString)
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
- class UPLID(Generic[PREFIX]):
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
- Attributes:
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
- Methods:
22
- from_string(string: str, prefix: PREFIX): Class method to create an instance of PrefixedId from a
23
- base62 string representation.
24
- generate(prefix: PREFIX): Class method to generate a new PrefixedId with a given prefix, optionally can be generated
25
- for a specific datetime.
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
- ValueError: If the string representation does not conform to the expected format, if prefix
29
- contains non-alphabetic characters, is not lowercase, or does not match the expected prefix.
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
- This class requires a literal string as a type parameter for PREFIX. It integrates with Pydantic
33
- for validation and serialization purposes.
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
- prefix: PREFIX
37
- uid: KsuidMs
160
+ __slots__ = ("_base62_uid", "_prefix", "_uid")
38
161
 
39
- def __init__(self, prefix: PREFIX, uid: Union[str, KsuidMs]) -> None:
40
- self.prefix = prefix
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
- def __repr__(self) -> str:
47
- return str(self)
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
- def __str__(self) -> str:
50
- return f"{self.prefix}_{self.uid}"
51
-
52
- def __eq__(self, other: Any) -> bool:
53
- if isinstance(other, self.__class__):
54
- return self.prefix == other.prefix and self.uid == other.uid
55
- return False
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 datetime(self) -> Datetime:
96
- return self.uid.datetime
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
- return self.uid.timestamp
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
- split_content = string.split("_")
105
- if len(split_content) != 2:
106
- raise ValueError(
107
- f"Prefixed Id Strings must be of the form <prefix>_<uid>, received {string}"
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
- @classmethod
122
- def generate(cls, prefix: PREFIX, at: Union[Datetime, None] = None) -> Self:
123
- return cls(prefix, KsuidMs(at))
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, source_type: Any, handler: GetCoreSchemaHandler
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: # used as `x: PrefixId` without params
131
- raise RuntimeError("PrefixId must be used with a prefix literal string")
132
- prefix_str_type = get_args(source_type)[0]
133
- type_args = get_args(prefix_str_type)
134
- if not type_args: # When prefix is a TypeVar
135
- prefix_str_type = prefix_str_type.__value__
136
- prefix_str = prefix_str_type.__args__[0]
137
- else:
138
- prefix_str = type_args[0]
139
-
140
- if not prefix_str:
141
- raise RuntimeError(f"Expected prefix to be a literal string, got {prefix_str_type}")
142
-
143
- def val_str(v: str) -> UPLID[PREFIX]:
144
- try:
145
- prefixed_id = cls.from_string(v, prefix_str)
146
- except ValueError as e:
147
- raise AssertionError(e) from e
148
- return prefixed_id
149
-
150
- def val_prefix(v: Union[UPLID[PREFIX], str]) -> UPLID[PREFIX]:
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
- v = val_str(v)
153
- if v.prefix == prefix_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}")
154
380
  return v
155
- raise AssertionError(f"Expected id to have prefix {prefix_str}, got {v.prefix}")
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.no_info_before_validator_function(val_str, python_schema),
387
+ core_schema.no_info_plain_validator_function(validate),
167
388
  ]
168
389
  ),
169
- python_schema=python_schema,
170
- serialization=core_schema.plain_serializer_function_ser_schema(lambda x: str(x)),
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: Type[UPLID[PREFIX]]) -> PREFIX:
175
- literal_type = uplid_type.__args__[0] # type: ignore
176
- return literal_type.__args__[0] # type: ignore
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
- def factory(uplid_type: Type[UPLID[PREFIX]]) -> Callable[[], UPLID[PREFIX]]:
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 f() -> UPLID[PREFIX]:
425
+ def _factory() -> UPLID[PREFIX]:
183
426
  return UPLID.generate(prefix)
184
427
 
185
- return f
428
+ return _factory
186
429
 
187
430
 
188
- def validator(uplid_type: Type[UPLID[PREFIX]]) -> Callable[[str], UPLID[PREFIX]]:
189
- prefix = _get_prefix(uplid_type)
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
- return UPLID.from_string(v, prefix)
194
- except ValueError as e:
195
- raise ValidationError.from_exception_data(
196
- f"{prefix.capitalize()}Id",
197
- [
198
- {
199
- "loc": (f"{prefix}_id",),
200
- "input": v,
201
- "type": PydanticCustomError("value_error", str(e)),
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
+ [![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 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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.8.1
2
+ Generator: uv 0.9.28
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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.
@@ -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
-
@@ -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,,