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