ulidx 0.0.1__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.
- ulidx-0.0.1/LICENSE +21 -0
- ulidx-0.0.1/PKG-INFO +83 -0
- ulidx-0.0.1/README.md +62 -0
- ulidx-0.0.1/pyproject.toml +38 -0
- ulidx-0.0.1/src/ulidx/__init__.py +341 -0
- ulidx-0.0.1/src/ulidx/py.typed +0 -0
- ulidx-0.0.1/tests/test_ulidx.py +67 -0
ulidx-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lqxnjk
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
ulidx-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ulidx
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: ULID generation and conversion: sortable, monotonic, thread-safe.
|
|
5
|
+
Project-URL: Homepage, https://github.com/lqxnjk/ulidx
|
|
6
|
+
Project-URL: Source, https://github.com/lqxnjk/ulidx
|
|
7
|
+
Author: lqxnjk
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: id,identifier,monotonic,sortable,ulid,uuid
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# ulidx
|
|
23
|
+
|
|
24
|
+
ULID generation and conversion. A ULID is a 128-bit identifier that is both
|
|
25
|
+
**lexicographically sortable by creation time** and **random enough to be used
|
|
26
|
+
as a primary key** — 26 characters in Crockford base32, no hyphens.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```console
|
|
31
|
+
pip install ulidx
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from ulidx import Ulid
|
|
38
|
+
|
|
39
|
+
# plain generation
|
|
40
|
+
Ulid.generate() # '01J8Z9K2M3N4P5Q6R7S8T9V0WX'
|
|
41
|
+
|
|
42
|
+
# guaranteed strictly increasing within a process, even in the same millisecond
|
|
43
|
+
Ulid.generate(n_monotonic=True)
|
|
44
|
+
|
|
45
|
+
# pin the timestamp (aware or naive datetime, or epoch seconds)
|
|
46
|
+
Ulid.generate(n_timestamp=1700000000)
|
|
47
|
+
|
|
48
|
+
# bulk
|
|
49
|
+
Ulid.generate_batch(1000)
|
|
50
|
+
|
|
51
|
+
# back and forth
|
|
52
|
+
value = Ulid.generate()
|
|
53
|
+
Ulid.to_timestamp(value) # float, epoch seconds
|
|
54
|
+
Ulid.to_time(value) # datetime in UTC+8
|
|
55
|
+
Ulid.to_bytes(value) # 16 raw bytes
|
|
56
|
+
Ulid.by_bytes(Ulid.to_bytes(value)) # round trip
|
|
57
|
+
Ulid.to_uuid(value) # uuid.UUID
|
|
58
|
+
Ulid.from_uuid(some_uuid) # 26-char str
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
| Method | Description |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `generate(ts=None, monotonic=False)` | New 26-character ULID |
|
|
66
|
+
| `generate_batch(count, ...)` | List of ULIDs |
|
|
67
|
+
| `generate_uuid(ts=None, ...)` | New ULID as `uuid.UUID` |
|
|
68
|
+
| `by_bytes(data)` | 16 bytes to ULID string |
|
|
69
|
+
| `to_bytes(ulid)` | ULID (str / bytes / UUID) to 16 bytes |
|
|
70
|
+
| `to_timestamp(ulid)` | Epoch seconds encoded in the ULID |
|
|
71
|
+
| `to_time(ulid, hour_offset=8)` | `datetime` in the given UTC offset |
|
|
72
|
+
| `to_uuid(ulid)` / `from_uuid(uuid)` | Convert between ULID and `uuid.UUID` |
|
|
73
|
+
|
|
74
|
+
## Monotonicity
|
|
75
|
+
|
|
76
|
+
`n_monotonic=True` keeps the generator state per thread, so concurrent threads
|
|
77
|
+
never share a counter. Within a thread, ULIDs produced in the same millisecond
|
|
78
|
+
strictly increase, which keeps sorting stable when several IDs are created in
|
|
79
|
+
one tick.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
ulidx-0.0.1/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ulidx
|
|
2
|
+
|
|
3
|
+
ULID generation and conversion. A ULID is a 128-bit identifier that is both
|
|
4
|
+
**lexicographically sortable by creation time** and **random enough to be used
|
|
5
|
+
as a primary key** — 26 characters in Crockford base32, no hyphens.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
pip install ulidx
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from ulidx import Ulid
|
|
17
|
+
|
|
18
|
+
# plain generation
|
|
19
|
+
Ulid.generate() # '01J8Z9K2M3N4P5Q6R7S8T9V0WX'
|
|
20
|
+
|
|
21
|
+
# guaranteed strictly increasing within a process, even in the same millisecond
|
|
22
|
+
Ulid.generate(n_monotonic=True)
|
|
23
|
+
|
|
24
|
+
# pin the timestamp (aware or naive datetime, or epoch seconds)
|
|
25
|
+
Ulid.generate(n_timestamp=1700000000)
|
|
26
|
+
|
|
27
|
+
# bulk
|
|
28
|
+
Ulid.generate_batch(1000)
|
|
29
|
+
|
|
30
|
+
# back and forth
|
|
31
|
+
value = Ulid.generate()
|
|
32
|
+
Ulid.to_timestamp(value) # float, epoch seconds
|
|
33
|
+
Ulid.to_time(value) # datetime in UTC+8
|
|
34
|
+
Ulid.to_bytes(value) # 16 raw bytes
|
|
35
|
+
Ulid.by_bytes(Ulid.to_bytes(value)) # round trip
|
|
36
|
+
Ulid.to_uuid(value) # uuid.UUID
|
|
37
|
+
Ulid.from_uuid(some_uuid) # 26-char str
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
| Method | Description |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `generate(ts=None, monotonic=False)` | New 26-character ULID |
|
|
45
|
+
| `generate_batch(count, ...)` | List of ULIDs |
|
|
46
|
+
| `generate_uuid(ts=None, ...)` | New ULID as `uuid.UUID` |
|
|
47
|
+
| `by_bytes(data)` | 16 bytes to ULID string |
|
|
48
|
+
| `to_bytes(ulid)` | ULID (str / bytes / UUID) to 16 bytes |
|
|
49
|
+
| `to_timestamp(ulid)` | Epoch seconds encoded in the ULID |
|
|
50
|
+
| `to_time(ulid, hour_offset=8)` | `datetime` in the given UTC offset |
|
|
51
|
+
| `to_uuid(ulid)` / `from_uuid(uuid)` | Convert between ULID and `uuid.UUID` |
|
|
52
|
+
|
|
53
|
+
## Monotonicity
|
|
54
|
+
|
|
55
|
+
`n_monotonic=True` keeps the generator state per thread, so concurrent threads
|
|
56
|
+
never share a counter. Within a thread, ULIDs produced in the same millisecond
|
|
57
|
+
strictly increase, which keeps sorting stable when several IDs are created in
|
|
58
|
+
one tick.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ulidx"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "ULID generation and conversion: sortable, monotonic, thread-safe."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "lqxnjk" }]
|
|
13
|
+
keywords = ["ulid", "uuid", "identifier", "sortable", "id", "monotonic"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/lqxnjk/ulidx"
|
|
28
|
+
Source = "https://github.com/lqxnjk/ulidx"
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
pythonpath = ["src"]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/ulidx"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["src/ulidx", "tests", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Author: lqxnjk <lqxnjk@qq.com>
|
|
3
|
+
Created: 2026-10-23 10:06
|
|
4
|
+
Modified: 2026-10-23 10:06
|
|
5
|
+
Python Version: 3.10
|
|
6
|
+
ULID generation and conversion: sortable, monotonic, thread-safe.
|
|
7
|
+
|
|
8
|
+
Standalone package: stdlib only, no internal dependencies.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import calendar
|
|
12
|
+
import datetime
|
|
13
|
+
import os
|
|
14
|
+
import struct
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import typing
|
|
19
|
+
import uuid
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
__all__ = ["Ulid"]
|
|
23
|
+
|
|
24
|
+
class Ulid:
|
|
25
|
+
"""生成并转换按时间排序的 128 位 ULID 标识符。"""
|
|
26
|
+
|
|
27
|
+
_symbols = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
28
|
+
_py3 = (sys.version_info[0] >= 3)
|
|
29
|
+
_text_type = str
|
|
30
|
+
|
|
31
|
+
# 单调性状态按线程隔离:每个线程各自持有「上一次熵 / 上一次毫秒时间戳」,
|
|
32
|
+
# 避免把进程级可变状态暴露给所有调用方而互相干扰。
|
|
33
|
+
_state: threading.local = threading.local()
|
|
34
|
+
# 保护「读旧值 -> 比较 -> 写回」临界区,保证同一线程内的单调性承诺不被并发交错破坏
|
|
35
|
+
_lock: threading.Lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
if _py3:
|
|
38
|
+
_to_binary = bytes
|
|
39
|
+
|
|
40
|
+
_decode_table: typing.ClassVar[list[int]] = [
|
|
41
|
+
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
|
42
|
+
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
|
43
|
+
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
|
44
|
+
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
|
45
|
+
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01,
|
|
46
|
+
0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0xFF, 0xFF,
|
|
47
|
+
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
|
|
48
|
+
0x0F, 0x10, 0x11, 0xFF, 0x12, 0x13, 0xFF, 0x14, 0x15, 0xFF,
|
|
49
|
+
0x16, 0x17, 0x18, 0x19, 0x1A, 0xFF, 0x1B, 0x1C, 0x1D, 0x1E,
|
|
50
|
+
0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x0A, 0x0B, 0x0C,
|
|
51
|
+
0x0D, 0x0E, 0x0F, 0x10, 0x11, 0xFF, 0x12, 0x13, 0xFF, 0x14,
|
|
52
|
+
0x15, 0xFF, 0x16, 0x17, 0x18, 0x19, 0x1A, 0xFF, 0x1B, 0x1C,
|
|
53
|
+
0x1D, 0x1E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def generate(cls, n_timestamp: float | datetime.datetime | None = None, n_monotonic: bool = False) -> str:
|
|
58
|
+
"""生成按时间排序的 26 字符 ULID 字符串。
|
|
59
|
+
|
|
60
|
+
参数:
|
|
61
|
+
n_timestamp: n_=时间戳。None 使用当前时间;为 datetime 时按 UTC 取秒;
|
|
62
|
+
为 float 时按 Unix 秒解释。默认 None。
|
|
63
|
+
n_monotonic: n_=是否单调递增。为 True 时若同一毫秒内生成的熵小于上一次,
|
|
64
|
+
则重新生成以保证严格递增,避免碰撞。
|
|
65
|
+
|
|
66
|
+
返回:
|
|
67
|
+
str: 26 字符的 Crockford base32 大写 ULID 字符串。
|
|
68
|
+
|
|
69
|
+
注意:
|
|
70
|
+
``n_monotonic`` 只在**单个线程内**保证严格递增;各线程各自持有单调状态,
|
|
71
|
+
不保证跨线程的全局单调。若需要跨线程全局有序,请由调用方自行串行化。
|
|
72
|
+
"""
|
|
73
|
+
return cls.by_bytes(cls._generate_bytes(n_timestamp, n_monotonic))
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def generate_batch(
|
|
77
|
+
cls,
|
|
78
|
+
n_count: int = 1,
|
|
79
|
+
n_timestamp: float | datetime.datetime | None = None,
|
|
80
|
+
n_monotonic: bool = False,
|
|
81
|
+
) -> list[str]:
|
|
82
|
+
"""批量生成多个 ULID 字符串(共享同一时间戳)。
|
|
83
|
+
|
|
84
|
+
与 :meth:`generate` 不同,本方法在一次调用内固定时间戳,仅随机部分不同,
|
|
85
|
+
因此适用于需要同刻批量生成且不依赖逐次递增的场景;内部复用
|
|
86
|
+
:meth:`_generate_bytes`,熵源与单调逻辑和 :meth:`generate` 完全一致。
|
|
87
|
+
|
|
88
|
+
参数:
|
|
89
|
+
n_count: m_=生成数量,默认 1。
|
|
90
|
+
n_timestamp: n_=时间戳(语义同 :meth:`generate`),默认 None(当前时间)。
|
|
91
|
+
n_monotonic: n_=是否单调递增,默认 False;同样只在单线程内保证严格递增。
|
|
92
|
+
|
|
93
|
+
返回:
|
|
94
|
+
list[str]: 长度为 n_count 的 ULID 字符串列表。
|
|
95
|
+
"""
|
|
96
|
+
if n_timestamp is None:
|
|
97
|
+
_ts_val = time.time()
|
|
98
|
+
elif isinstance(n_timestamp, datetime.datetime):
|
|
99
|
+
_ts_val = float(calendar.timegm(n_timestamp.utctimetuple()))
|
|
100
|
+
else:
|
|
101
|
+
_ts_val = float(n_timestamp)
|
|
102
|
+
|
|
103
|
+
# 复用 _generate_bytes:统一熵源(os.urandom)、统一加锁的单调逻辑,
|
|
104
|
+
# 避免本方法再维护一份平行实现;传同一 _ts_val 保证整批共享同一毫秒时间戳。
|
|
105
|
+
return [cls.by_bytes(cls._generate_bytes(_ts_val, n_monotonic)) for _ in range(n_count)]
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def generate_uuid(cls, n_timestamp: float | datetime.datetime | None = None, n_monotonic: bool = False) -> uuid.UUID:
|
|
109
|
+
"""生成与 ULID 等价的 :class:`uuid.UUID` 对象。
|
|
110
|
+
|
|
111
|
+
参数:
|
|
112
|
+
n_timestamp: n_=时间戳(语义同 :meth:`generate`),默认 None。
|
|
113
|
+
n_monotonic: n_=是否单调递增,默认 False。
|
|
114
|
+
|
|
115
|
+
返回:
|
|
116
|
+
uuid.UUID: 由 ULID 16 字节直接构造的 UUID。
|
|
117
|
+
"""
|
|
118
|
+
return uuid.UUID(bytes=cls._generate_bytes(n_timestamp, n_monotonic))
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def _next_entropy(cls, m_timestamp: int, n_monotonic: bool) -> bytes:
|
|
122
|
+
"""在锁内生成当前 ULID 的 10 字节熵,并维护线程级单调状态。
|
|
123
|
+
|
|
124
|
+
参数:
|
|
125
|
+
m_timestamp: m_=当前 ULID 的毫秒时间戳,用于判断是否与上一次同毫秒。
|
|
126
|
+
n_monotonic: m_=是否单调递增;为 True 且与上一次同毫秒时,
|
|
127
|
+
重新取样直到熵不小于上一次,保证同线程内严格递增。
|
|
128
|
+
|
|
129
|
+
返回:
|
|
130
|
+
bytes: 10 字节随机熵(统一取 :func:`os.urandom` 密码学随机源)。
|
|
131
|
+
|
|
132
|
+
注意:
|
|
133
|
+
状态存放在 :data:`_state`(``threading.local``),因此单调性仅限单线程;
|
|
134
|
+
读旧值、比较与写回都在 :data:`_lock` 内完成,保证临界区原子。
|
|
135
|
+
"""
|
|
136
|
+
_state = cls._state
|
|
137
|
+
with cls._lock:
|
|
138
|
+
_prev = getattr(_state, "entropy", None)
|
|
139
|
+
_prev_timestamp = getattr(_state, "timestamp", None)
|
|
140
|
+
_entropy = os.urandom(10)
|
|
141
|
+
if n_monotonic and _prev is not None and _prev_timestamp == m_timestamp:
|
|
142
|
+
while _entropy < _prev:
|
|
143
|
+
_entropy = os.urandom(10)
|
|
144
|
+
_state.entropy = _entropy
|
|
145
|
+
_state.timestamp = m_timestamp
|
|
146
|
+
return _entropy
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def _generate_bytes(cls, n_timestamp: float | datetime.datetime | None = None, n_monotonic: bool = False) -> bytes:
|
|
150
|
+
"""生成 ULID 字节(内部方法)。
|
|
151
|
+
|
|
152
|
+
参数:
|
|
153
|
+
n_timestamp: n_=时间戳,语义同 :meth:`generate`,默认 None(当前时间)。
|
|
154
|
+
n_monotonic: n_=是否单调递增,默认 False(仅单线程内保证严格递增)。
|
|
155
|
+
|
|
156
|
+
返回:
|
|
157
|
+
bytes: 16 字节 ULID(6 字节毫秒时间戳 + 10 字节随机熵)。
|
|
158
|
+
"""
|
|
159
|
+
if n_timestamp is None:
|
|
160
|
+
_ts_val = time.time()
|
|
161
|
+
elif isinstance(n_timestamp, datetime.datetime):
|
|
162
|
+
_ts_val = float(calendar.timegm(n_timestamp.utctimetuple()))
|
|
163
|
+
else:
|
|
164
|
+
_ts_val = float(n_timestamp)
|
|
165
|
+
|
|
166
|
+
_ts = int(_ts_val * 1000.0)
|
|
167
|
+
_ts_bytes: bytes = struct.pack(b"!Q", _ts)[2:]
|
|
168
|
+
_entropy = cls._next_entropy(_ts, n_monotonic)
|
|
169
|
+
return _ts_bytes + _entropy
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def by_bytes(cls, m_binary: bytes | Sequence[int]) -> str:
|
|
173
|
+
"""
|
|
174
|
+
将 16 字节二进制数据编码为 26 字符的 base32 字符串
|
|
175
|
+
:param m_binary: 字节串或字节列表
|
|
176
|
+
:return: 26 字符的 ASCII 字符串
|
|
177
|
+
:rtype: str
|
|
178
|
+
"""
|
|
179
|
+
assert len(m_binary) == 16
|
|
180
|
+
|
|
181
|
+
_symbols = cls._symbols
|
|
182
|
+
return "".join(
|
|
183
|
+
[
|
|
184
|
+
_symbols[(m_binary[0] & 224) >> 5],
|
|
185
|
+
_symbols[m_binary[0] & 31],
|
|
186
|
+
_symbols[(m_binary[1] & 248) >> 3],
|
|
187
|
+
_symbols[((m_binary[1] & 7) << 2) | ((m_binary[2] & 192) >> 6)],
|
|
188
|
+
_symbols[(m_binary[2] & 62) >> 1],
|
|
189
|
+
_symbols[((m_binary[2] & 1) << 4) | ((m_binary[3] & 240) >> 4)],
|
|
190
|
+
_symbols[((m_binary[3] & 15) << 1) | ((m_binary[4] & 128) >> 7)],
|
|
191
|
+
_symbols[(m_binary[4] & 124) >> 2],
|
|
192
|
+
_symbols[((m_binary[4] & 3) << 3) | ((m_binary[5] & 224) >> 5)],
|
|
193
|
+
_symbols[m_binary[5] & 31],
|
|
194
|
+
_symbols[(m_binary[6] & 248) >> 3],
|
|
195
|
+
_symbols[((m_binary[6] & 7) << 2) | ((m_binary[7] & 192) >> 6)],
|
|
196
|
+
_symbols[(m_binary[7] & 62) >> 1],
|
|
197
|
+
_symbols[((m_binary[7] & 1) << 4) | ((m_binary[8] & 240) >> 4)],
|
|
198
|
+
_symbols[((m_binary[8] & 15) << 1) | ((m_binary[9] & 128) >> 7)],
|
|
199
|
+
_symbols[(m_binary[9] & 124) >> 2],
|
|
200
|
+
_symbols[((m_binary[9] & 3) << 3) | ((m_binary[10] & 224) >> 5)],
|
|
201
|
+
_symbols[m_binary[10] & 31],
|
|
202
|
+
_symbols[(m_binary[11] & 248) >> 3],
|
|
203
|
+
_symbols[((m_binary[11] & 7) << 2) | ((m_binary[12] & 192) >> 6)],
|
|
204
|
+
_symbols[(m_binary[12] & 62) >> 1],
|
|
205
|
+
_symbols[((m_binary[12] & 1) << 4) | ((m_binary[13] & 240) >> 4)],
|
|
206
|
+
_symbols[((m_binary[13] & 15) << 1) | ((m_binary[14] & 128) >> 7)],
|
|
207
|
+
_symbols[(m_binary[14] & 124) >> 2],
|
|
208
|
+
_symbols[((m_binary[14] & 3) << 3) | ((m_binary[15] & 224) >> 5)],
|
|
209
|
+
_symbols[m_binary[15] & 31],
|
|
210
|
+
]
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def to_bytes(cls, m_ulid: str) -> bytes:
|
|
215
|
+
"""将 26 字符 ULID 字符串解码为 16 字节。
|
|
216
|
+
|
|
217
|
+
参数:
|
|
218
|
+
m_ulid: m_=待解码的 ULID 字符串。
|
|
219
|
+
|
|
220
|
+
返回:
|
|
221
|
+
bytes: 16 字节的二进制表示。
|
|
222
|
+
|
|
223
|
+
异常:
|
|
224
|
+
ValueError: 长度非 26、首字符超出 '0'-'7' 范围或包含非法 base32 字符时抛出。
|
|
225
|
+
"""
|
|
226
|
+
if len(m_ulid) != 26:
|
|
227
|
+
raise ValueError(f"base32 ulid is {len(m_ulid)} characters long, expected 26")
|
|
228
|
+
if not all(c in cls._symbols for c in m_ulid):
|
|
229
|
+
raise ValueError("invalid characters in base32 ulid")
|
|
230
|
+
|
|
231
|
+
_b = [ord(c) for c in m_ulid]
|
|
232
|
+
if _b[0] < 48 or _b[0] > 55:
|
|
233
|
+
raise ValueError(f"base32 ulid is out of range (starts with {m_ulid[0]}; accepted are 01234567)")
|
|
234
|
+
|
|
235
|
+
_tab = cls._decode_table
|
|
236
|
+
_binary = [(c & 0xFF) for c in [
|
|
237
|
+
((_tab[_b[0]] << 5) | _tab[_b[1]]),
|
|
238
|
+
((_tab[_b[2]] << 3) | (_tab[_b[3]] >> 2)),
|
|
239
|
+
((_tab[_b[3]] << 6) | (_tab[_b[4]] << 1) | (_tab[_b[5]] >> 4)),
|
|
240
|
+
((_tab[_b[5]] << 4) | (_tab[_b[6]] >> 1)),
|
|
241
|
+
((_tab[_b[6]] << 7) | (_tab[_b[7]] << 2) | (_tab[_b[8]] >> 3)),
|
|
242
|
+
((_tab[_b[8]] << 5) | _tab[_b[9]]),
|
|
243
|
+
((_tab[_b[10]] << 3) | (_tab[_b[11]] >> 2)),
|
|
244
|
+
((_tab[_b[11]] << 6) | (_tab[_b[12]] << 1) | (_tab[_b[13]] >> 4)),
|
|
245
|
+
((_tab[_b[13]] << 4) | (_tab[_b[14]] >> 1)),
|
|
246
|
+
((_tab[_b[14]] << 7) | (_tab[_b[15]] << 2) | (_tab[_b[16]] >> 3)),
|
|
247
|
+
((_tab[_b[16]] << 5) | _tab[_b[17]]),
|
|
248
|
+
((_tab[_b[18]] << 3) | _tab[_b[19]] >> 2),
|
|
249
|
+
((_tab[_b[19]] << 6) | (_tab[_b[20]] << 1) | (_tab[_b[21]] >> 4)),
|
|
250
|
+
((_tab[_b[21]] << 4) | (_tab[_b[22]] >> 1)),
|
|
251
|
+
((_tab[_b[22]] << 7) | (_tab[_b[23]] << 2) | (_tab[_b[24]] >> 3)),
|
|
252
|
+
((_tab[_b[24]] << 5) | _tab[_b[25]]),
|
|
253
|
+
]]
|
|
254
|
+
return bytes(_binary)
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def to_binary(cls, m_ulid: str | bytes | bytearray | uuid.UUID) -> bytes | bytearray:
|
|
258
|
+
"""将多种表示形式统一转换为 16 字节二进制。
|
|
259
|
+
|
|
260
|
+
参数:
|
|
261
|
+
m_ulid: m_=ULID 字符串(26 字符)、bytes/bytearray(16 字节)或
|
|
262
|
+
:class:`uuid.UUID` 对象。
|
|
263
|
+
|
|
264
|
+
返回:
|
|
265
|
+
bytes | bytearray: 对应的 16 字节二进制(输入为 bytearray 时原样返回)。
|
|
266
|
+
|
|
267
|
+
异常:
|
|
268
|
+
ValueError: 输入无法识别为有效 ULID(长度/类型不符)时抛出。
|
|
269
|
+
"""
|
|
270
|
+
if isinstance(m_ulid, uuid.UUID):
|
|
271
|
+
return m_ulid.bytes
|
|
272
|
+
if isinstance(m_ulid, (cls._text_type, bytes)) and len(m_ulid) == 26:
|
|
273
|
+
_str_val = m_ulid.decode("ascii") if isinstance(m_ulid, bytes) else m_ulid
|
|
274
|
+
return cls.to_bytes(_str_val)
|
|
275
|
+
if isinstance(m_ulid, (bytes, bytearray)) and len(m_ulid) == 16:
|
|
276
|
+
return m_ulid
|
|
277
|
+
raise ValueError(f"can not convert ulid {m_ulid!r} to binary")
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def to_timestamp(cls, m_ulid: str | bytes | bytearray | uuid.UUID) -> float:
|
|
281
|
+
"""从 ULID 提取其时间分量(UTC 毫秒时间戳的秒值)。
|
|
282
|
+
|
|
283
|
+
参数:
|
|
284
|
+
m_ulid: m_=任意合法的 ULID 表示(字符串/字节/UUID)。
|
|
285
|
+
|
|
286
|
+
返回:
|
|
287
|
+
float: Unix 秒(含毫秒小数)时间戳。
|
|
288
|
+
|
|
289
|
+
异常:
|
|
290
|
+
ValueError: 输入非有效 ULID 时由 :meth:`to_binary` 抛出。
|
|
291
|
+
"""
|
|
292
|
+
_ts_bytes = bytes(cls.to_binary(m_ulid)[:6])
|
|
293
|
+
_ts_bytes = b"\0\0" + _ts_bytes
|
|
294
|
+
assert len(_ts_bytes) == 8
|
|
295
|
+
_res: tuple[int, ...] = struct.unpack(b"!Q", _ts_bytes)
|
|
296
|
+
return float(_res[0] / 1000.)
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def to_time(cls, m_ulid: str | bytes | bytearray | uuid.UUID, n_hour_offset: int = 8) -> datetime.datetime:
|
|
300
|
+
"""从 ULID 提取时间并转换为带时区的 :class:`datetime.datetime`。
|
|
301
|
+
|
|
302
|
+
参数:
|
|
303
|
+
m_ulid: m_=任意合法的 ULID 表示(字符串/字节/UUID)。
|
|
304
|
+
n_hour_offset: n_=目标时区相对 UTC 的小时偏移,默认 +8(中国标准时间)。
|
|
305
|
+
|
|
306
|
+
返回:
|
|
307
|
+
datetime.datetime: 已附时区信息的本地化时间。
|
|
308
|
+
|
|
309
|
+
注意:
|
|
310
|
+
内部先按 UTC 解释时间戳再换算到目标时区,不依赖运行机器的本地时区。
|
|
311
|
+
"""
|
|
312
|
+
# to_timestamp 返回的是 UTC 秒:先按 UTC 解释,再换算到目标时区,
|
|
313
|
+
# 避免依赖运行机器的本地时区(旧实现在非 UTC+8 环境会整体偏移)
|
|
314
|
+
_timestamp = cls.to_timestamp(m_ulid)
|
|
315
|
+
_tz = datetime.timezone(datetime.timedelta(hours=n_hour_offset))
|
|
316
|
+
return datetime.datetime.fromtimestamp(_timestamp, datetime.timezone.utc).astimezone(_tz)
|
|
317
|
+
|
|
318
|
+
@classmethod
|
|
319
|
+
def to_uuid(cls, m_ulid: str | bytes | bytearray | uuid.UUID) -> uuid.UUID:
|
|
320
|
+
"""将任意 ULID 表示转换为 :class:`uuid.UUID` 对象。
|
|
321
|
+
|
|
322
|
+
参数:
|
|
323
|
+
m_ulid: m_=ULID 字符串(26 字符)、16 字节或 UUID。
|
|
324
|
+
|
|
325
|
+
返回:
|
|
326
|
+
uuid.UUID: 由 ULID 二进制构造的 UUID。
|
|
327
|
+
"""
|
|
328
|
+
return uuid.UUID(bytes=bytes(cls.to_binary(m_ulid)))
|
|
329
|
+
|
|
330
|
+
@classmethod
|
|
331
|
+
def from_uuid(cls, m_uuid: uuid.UUID) -> str:
|
|
332
|
+
"""将 :class:`uuid.UUID` 转换为对应的 ULID 字符串。
|
|
333
|
+
|
|
334
|
+
参数:
|
|
335
|
+
m_uuid: m_=源 UUID 对象。
|
|
336
|
+
|
|
337
|
+
返回:
|
|
338
|
+
str: 由其 16 字节编码得到的 26 字符 ULID 字符串。
|
|
339
|
+
"""
|
|
340
|
+
return cls.by_bytes(m_uuid.bytes)
|
|
341
|
+
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Author: lqxnjk <lqxnjk@qq.com>
|
|
3
|
+
Created: 2026-10-23 10:06
|
|
4
|
+
Modified: 2026-10-23 10:06
|
|
5
|
+
Python Version: 3.10
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from ulidx import Ulid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_length_and_alphabet() -> None:
|
|
16
|
+
value = Ulid.generate()
|
|
17
|
+
assert len(value) == 26
|
|
18
|
+
assert all(ch in Ulid._symbols for ch in value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_unique() -> None:
|
|
22
|
+
values = {Ulid.generate() for _ in range(2000)}
|
|
23
|
+
assert len(values) == 2000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_monotonic_within_process() -> None:
|
|
27
|
+
values = [Ulid.generate(n_monotonic=True) for _ in range(2000)]
|
|
28
|
+
assert values == sorted(values)
|
|
29
|
+
assert len(set(values)) == len(values)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_byte_round_trip() -> None:
|
|
33
|
+
value = Ulid.generate()
|
|
34
|
+
raw = Ulid.to_bytes(value)
|
|
35
|
+
assert isinstance(raw, bytes) and len(raw) == 16
|
|
36
|
+
assert Ulid.by_bytes(raw) == value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_timestamp_round_trip() -> None:
|
|
40
|
+
stamp = 1700000000
|
|
41
|
+
value = Ulid.generate(n_timestamp=stamp)
|
|
42
|
+
assert Ulid.to_timestamp(value) == pytest.approx(stamp, abs=1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_to_time_uses_offset() -> None:
|
|
46
|
+
value = Ulid.generate(n_timestamp=0)
|
|
47
|
+
assert Ulid.to_time(value, n_hour_offset=0).tzinfo is not None
|
|
48
|
+
assert Ulid.to_time(value, n_hour_offset=8).utcoffset().total_seconds() == 8 * 3600
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_datetime_input() -> None:
|
|
52
|
+
moment = datetime(2026, 10, 23, 12, 0, tzinfo=timezone.utc)
|
|
53
|
+
value = Ulid.generate(n_timestamp=moment)
|
|
54
|
+
assert Ulid.to_timestamp(value) == pytest.approx(moment.timestamp(), abs=1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_uuid_round_trip() -> None:
|
|
58
|
+
value = Ulid.generate()
|
|
59
|
+
converted = Ulid.to_uuid(value)
|
|
60
|
+
assert converted.int >> 80 > 0
|
|
61
|
+
assert Ulid.from_uuid(converted) == value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_batch() -> None:
|
|
65
|
+
values = Ulid.generate_batch(500)
|
|
66
|
+
assert len(values) == 500
|
|
67
|
+
assert len(set(values)) == 500
|