gr-ulid 0.1.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.
@@ -0,0 +1,83 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ name: Lint
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.13"
20
+
21
+ - name: Install dependencies
22
+ run: pip install hatch
23
+
24
+ - name: Check formatting
25
+ run: hatch run fmt -- --check
26
+
27
+ - name: Lint
28
+ run: hatch run lint
29
+
30
+ typecheck:
31
+ name: Type Check
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+
36
+ - name: Set up Python
37
+ uses: actions/setup-python@v5
38
+ with:
39
+ python-version: "3.13"
40
+
41
+ - name: Install dependencies
42
+ run: pip install hatch
43
+
44
+ - name: mypy
45
+ run: hatch run typecheck
46
+
47
+ test:
48
+ name: Test (Python ${{ matrix.python-version }})
49
+ runs-on: ubuntu-latest
50
+ strategy:
51
+ matrix:
52
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+
56
+ - name: Set up Python ${{ matrix.python-version }}
57
+ uses: actions/setup-python@v5
58
+ with:
59
+ python-version: ${{ matrix.python-version }}
60
+ allow-prereleases: true
61
+
62
+ - name: Install dependencies
63
+ run: pip install hatch
64
+
65
+ - name: Test
66
+ run: hatch run test -- -v
67
+
68
+ benchmark:
69
+ name: Benchmarks
70
+ runs-on: ubuntu-latest
71
+ steps:
72
+ - uses: actions/checkout@v4
73
+
74
+ - name: Set up Python
75
+ uses: actions/setup-python@v5
76
+ with:
77
+ python-version: "3.13"
78
+
79
+ - name: Install dependencies
80
+ run: pip install hatch
81
+
82
+ - name: Run benchmarks
83
+ run: hatch run bench
@@ -0,0 +1,27 @@
1
+ name: publish
2
+
3
+ on:
4
+ release:
5
+ types: [created]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+
17
+ - name: Install build dependencies
18
+ run: pip install build twine
19
+
20
+ - name: Build package
21
+ run: python -m build
22
+
23
+ - name: Publish to PyPI
24
+ env:
25
+ TWINE_USERNAME: __token__
26
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
27
+ run: twine upload dist/*
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ uv.lock
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ *.egg
gr_ulid-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gaucho Racing
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.
gr_ulid-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,333 @@
1
+ Metadata-Version: 2.4
2
+ Name: gr-ulid
3
+ Version: 0.1.0
4
+ Summary: A blazing fast, production-grade ULID implementation in Python
5
+ Project-URL: Homepage, https://github.com/gaucho-racing/ulid-py
6
+ Project-URL: Repository, https://github.com/gaucho-racing/ulid-py
7
+ Project-URL: Issues, https://github.com/gaucho-racing/ulid-py/issues
8
+ Author: Gaucho Racing
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: identifier,sortable,ulid,uuid
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ulid-py
26
+
27
+ [![CI](https://github.com/gaucho-racing/ulid-py/actions/workflows/ci.yml/badge.svg)](https://github.com/gaucho-racing/ulid-py/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/gr-ulid.svg)](https://pypi.org/project/gr-ulid/)
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
30
+
31
+ A blazing fast, production-grade [ULID](https://github.com/ulid/spec) implementation in Python. Designed to provide a consistent, ergonomic identifier format, ulid-py is currently used across many of Gaucho Racing's services and projects.
32
+
33
+ - **Lowercase by default** — all string output uses lowercase Crockford Base32
34
+ - **Prefix support** — generate entity-scoped IDs like `user_01arz3ndek...` or `txn_01arz3ndek...`
35
+ - **Distributed uniqueness** — `Generator` with node ID partitioning guarantees collision-free IDs across up to 65,536 nodes without coordination
36
+ - **Monotonic sorting** — IDs generated within the same millisecond are strictly ordered
37
+ - **Fully unrolled encoding** — Crockford Base32 encode/decode with no loops
38
+ - **Thread-safe** — `make()`, `Generator`, and `default_entropy()` are safe for concurrent use
39
+ - **128-bit UUID compatible** — drop-in replacement for UUID columns in databases
40
+ - **Fully typed** — PEP 561 compliant with `py.typed` marker, passes `mypy --strict`
41
+ - **Zero runtime dependencies** — only stdlib
42
+
43
+ ## Getting Started
44
+
45
+ ### Installing
46
+
47
+ ```sh
48
+ pip install gr-ulid
49
+ ```
50
+
51
+ ### Usage
52
+
53
+ ```python
54
+ import ulid
55
+
56
+ # Generate a ULID
57
+ id = ulid.make()
58
+ print(id) # 01jgy5fz7rqv8s3n0x4m6k2w1h
59
+
60
+ # With a prefix
61
+ print(id.prefixed("user")) # user_01jgy5fz7rqv8s3n0x4m6k2w1h
62
+
63
+ # Parse it back
64
+ parsed = ulid.parse("01jgy5fz7rqv8s3n0x4m6k2w1h")
65
+ print(parsed.time()) # Unix millisecond timestamp
66
+ print(parsed.timestamp()) # datetime
67
+
68
+ # Parse prefixed IDs
69
+ prefix, parsed = ulid.parse_prefixed("user_01jgy5fz7rqv8s3n0x4m6k2w1h")
70
+ print(prefix) # "user"
71
+
72
+ # Use a Generator for distributed systems
73
+ gen = ulid.new_generator(
74
+ ulid.with_node_id(1),
75
+ ulid.with_prefix("evt"),
76
+ )
77
+ print(gen.make_prefixed()) # evt_01jgy5fz7r...
78
+ ```
79
+
80
+ ## Specification
81
+
82
+ This library implements the [ULID spec](https://github.com/ulid/spec) with several opinionated extensions. This section covers the binary format, encoding, monotonicity behavior, distributed uniqueness strategy, and every deviation from the official spec.
83
+
84
+ ### Binary Layout
85
+
86
+ A ULID is 128 bits (16 bytes), stored in big-endian (network byte order) as an immutable `bytes` object:
87
+
88
+ ```
89
+ 0 1 2 3
90
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
91
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
92
+ | 32_bit_uint_time_high |
93
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
94
+ | 16_bit_uint_time_low | 16_bit_uint_random |
95
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
96
+ | 32_bit_uint_random |
97
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
98
+ | 32_bit_uint_random |
99
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
100
+ ```
101
+
102
+ | Component | Bytes | Bits | Description |
103
+ |---|---|---|---|
104
+ | Timestamp | `[0:6]` | 48 | Unix milliseconds, big-endian. Valid until year 10889 AD. |
105
+ | Entropy | `[6:16]` | 80 | Cryptographic randomness (or node-partitioned randomness). |
106
+
107
+ Using immutable `bytes` as the underlying type means ULIDs are **hashable**: they can be used as dictionary keys and set members. Byte comparison ordering is consistent with chronological and lexicographic string ordering because the timestamp occupies the most significant bytes.
108
+
109
+ ### Crockford Base32 Encoding
110
+
111
+ The string representation is 26 characters using the Crockford Base32 alphabet:
112
+
113
+ ```
114
+ 0123456789abcdefghjkmnpqrstvwxyz
115
+ ```
116
+
117
+ The first 10 characters encode the 48-bit timestamp, the remaining 16 encode the 80-bit entropy:
118
+
119
+ ```
120
+ ttttttttttrrrrrrrrrrrrrrrr
121
+ ```
122
+
123
+ The encoding and decoding are **fully unrolled**: every bit extraction/insertion is a single explicit line with no loops. Decoding uses a 256-byte lookup table for O(1) character-to-value conversion, and both upper and lowercase map to the same values, making parsing inherently case-insensitive.
124
+
125
+ **Overflow check**: 26 Base32 characters technically encode 130 bits, but a ULID only uses 128. The first character is restricted to values `0`–`7` (3 bits). Any ULID string starting with `8` or higher is rejected with `ErrOverflow`. The largest valid ULID is `7zzzzzzzzzzzzzzzzzzzzzzzzz`.
126
+
127
+ #### `parse` vs `parse_strict`
128
+
129
+ `parse` skips character validation for speed. Invalid characters (like `I`, `L`, `O`, `U`) will silently produce wrong bits rather than returning an error. Use `parse_strict` when accepting untrusted input. Use `parse` when you control the input (e.g., reading from your own database).
130
+
131
+ ### Monotonicity
132
+
133
+ When multiple ULIDs are generated within the same millisecond, the spec requires monotonic ordering. This library implements monotonicity through `MonotonicEntropy`:
134
+
135
+ ```python
136
+ import os
137
+ import ulid
138
+
139
+ entropy = ulid.monotonic(os.urandom, 0)
140
+ ms = ulid.now()
141
+
142
+ # All three share the same millisecond: entropy is incremented, not re-randomized
143
+ id1 = ulid.new(ms, entropy) # random entropy R
144
+ id2 = ulid.new(ms, entropy) # R + random_increment
145
+ id3 = ulid.new(ms, entropy) # R + random_increment + random_increment
146
+ # id1 < id2 < id3 guaranteed
147
+ ```
148
+
149
+ **Overflow behavior**: The 80-bit entropy space is tracked using a custom `_UInt80` type (`uint16` high + `uint64` low) with explicit masking (since Python integers are arbitrary precision). When incrementing would overflow, `ErrMonotonicOverflow` is raised. The library **never** silently wraps around or advances the timestamp.
150
+
151
+ **Thread safety**: `MonotonicEntropy` itself is **not** thread-safe. For concurrent use, wrap it with `LockedMonotonicReader` (which adds a `threading.Lock`), or use `default_entropy()` / `make()` which do this automatically. The `Generator` class also handles its own locking internally.
152
+
153
+ ### Entropy Sources
154
+
155
+ The library accepts any `Callable[[int], bytes]` as an entropy source:
156
+
157
+ | Source | Security | Notes |
158
+ |---|---|---|
159
+ | `os.urandom` | Cryptographic | Default. Uses OS entropy pool. |
160
+ | Custom callable | Varies | Any function `(int) -> bytes`. |
161
+ | `monotonic(r, inc)` | Inherits from `r` | Increments within same ms instead of re-reading. |
162
+ | `None` | None | Zero entropy. Useful for timestamp-only IDs. |
163
+
164
+ ### Distributed Uniqueness
165
+
166
+ For multi-node deployments, the `Generator` class supports embedding a **16-bit node ID** in the first 2 bytes of the entropy field:
167
+
168
+ ```python
169
+ gen = ulid.new_generator(ulid.with_node_id(42))
170
+ id = gen.make()
171
+ ```
172
+
173
+ This partitions the entropy layout as follows:
174
+
175
+ ```
176
+ Bytes [0:6] - 48-bit timestamp (unchanged)
177
+ Bytes [6:8] - 16-bit node ID (0–65535)
178
+ Bytes [8:16] - 64-bit monotonic random entropy
179
+ ```
180
+
181
+ Two generators with different node IDs **cannot** produce the same ULID, even within the same millisecond.
182
+
183
+ ### Prefixed IDs
184
+
185
+ Prefixed IDs are a library extension for entity-scoped identifiers:
186
+
187
+ ```python
188
+ id = ulid.make()
189
+ id.prefixed("user") # "user_01arz3ndektsv4rrffq69g5fav"
190
+ id.prefixed("txn") # "txn_01arz3ndektsv4rrffq69g5fav"
191
+ ```
192
+
193
+ The prefix is **not** part of the ULID itself. `parse_prefixed` splits on the first `_` and parses the ULID portion:
194
+
195
+ ```python
196
+ prefix, id = ulid.parse_prefixed("user_01arz3ndektsv4rrffq69g5fav")
197
+ # prefix = "user", id = the parsed ULID
198
+ ```
199
+
200
+ ### Deviations from the Official Spec
201
+
202
+ | Behavior | Official Spec | This Library |
203
+ |---|---|---|
204
+ | **String case** | Uppercase (`01ARZ3NDEK...`) | Lowercase (`01arz3ndek...`). Parsing remains case-insensitive. |
205
+ | **Prefixed IDs** | Not specified | Supported via `prefixed()` and `parse_prefixed()`. |
206
+ | **Node ID partitioning** | Not specified | Supported via `Generator` with `with_node_id()`. |
207
+ | **Excluded letter handling** | Crockford spec maps `I`→`1`, `L`→`1`, `O`→`0` during decoding | Not mapped. `I`, `L`, `O`, `U` are treated as invalid in strict mode and produce undefined results in non-strict mode. |
208
+
209
+ ### Footguns
210
+
211
+ - **`parse` does not validate characters.** Use `parse_strict` for untrusted input.
212
+ - **`MonotonicEntropy` is not thread-safe.** Using it from multiple threads without `LockedMonotonicReader` will corrupt state. `make()` and `Generator` handle this for you.
213
+ - **`bytes()` and `entropy()` return the underlying immutable bytes.** Since `_data` is immutable `bytes`, no copy is needed.
214
+ - **`Generator` with node ID clobbers monotonic high bits.** If intra-millisecond ordering matters more than distributed uniqueness, use `make()` instead.
215
+ - **Monotonic overflow is an error, not a retry.** When `ErrMonotonicOverflow` is raised, the caller is responsible for handling it.
216
+
217
+ ## Benchmarks
218
+
219
+ Measured with `pytest-benchmark` on Python 3.14, AMD EPYC 7763 (GitHub Actions CI). Pure Python, no C extensions.
220
+
221
+ | Operation | Median | Throughput |
222
+ |---|---|---|
223
+ | `marshal_binary()` | 93 ns | 10.7M ops/sec |
224
+ | `compare()` | 122 ns | 8.1M ops/sec |
225
+ | `now()` | 201 ns | 4.9M ops/sec |
226
+ | `new()` (crypto entropy) | 2.6 µs | 372K ops/sec |
227
+ | `string()` | 3.1 µs | 320K ops/sec |
228
+ | `parse()` | 3.7 µs | 260K ops/sec |
229
+ | `parse_strict()` | 5.0 µs | 198K ops/sec |
230
+ | `make()` | 5.5 µs | 180K ops/sec |
231
+ | `new_generator().make()` | 5.5 µs | 180K ops/sec |
232
+ | `new_generator().make_prefixed()` | 9.3 µs | 106K ops/sec |
233
+
234
+ Run benchmarks locally:
235
+
236
+ ```sh
237
+ hatch run bench
238
+ ```
239
+
240
+ ## API
241
+
242
+ ### Constructors
243
+
244
+ | Function | Description |
245
+ |---|---|
246
+ | `make()` | Generate a ULID with current time and default entropy. Thread-safe. |
247
+ | `new(ms, entropy)` | Generate with explicit timestamp and entropy source. |
248
+ | `must_new(ms, entropy)` | Like `new` (raises on error in Python). |
249
+ | `parse(s)` | Decode a 26-char Base32 string. Case-insensitive. |
250
+ | `parse_strict(s)` | Like `parse` with character validation. |
251
+ | `parse_prefixed(s)` | Parse a `prefix_ulid` string, returning `(prefix, ULID)`. |
252
+ | `must_parse(s)` | Like `parse` (raises on error in Python). |
253
+ | `must_parse_strict(s)` | Like `parse_strict` (raises on error in Python). |
254
+
255
+ ### ULID Methods
256
+
257
+ | Method | Description |
258
+ |---|---|
259
+ | `string()` | 26-char lowercase Crockford Base32 string. |
260
+ | `prefixed(p)` | Prefixed string: `p_<ulid>`. |
261
+ | `bytes()` | Raw 16-byte data. |
262
+ | `time()` | Unix millisecond timestamp. |
263
+ | `timestamp()` | Timestamp as `datetime`. |
264
+ | `entropy()` | 10-byte entropy. |
265
+ | `is_zero()` | True if zero value. |
266
+ | `compare(other)` | Lexicographic comparison (-1, 0, +1). |
267
+ | `set_time(ms)` | Return new ULID with updated timestamp. |
268
+ | `set_entropy(e)` | Return new ULID with updated entropy (10 bytes). |
269
+
270
+ ### Python Special Methods
271
+
272
+ | Method | Description |
273
+ |---|---|
274
+ | `__str__` | Same as `string()`. |
275
+ | `__bytes__` | Same as `bytes()`. |
276
+ | `__int__` | 128-bit integer value. |
277
+ | `__hash__` | Hashable (usable as dict key / set member). |
278
+ | `__eq__`, `__lt__`, etc. | Full rich comparison support. |
279
+
280
+ ### Serialization
281
+
282
+ | Method | Description |
283
+ |---|---|
284
+ | `marshal_binary()` | Raw 16-byte data. |
285
+ | `marshal_text()` | 26-byte ASCII encoded string. |
286
+ | `marshal_json()` | JSON-encoded quoted string. |
287
+ | `ULID.unmarshal_binary(data)` | Parse from 16 bytes. |
288
+ | `ULID.unmarshal_text(data)` | Parse from 26-char string/bytes. |
289
+ | `ULID.unmarshal_json(data)` | Parse from JSON string. |
290
+
291
+ ### Time Helpers
292
+
293
+ | Function | Description |
294
+ |---|---|
295
+ | `now()` | Current UTC Unix milliseconds. |
296
+ | `timestamp(dt)` | Convert `datetime` to Unix ms. |
297
+ | `time(ms)` | Convert Unix ms to `datetime`. |
298
+ | `max_time()` | Maximum encodable timestamp (year 10889). |
299
+
300
+ ### Entropy
301
+
302
+ | Function | Description |
303
+ |---|---|
304
+ | `default_entropy()` | Process-global thread-safe monotonic entropy (`os.urandom`). |
305
+ | `monotonic(r, inc)` | Create a monotonic entropy source wrapping any callable. |
306
+
307
+ ### Generator
308
+
309
+ | Function/Method | Description |
310
+ |---|---|
311
+ | `new_generator(*opts)` | Create a generator with options. |
312
+ | `with_node_id(id)` | Embed a 16-bit node ID for distributed uniqueness. |
313
+ | `with_entropy(r)` | Use a custom entropy source. |
314
+ | `with_prefix(p)` | Set a default prefix. |
315
+ | `gen.make()` | Generate a ULID. Thread-safe. |
316
+ | `gen.make_prefixed(p)` | Generate a prefixed ULID string. |
317
+ | `gen.new(ms)` | Generate with explicit timestamp. |
318
+ | `gen.node_id()` | Get `(node_id, has_node)`. |
319
+
320
+ ## Contributing
321
+
322
+ If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".
323
+ Don't forget to give the project a star! Thanks again!
324
+
325
+ 1. Fork the Project
326
+ 2. Create your Feature Branch (`git checkout -b gh-username/my-amazing-feature`)
327
+ 3. Commit your Changes (`git commit -m 'Add my amazing feature'`)
328
+ 4. Push to the Branch (`git push origin gh-username/my-amazing-feature`)
329
+ 5. Open a Pull Request
330
+
331
+ ## License
332
+
333
+ MIT. See [LICENSE](LICENSE).