uplid 1.0.1__py3-none-any.whl → 1.1.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 +2 -2
- uplid/sqlalchemy.py +205 -0
- uplid/uplid.py +18 -6
- uplid-1.1.1.dist-info/METADATA +223 -0
- uplid-1.1.1.dist-info/RECORD +7 -0
- {uplid-1.0.1.dist-info → uplid-1.1.1.dist-info}/WHEEL +1 -1
- uplid-1.0.1.dist-info/METADATA +0 -296
- uplid-1.0.1.dist-info/RECORD +0 -6
uplid/__init__.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from uplid.uplid import UPLID, UPLIDError, UPLIDType, factory, parse
|
|
5
|
+
from uplid.uplid import UPLID, UPLIDError, UPLIDType, _get_prefix, factory, parse
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
__all__ = ["UPLID", "UPLIDError", "UPLIDType", "factory", "parse"]
|
|
8
|
+
__all__ = ["UPLID", "UPLIDError", "UPLIDType", "_get_prefix", "factory", "parse"]
|
uplid/sqlalchemy.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""SQLAlchemy integration for UPLID.
|
|
2
|
+
|
|
3
|
+
Provides a TypeDecorator and helper for using UPLIDs as typed columns
|
|
4
|
+
that store as TEXT in the database.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from typing import Literal
|
|
8
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped
|
|
9
|
+
from uplid import UPLID
|
|
10
|
+
from uplid.sqlalchemy import uplid_column
|
|
11
|
+
|
|
12
|
+
UserId = UPLID[Literal["usr"]]
|
|
13
|
+
OrgId = UPLID[Literal["org"]]
|
|
14
|
+
|
|
15
|
+
class Base(DeclarativeBase):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
class User(Base):
|
|
19
|
+
__tablename__ = "users"
|
|
20
|
+
|
|
21
|
+
id: Mapped[UserId] = uplid_column(UserId, primary_key=True)
|
|
22
|
+
org_id: Mapped[OrgId | None] = uplid_column(OrgId)
|
|
23
|
+
name: Mapped[str]
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import TYPE_CHECKING, Any, TypedDict, Unpack, cast
|
|
29
|
+
|
|
30
|
+
from sqlalchemy import Text
|
|
31
|
+
from sqlalchemy.orm import mapped_column
|
|
32
|
+
from sqlalchemy.types import TypeDecorator
|
|
33
|
+
|
|
34
|
+
from uplid import UPLID, UPLIDError, UPLIDType, _get_prefix
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
|
|
40
|
+
from sqlalchemy.engine import Dialect
|
|
41
|
+
from sqlalchemy.orm import MappedColumn
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UPLIDColumnKwargs(TypedDict, total=False):
|
|
45
|
+
"""Keyword arguments for uplid_column, matching mapped_column's common options."""
|
|
46
|
+
|
|
47
|
+
primary_key: bool
|
|
48
|
+
nullable: bool
|
|
49
|
+
default: object
|
|
50
|
+
default_factory: Callable[[], object]
|
|
51
|
+
index: bool
|
|
52
|
+
unique: bool
|
|
53
|
+
insert_default: object
|
|
54
|
+
onupdate: object
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UPLIDColumn(TypeDecorator[UPLIDType]):
|
|
58
|
+
"""SQLAlchemy TypeDecorator for UPLID storage as TEXT.
|
|
59
|
+
|
|
60
|
+
Automatically serializes UPLID objects to strings on write
|
|
61
|
+
and deserializes back to UPLID objects on read.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
prefix: The expected prefix for UPLIDs in this column.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
id: Mapped[UserId] = mapped_column(UPLIDColumn("usr"), primary_key=True)
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
impl = Text
|
|
71
|
+
cache_ok = True
|
|
72
|
+
|
|
73
|
+
def __init__(self, prefix: str) -> None:
|
|
74
|
+
"""Initialize with the expected UPLID prefix."""
|
|
75
|
+
self.prefix = prefix
|
|
76
|
+
super().__init__()
|
|
77
|
+
|
|
78
|
+
def process_bind_param(
|
|
79
|
+
self,
|
|
80
|
+
value: UPLIDType | str | None,
|
|
81
|
+
dialect: Dialect, # noqa: ARG002
|
|
82
|
+
) -> str | None:
|
|
83
|
+
"""Convert UPLID to string for database storage.
|
|
84
|
+
|
|
85
|
+
Validates that strings have the correct prefix before storing.
|
|
86
|
+
This catches prefix mismatches at write time rather than read time.
|
|
87
|
+
"""
|
|
88
|
+
if value is None:
|
|
89
|
+
return None
|
|
90
|
+
if isinstance(value, UPLIDType):
|
|
91
|
+
if value.prefix != self.prefix:
|
|
92
|
+
msg = f"Expected prefix {self.prefix!r}, got {value.prefix!r}"
|
|
93
|
+
raise ValueError(msg)
|
|
94
|
+
return str(value)
|
|
95
|
+
# Validate string format and prefix before storing
|
|
96
|
+
if isinstance(value, str):
|
|
97
|
+
UPLID.from_string(value, self.prefix) # Raises UPLIDError if invalid
|
|
98
|
+
return value
|
|
99
|
+
return value # pragma: no cover
|
|
100
|
+
|
|
101
|
+
def process_result_value(
|
|
102
|
+
self,
|
|
103
|
+
value: str | None,
|
|
104
|
+
dialect: Dialect, # noqa: ARG002
|
|
105
|
+
) -> UPLIDType | None:
|
|
106
|
+
"""Convert database string to UPLID object."""
|
|
107
|
+
if value is None:
|
|
108
|
+
return None
|
|
109
|
+
return UPLID.from_string(value, self.prefix)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_prefix[T](uplid_type: type[T]) -> str:
|
|
113
|
+
"""Extract prefix from a parameterized UPLID type like UPLID[Literal["usr"]].
|
|
114
|
+
|
|
115
|
+
Wraps _get_prefix to convert UPLIDError to TypeError for SQLAlchemy context.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
return _get_prefix(uplid_type) # type: ignore[arg-type]
|
|
119
|
+
except UPLIDError as e:
|
|
120
|
+
raise TypeError(str(e)) from e
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def uplid_column[T](
|
|
124
|
+
uplid_type: type[T],
|
|
125
|
+
**kwargs: Unpack[UPLIDColumnKwargs],
|
|
126
|
+
) -> MappedColumn[T]:
|
|
127
|
+
"""Create a mapped_column for a UPLID type (pure SQLAlchemy).
|
|
128
|
+
|
|
129
|
+
Infers the prefix from the type parameter, so you don't need to
|
|
130
|
+
specify it twice.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
uplid_type: A parameterized UPLID type like UPLID[Literal["usr"]].
|
|
134
|
+
**kwargs: Additional arguments passed to mapped_column.
|
|
135
|
+
Supports: primary_key, nullable, default, default_factory,
|
|
136
|
+
index, unique, insert_default, onupdate.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
A mapped_column configured with the appropriate UPLIDColumn.
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
UserId = UPLID[Literal["usr"]]
|
|
143
|
+
OrgId = UPLID[Literal["org"]]
|
|
144
|
+
|
|
145
|
+
class User(Base):
|
|
146
|
+
__tablename__ = "users"
|
|
147
|
+
|
|
148
|
+
id: Mapped[UserId] = uplid_column(UserId, primary_key=True)
|
|
149
|
+
org_id: Mapped[OrgId | None] = uplid_column(OrgId)
|
|
150
|
+
"""
|
|
151
|
+
prefix = _extract_prefix(uplid_type)
|
|
152
|
+
return mapped_column(UPLIDColumn(prefix), **kwargs)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class UPLIDFieldKwargs(TypedDict, total=False):
|
|
156
|
+
"""Keyword arguments for uplid_field, matching SQLModel Field's common options."""
|
|
157
|
+
|
|
158
|
+
default: object
|
|
159
|
+
default_factory: Callable[[], object]
|
|
160
|
+
primary_key: bool
|
|
161
|
+
nullable: bool
|
|
162
|
+
index: bool
|
|
163
|
+
unique: bool
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def uplid_field[T](
|
|
167
|
+
uplid_type: type[T],
|
|
168
|
+
**kwargs: Unpack[UPLIDFieldKwargs],
|
|
169
|
+
) -> Any: # noqa: ANN401 - return type matches SQLModel's Field
|
|
170
|
+
"""Create a SQLModel Field for a UPLID type.
|
|
171
|
+
|
|
172
|
+
Infers the prefix from the type parameter and configures sa_type
|
|
173
|
+
automatically.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
uplid_type: A parameterized UPLID type like UPLID[Literal["usr"]].
|
|
177
|
+
**kwargs: Additional arguments passed to Field.
|
|
178
|
+
Supports: default, default_factory, primary_key, index, unique.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
A SQLModel Field configured with the appropriate UPLIDColumn.
|
|
182
|
+
|
|
183
|
+
Example:
|
|
184
|
+
from sqlmodel import SQLModel
|
|
185
|
+
from uplid import UPLID, factory
|
|
186
|
+
from uplid.sqlalchemy import uplid_field
|
|
187
|
+
|
|
188
|
+
UserId = UPLID[Literal["usr"]]
|
|
189
|
+
UserIdFactory = factory(UserId)
|
|
190
|
+
|
|
191
|
+
class User(SQLModel, table=True):
|
|
192
|
+
id: UserId = uplid_field(UserId, default_factory=UserIdFactory, primary_key=True)
|
|
193
|
+
org_id: OrgId | None = uplid_field(OrgId, default=None)
|
|
194
|
+
"""
|
|
195
|
+
# Import here to avoid hard dependency on sqlmodel
|
|
196
|
+
from sqlmodel import Field
|
|
197
|
+
|
|
198
|
+
prefix = _extract_prefix(uplid_type)
|
|
199
|
+
# SQLModel's sa_type is incorrectly typed as type[Any] but accepts TypeEngine instances.
|
|
200
|
+
# Use cast to satisfy the type checker until SQLModel fixes their stubs.
|
|
201
|
+
sa_type = cast("type[Any]", UPLIDColumn(prefix))
|
|
202
|
+
return Field(sa_type=sa_type, **kwargs)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
__all__ = ["UPLIDColumn", "uplid_column", "uplid_field"]
|
uplid/uplid.py
CHANGED
|
@@ -7,7 +7,6 @@ from datetime import UTC
|
|
|
7
7
|
from datetime import datetime as dt_datetime
|
|
8
8
|
from typing import (
|
|
9
9
|
TYPE_CHECKING,
|
|
10
|
-
Any,
|
|
11
10
|
LiteralString,
|
|
12
11
|
Protocol,
|
|
13
12
|
Self,
|
|
@@ -234,24 +233,28 @@ class UPLID[PREFIX: LiteralString]:
|
|
|
234
233
|
def __lt__(self, other: object) -> bool:
|
|
235
234
|
"""Compare for sorting (by prefix, then by uid)."""
|
|
236
235
|
if isinstance(other, UPLID):
|
|
236
|
+
# type: ignore needed because UUID comparison is not recognized by type checkers
|
|
237
237
|
return (self._prefix, self._uid) < (other._prefix, other._uid) # type: ignore[operator]
|
|
238
238
|
return NotImplemented
|
|
239
239
|
|
|
240
240
|
def __le__(self, other: object) -> bool:
|
|
241
241
|
"""Compare for sorting (by prefix, then by uid)."""
|
|
242
242
|
if isinstance(other, UPLID):
|
|
243
|
+
# type: ignore needed because UUID comparison is not recognized by type checkers
|
|
243
244
|
return (self._prefix, self._uid) <= (other._prefix, other._uid) # type: ignore[operator]
|
|
244
245
|
return NotImplemented
|
|
245
246
|
|
|
246
247
|
def __gt__(self, other: object) -> bool:
|
|
247
248
|
"""Compare for sorting (by prefix, then by uid)."""
|
|
248
249
|
if isinstance(other, UPLID):
|
|
250
|
+
# type: ignore needed because UUID comparison is not recognized by type checkers
|
|
249
251
|
return (self._prefix, self._uid) > (other._prefix, other._uid) # type: ignore[operator]
|
|
250
252
|
return NotImplemented
|
|
251
253
|
|
|
252
254
|
def __ge__(self, other: object) -> bool:
|
|
253
255
|
"""Compare for sorting (by prefix, then by uid)."""
|
|
254
256
|
if isinstance(other, UPLID):
|
|
257
|
+
# type: ignore needed because UUID comparison is not recognized by type checkers
|
|
255
258
|
return (self._prefix, self._uid) >= (other._prefix, other._uid) # type: ignore[operator]
|
|
256
259
|
return NotImplemented
|
|
257
260
|
|
|
@@ -259,7 +262,7 @@ class UPLID[PREFIX: LiteralString]:
|
|
|
259
262
|
"""Return self (UPLIDs are immutable)."""
|
|
260
263
|
return self
|
|
261
264
|
|
|
262
|
-
def __deepcopy__(self, memo:
|
|
265
|
+
def __deepcopy__(self, memo: object) -> Self:
|
|
263
266
|
"""Return self (UPLIDs are immutable)."""
|
|
264
267
|
return self
|
|
265
268
|
|
|
@@ -282,6 +285,14 @@ class UPLID[PREFIX: LiteralString]:
|
|
|
282
285
|
UPLIDError: If the prefix is not valid snake_case.
|
|
283
286
|
"""
|
|
284
287
|
_validate_prefix(prefix)
|
|
288
|
+
return cls._generate_unchecked(prefix)
|
|
289
|
+
|
|
290
|
+
@classmethod
|
|
291
|
+
def _generate_unchecked(cls, prefix: PREFIX) -> Self:
|
|
292
|
+
"""Generate a new UPLID without validating the prefix.
|
|
293
|
+
|
|
294
|
+
Internal method for use when prefix has already been validated.
|
|
295
|
+
"""
|
|
285
296
|
instance = cls.__new__(cls)
|
|
286
297
|
instance._prefix = prefix # noqa: SLF001
|
|
287
298
|
instance._uid = uuid7() # noqa: SLF001
|
|
@@ -339,8 +350,8 @@ class UPLID[PREFIX: LiteralString]:
|
|
|
339
350
|
@classmethod
|
|
340
351
|
def __get_pydantic_core_schema__(
|
|
341
352
|
cls,
|
|
342
|
-
source_type:
|
|
343
|
-
handler:
|
|
353
|
+
source_type: type[Self],
|
|
354
|
+
handler: object,
|
|
344
355
|
) -> CoreSchema:
|
|
345
356
|
"""Pydantic integration for validation and serialization.
|
|
346
357
|
|
|
@@ -371,7 +382,7 @@ class UPLID[PREFIX: LiteralString]:
|
|
|
371
382
|
|
|
372
383
|
prefix_str: str = prefix_args[0]
|
|
373
384
|
|
|
374
|
-
def validate(v:
|
|
385
|
+
def validate(v: UPLIDType | str) -> UPLIDType:
|
|
375
386
|
if isinstance(v, str):
|
|
376
387
|
return cls.from_string(v, prefix_str)
|
|
377
388
|
if isinstance(v, UPLID):
|
|
@@ -421,9 +432,10 @@ def factory[PREFIX: LiteralString](
|
|
|
421
432
|
id: UserId = Field(default_factory=factory(UserId))
|
|
422
433
|
"""
|
|
423
434
|
prefix = _get_prefix(uplid_type)
|
|
435
|
+
_validate_prefix(prefix) # Validate once at factory creation
|
|
424
436
|
|
|
425
437
|
def _factory() -> UPLID[PREFIX]:
|
|
426
|
-
return UPLID.
|
|
438
|
+
return UPLID._generate_unchecked(prefix) # noqa: SLF001
|
|
427
439
|
|
|
428
440
|
return _factory
|
|
429
441
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uplid
|
|
3
|
+
Version: 1.1.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
|
+
```
|
|
25
|
+
██╗ ██╗██████╗ ██╗ ██╗██████╗
|
|
26
|
+
██║ ██║██╔══██╗██║ ██║██╔══██╗
|
|
27
|
+
██║ ██║██████╔╝██║ ██║██║ ██║
|
|
28
|
+
██║ ██║██╔═══╝ ██║ ██║██║ ██║
|
|
29
|
+
╚██████╔╝██║ ███████╗██║██████╔╝
|
|
30
|
+
╚═════╝ ╚═╝ ╚══════╝╚═╝╚═════╝
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Stripe-style IDs for Python.**
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# Before: WTF is this?
|
|
37
|
+
"550e8400-e29b-41d4-a716-446655440000"
|
|
38
|
+
|
|
39
|
+
# After: It's a user.
|
|
40
|
+
"usr_0M3xL9kQ7vR2nP5wY1jZ4c"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
[](https://github.com/zvsdev/uplid/actions/workflows/ci.yml)
|
|
44
|
+
[](https://pypi.org/project/uplid/)
|
|
45
|
+
[](https://pypi.org/project/uplid/)
|
|
46
|
+
[](https://github.com/zvsdev/uplid)
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install uplid
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Requires Python 3.14+ and Pydantic 2.10+.
|
|
55
|
+
|
|
56
|
+
## Quick Start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
>>> from uplid import UPLID
|
|
60
|
+
>>> UPLID.generate("usr")
|
|
61
|
+
usr_0M3xL9kQ7vR2nP5wY1jZ4c
|
|
62
|
+
>>> UPLID.generate("ord")
|
|
63
|
+
ord_7x9KmNpQrStUvWxYz012Ab
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Why UPLID?
|
|
67
|
+
|
|
68
|
+
**Debuggable** - See `usr_` in your logs and instantly know it's a user, not an order, not a session, not a mystery.
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
# Your logs now:
|
|
72
|
+
"User usr_0M3xL9kQ7vR2nP5wY1jZ4c created order ord_1a2B3c4D5e6F7g..."
|
|
73
|
+
|
|
74
|
+
# vs the nightmare:
|
|
75
|
+
"User 550e8400-e29b-41d4... created order 7c9e6679-7425-40de..."
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Type-safe** - Your type checker catches `user_id = order_id` mistakes before they hit production.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
UserId = UPLID[Literal["usr"]]
|
|
82
|
+
OrgId = UPLID[Literal["org"]]
|
|
83
|
+
|
|
84
|
+
def get_user(user_id: UserId) -> User: ...
|
|
85
|
+
|
|
86
|
+
get_user(org_id) # Type error! Caught by mypy/pyright/ty
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Time-sortable** - Built on UUIDv7. Sort by ID = sort by creation time. No extra column needed.
|
|
90
|
+
|
|
91
|
+
**URL-safe** - 26 characters, no special characters, no encoding. `usr_0M3xL9kQ7vR2nP5wY1jZ4c`
|
|
92
|
+
|
|
93
|
+
**Minimal dependencies** - Just Pydantic. UUID generation uses Python 3.14's stdlib `uuid7()`.
|
|
94
|
+
|
|
95
|
+
> Inspired by Stripe's prefixed IDs (`sk_live_...`, `cus_...`, `pi_...`) - the same pattern trusted by millions of API calls daily.
|
|
96
|
+
|
|
97
|
+
## Pydantic Integration
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from typing import Literal
|
|
101
|
+
from pydantic import BaseModel, Field
|
|
102
|
+
from uplid import UPLID, factory
|
|
103
|
+
|
|
104
|
+
UserId = UPLID[Literal["usr"]]
|
|
105
|
+
|
|
106
|
+
class User(BaseModel):
|
|
107
|
+
id: UserId = Field(default_factory=factory(UserId))
|
|
108
|
+
name: str
|
|
109
|
+
|
|
110
|
+
user = User(name="Alice")
|
|
111
|
+
user.model_dump() # {"id": "usr_0M3xL9kQ7vR2nP5wY1jZ4c", "name": "Alice"}
|
|
112
|
+
|
|
113
|
+
User(id="org_xxx...", name="Bad") # ValidationError: wrong prefix
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## FastAPI Integration
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from typing import Annotated, Literal
|
|
120
|
+
from fastapi import Depends, FastAPI, HTTPException
|
|
121
|
+
from uplid import UPLID, UPLIDError, parse
|
|
122
|
+
|
|
123
|
+
UserId = UPLID[Literal["usr"]]
|
|
124
|
+
parse_user_id = parse(UserId)
|
|
125
|
+
|
|
126
|
+
def validate_user_id(user_id: str) -> UserId:
|
|
127
|
+
try:
|
|
128
|
+
return parse_user_id(user_id)
|
|
129
|
+
except UPLIDError as e:
|
|
130
|
+
raise HTTPException(422, str(e)) from None
|
|
131
|
+
|
|
132
|
+
@app.get("/users/{user_id}")
|
|
133
|
+
def get_user(user_id: Annotated[UserId, Depends(validate_user_id)]) -> User:
|
|
134
|
+
... # user_id is validated and typed
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## SQLAlchemy Integration
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from uplid import UPLID, factory
|
|
141
|
+
from uplid.sqlalchemy import uplid_column
|
|
142
|
+
|
|
143
|
+
UserId = UPLID[Literal["usr"]]
|
|
144
|
+
|
|
145
|
+
class User(Base):
|
|
146
|
+
__tablename__ = "users"
|
|
147
|
+
id: Mapped[UserId] = uplid_column(UserId, primary_key=True)
|
|
148
|
+
name: Mapped[str]
|
|
149
|
+
|
|
150
|
+
# Stores as TEXT, returns as UPLID objects
|
|
151
|
+
user = session.execute(select(User)).scalar_one()
|
|
152
|
+
user.id.prefix # "usr"
|
|
153
|
+
user.id.datetime # When the ID was created
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## SQLModel Integration
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from uplid import UPLID, factory
|
|
160
|
+
from uplid.sqlalchemy import uplid_field
|
|
161
|
+
|
|
162
|
+
UserId = UPLID[Literal["usr"]]
|
|
163
|
+
|
|
164
|
+
class User(SQLModel, table=True):
|
|
165
|
+
id: UserId = uplid_field(UserId, default_factory=factory(UserId), primary_key=True)
|
|
166
|
+
name: str
|
|
167
|
+
|
|
168
|
+
user.model_dump() # {"id": "usr_...", "name": "Alice"} - Pydantic just works
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Prefix Rules
|
|
172
|
+
|
|
173
|
+
Prefixes must be snake_case: lowercase letters and single underscores, cannot start/end with underscore, max 64 characters.
|
|
174
|
+
|
|
175
|
+
Examples: `usr`, `api_key`, `org_member`, `sk_live`
|
|
176
|
+
|
|
177
|
+
## API Reference
|
|
178
|
+
|
|
179
|
+
### `UPLID[PREFIX]`
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
uid = UPLID.generate("usr") # Generate new
|
|
183
|
+
uid = UPLID.from_string("usr_0M3xL9kQ7vR2nP5wY1jZ4c", "usr") # Parse
|
|
184
|
+
|
|
185
|
+
uid.prefix # "usr"
|
|
186
|
+
uid.uid # UUID object
|
|
187
|
+
uid.base62_uid # "0M3xL9kQ7vR2nP5wY1jZ4c"
|
|
188
|
+
uid.datetime # When created (from UUIDv7)
|
|
189
|
+
uid.timestamp # Unix timestamp
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### `factory(UPLIDType)` / `parse(UPLIDType)`
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
UserId = UPLID[Literal["usr"]]
|
|
196
|
+
UserIdFactory = factory(UserId) # For Pydantic default_factory
|
|
197
|
+
parse_user_id = parse(UserId) # For manual parsing, raises UPLIDError
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### `UPLIDType`
|
|
201
|
+
|
|
202
|
+
Protocol for functions accepting any UPLID:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
def log_entity(id: UPLIDType) -> None:
|
|
206
|
+
print(f"{id.prefix} created at {id.datetime}")
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### `uplid_column` / `uplid_field`
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from uplid.sqlalchemy import uplid_column, uplid_field
|
|
213
|
+
|
|
214
|
+
# SQLAlchemy
|
|
215
|
+
id: Mapped[UserId] = uplid_column(UserId, primary_key=True)
|
|
216
|
+
|
|
217
|
+
# SQLModel
|
|
218
|
+
id: UserId = uplid_field(UserId, default_factory=factory(UserId), primary_key=True)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
uplid/__init__.py,sha256=gtEVlE30N9TrHaTEpinM5t428HT6NzGjMTDq6ABsKMY,281
|
|
2
|
+
uplid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
uplid/sqlalchemy.py,sha256=FFELyg-7GCUPPiSBncIXSHEkoAaxxoqK1IYvS8Ja5Bg,6311
|
|
4
|
+
uplid/uplid.py,sha256=s4g81Vko5041EJsiYka2mVL7uNoCcfqoH-cU5Ka3b6o,15803
|
|
5
|
+
uplid-1.1.1.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
|
|
6
|
+
uplid-1.1.1.dist-info/METADATA,sha256=HaGgGzwuQWKs316WoLjpWn2ffD75UqHSz5UTnWyJ3q8,6241
|
|
7
|
+
uplid-1.1.1.dist-info/RECORD,,
|
uplid-1.0.1.dist-info/METADATA
DELETED
|
@@ -1,296 +0,0 @@
|
|
|
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
|
uplid-1.0.1.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
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,,
|