cuid2 2.2.0__tar.gz → 2.3.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.
- {cuid2-2.2.0 → cuid2-2.3.0}/PKG-INFO +7 -6
- {cuid2-2.2.0 → cuid2-2.3.0}/README.md +5 -3
- {cuid2-2.2.0 → cuid2-2.3.0}/pyproject.toml +8 -8
- {cuid2-2.2.0 → cuid2-2.3.0}/pyproject.toml.orig +8 -8
- {cuid2-2.2.0 → cuid2-2.3.0}/src/cuid2/__main__.py +2 -2
- {cuid2-2.2.0 → cuid2-2.3.0}/src/cuid2/cli.py +49 -9
- {cuid2-2.2.0 → cuid2-2.3.0}/src/cuid2/generator.py +67 -45
- cuid2-2.3.0/src/cuid2/utils.py +294 -0
- cuid2-2.2.0/src/cuid2/utils.py +0 -188
- {cuid2-2.2.0 → cuid2-2.3.0}/LICENSE +0 -0
- {cuid2-2.2.0 → cuid2-2.3.0}/src/cuid2/__init__.py +0 -0
- {cuid2-2.2.0 → cuid2-2.3.0}/src/cuid2/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cuid2
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Next generation GUIDs. Collision-resistant ids optimized for horizontal scaling and performance.
|
|
5
5
|
Keywords: crypt,security,uuid,guid,cuid,cryptography
|
|
6
6
|
Author: Will Gordon
|
|
@@ -11,7 +11,6 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: Operating System :: OS Independent
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -20,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.15
|
|
|
20
19
|
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
21
20
|
Classifier: Topic :: Security :: Cryptography
|
|
22
21
|
Classifier: Typing :: Typed
|
|
23
|
-
Requires-Python: >=3.
|
|
22
|
+
Requires-Python: >=3.11
|
|
24
23
|
Project-URL: repository, https://github.com/gordon-code/cuid2/
|
|
25
24
|
Project-URL: Changelog, https://github.com/gordon-code/cuid2/blob/main/CHANGELOG.md
|
|
26
25
|
Project-URL: Issues, https://github.com/gordon-code/cuid2/issues
|
|
@@ -28,7 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
28
27
|
|
|
29
28
|
# cuid2
|
|
30
29
|
|
|
31
|
-
[](https://pypi.org/project/cuid2/)
|
|
32
31
|
[](https://pypi.org/project/cuid2/)
|
|
33
32
|
[](https://pypi.org/project/cuid2/)
|
|
34
33
|
[](https://scorecard.dev/viewer/?uri=github.com/gordon-code/cuid2)
|
|
@@ -45,7 +44,7 @@ by [Parallel Drive](https://github.com/paralleldrive) to Python 3.
|
|
|
45
44
|
|
|
46
45
|
## What is CUID2?
|
|
47
46
|
|
|
48
|
-
* Secure: It's not feasible to guess the next ID (unguessability derives from the `
|
|
47
|
+
* Secure: It's not feasible to guess the next ID (unguessability derives from the `random.SystemRandom` CSPRNG salt; per [RFC 9562 §8](https://www.rfc-editor.org/rfc/rfc9562.html#section-8), IDs are identifiers, not capability tokens or secrets).
|
|
49
48
|
* Collision resistant: It's extremely unlikely to generate the same ID twice.
|
|
50
49
|
* Horizontally scalable: Generate IDs on multiple machines without coordination.
|
|
51
50
|
* Offline-compatible: Generate IDs without a network connection.
|
|
@@ -69,7 +68,7 @@ For more information on the improvements of CUID2 over CUID, see the
|
|
|
69
68
|
* **123.6-Bit Default Keyspace (`length=24`)**: 1 leading lowercase letter ($26$) followed by 23 base36 characters ($36^{23}$) yields $26 \times 36^{23} \approx \mathbf{1.61 \times 10^{37}}$ possible IDs ($\approx \mathbf{123.6\text{ bits}}$ of entropy)—slightly more than UUIDv4's 122 random bits in a 33% shorter string (24 vs. 36 chars). Reaching a 50% birthday-bound collision probability requires $\sqrt{2 \ln(2) \times 26 \times 36^{23}} \approx \mathbf{4.7 \times 10^{18}}$ IDs (generating **149 million IDs/sec continuously for 1,000 years**).
|
|
70
69
|
* **Up to 165.0 Bits (`BIG_LENGTH = 32`)**: At `length=32`, the keyspace expands to $26 \times 36^{31} \approx \mathbf{4.54 \times 10^{49}}$ IDs ($\approx \mathbf{165.0\text{ bits}}$; 50% collision bound at $\approx 7.9 \times 10^{24}$ IDs).
|
|
71
70
|
* **Histogram De-Biasing**: Because $2^{512} \approx 1.34 \times 10^{154}$ is not an exact power of 36, the most-significant base36 digit of a 512-bit SHA3-512 digest is biased toward low digits; CUID2 drops that leading hash character (`[1:]`) and prepends an independent uniform `[a-z]` letter so every character position has a uniform distribution.
|
|
72
|
-
* **Thread, Pre-Fork, & Container Snapshot Resilience**: `Cuid`, `create_id`, `cuid_wrapper`, and `create_counter` are thread-safe (including on free-threaded Python, tested on `3.14t` and `3.15t`). Each generator records the process ID (`os.getpid()`) that built its state; the first ID generated in a forked child process (`multiprocessing`, Gunicorn, Celery prefork, and uWSGI workers, including uWSGI's default mode, which skips Python's `os.register_at_fork` hooks) rebuilds that state with a fresh
|
|
71
|
+
* **Thread, Pre-Fork, & Container Snapshot Resilience**: `Cuid`, `create_id`, `cuid_wrapper`, and `create_counter` are thread-safe (including on free-threaded Python, tested on `3.14t` and `3.15t`). Each generator records the process ID (`os.getpid()`) that built its state; the first ID generated in a forked child process (`multiprocessing`, Gunicorn, Celery prefork, and uWSGI workers, including uWSGI's default mode, which skips Python's `os.register_at_fork` hooks) rebuilds that state with a fresh RNG state, lock (when `random_generator` returns something other than a `random.SystemRandom` instance or `counter` is not the default; the default `random.SystemRandom` and counter are already thread-safe), random initial counter (`INITIAL_COUNT_MAX = 476,782,367`, giving $\approx 25,700$ concurrent hosts before a 50% chance of two hosts starting at the same counter value), and child-PID fingerprints (`os.getpid()`, `socket.gethostname()`, and environment variable keys). For cloned VM/container snapshots where no `fork()` syscall occurs, per-call `random.SystemRandom` (`os.urandom`) salt and `time.time_ns()` provide defense-in-depth against state collisions.
|
|
73
72
|
* **Polyglot ORM Compatibility (Prisma & Drizzle)**: Generates 24-character base36 primary keys compatible with TypeScript/Node.js services using Prisma (`@default(cuid(2))` / `@onCreate(cuid(2))`) or Drizzle (`@paralleldrive/cuid2`), and replaces the deprecated CUIDv1 (`cuid`) format.
|
|
74
73
|
|
|
75
74
|
| Property | CUID2 (`cuid2`) | UUIDv7 (`uuid.uuid7`) | UUIDv4 (`uuid.uuid4`) | ULID (`python-ulid`) | NanoID (`nanoid`) |
|
|
@@ -105,6 +104,8 @@ short_cuid: str = create_id(length=10)
|
|
|
105
104
|
print(is_cuid(my_cuid)) # True
|
|
106
105
|
```
|
|
107
106
|
|
|
107
|
+
The CUID2 specification allows lengths 2..32; lengths `1` and `33`–`98` are still accepted in 2.x but emit a `FutureWarning` (shown as `cuid2: warning: ...` by the CLI), and 3.0.0 will reject them with `ValueError`.
|
|
108
|
+
|
|
108
109
|
> **Tip for Django & ORM defaults:** Pass `default=create_id` directly on model fields (e.g., `models.CharField(max_length=24, default=create_id)`). Because `create_id` is a top-level function backed by a lazy singleton generator, Django `makemigrations` can serialize it directly and `Cuid()` initialization is deferred until the first ID is generated in each worker process.
|
|
109
110
|
|
|
110
111
|
You can also generate and validate CUIDs directly in the terminal:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cuid2
|
|
2
2
|
|
|
3
|
-
[](https://pypi.org/project/cuid2/)
|
|
4
4
|
[](https://pypi.org/project/cuid2/)
|
|
5
5
|
[](https://pypi.org/project/cuid2/)
|
|
6
6
|
[](https://scorecard.dev/viewer/?uri=github.com/gordon-code/cuid2)
|
|
@@ -17,7 +17,7 @@ by [Parallel Drive](https://github.com/paralleldrive) to Python 3.
|
|
|
17
17
|
|
|
18
18
|
## What is CUID2?
|
|
19
19
|
|
|
20
|
-
* Secure: It's not feasible to guess the next ID (unguessability derives from the `
|
|
20
|
+
* Secure: It's not feasible to guess the next ID (unguessability derives from the `random.SystemRandom` CSPRNG salt; per [RFC 9562 §8](https://www.rfc-editor.org/rfc/rfc9562.html#section-8), IDs are identifiers, not capability tokens or secrets).
|
|
21
21
|
* Collision resistant: It's extremely unlikely to generate the same ID twice.
|
|
22
22
|
* Horizontally scalable: Generate IDs on multiple machines without coordination.
|
|
23
23
|
* Offline-compatible: Generate IDs without a network connection.
|
|
@@ -41,7 +41,7 @@ For more information on the improvements of CUID2 over CUID, see the
|
|
|
41
41
|
* **123.6-Bit Default Keyspace (`length=24`)**: 1 leading lowercase letter ($26$) followed by 23 base36 characters ($36^{23}$) yields $26 \times 36^{23} \approx \mathbf{1.61 \times 10^{37}}$ possible IDs ($\approx \mathbf{123.6\text{ bits}}$ of entropy)—slightly more than UUIDv4's 122 random bits in a 33% shorter string (24 vs. 36 chars). Reaching a 50% birthday-bound collision probability requires $\sqrt{2 \ln(2) \times 26 \times 36^{23}} \approx \mathbf{4.7 \times 10^{18}}$ IDs (generating **149 million IDs/sec continuously for 1,000 years**).
|
|
42
42
|
* **Up to 165.0 Bits (`BIG_LENGTH = 32`)**: At `length=32`, the keyspace expands to $26 \times 36^{31} \approx \mathbf{4.54 \times 10^{49}}$ IDs ($\approx \mathbf{165.0\text{ bits}}$; 50% collision bound at $\approx 7.9 \times 10^{24}$ IDs).
|
|
43
43
|
* **Histogram De-Biasing**: Because $2^{512} \approx 1.34 \times 10^{154}$ is not an exact power of 36, the most-significant base36 digit of a 512-bit SHA3-512 digest is biased toward low digits; CUID2 drops that leading hash character (`[1:]`) and prepends an independent uniform `[a-z]` letter so every character position has a uniform distribution.
|
|
44
|
-
* **Thread, Pre-Fork, & Container Snapshot Resilience**: `Cuid`, `create_id`, `cuid_wrapper`, and `create_counter` are thread-safe (including on free-threaded Python, tested on `3.14t` and `3.15t`). Each generator records the process ID (`os.getpid()`) that built its state; the first ID generated in a forked child process (`multiprocessing`, Gunicorn, Celery prefork, and uWSGI workers, including uWSGI's default mode, which skips Python's `os.register_at_fork` hooks) rebuilds that state with a fresh
|
|
44
|
+
* **Thread, Pre-Fork, & Container Snapshot Resilience**: `Cuid`, `create_id`, `cuid_wrapper`, and `create_counter` are thread-safe (including on free-threaded Python, tested on `3.14t` and `3.15t`). Each generator records the process ID (`os.getpid()`) that built its state; the first ID generated in a forked child process (`multiprocessing`, Gunicorn, Celery prefork, and uWSGI workers, including uWSGI's default mode, which skips Python's `os.register_at_fork` hooks) rebuilds that state with a fresh RNG state, lock (when `random_generator` returns something other than a `random.SystemRandom` instance or `counter` is not the default; the default `random.SystemRandom` and counter are already thread-safe), random initial counter (`INITIAL_COUNT_MAX = 476,782,367`, giving $\approx 25,700$ concurrent hosts before a 50% chance of two hosts starting at the same counter value), and child-PID fingerprints (`os.getpid()`, `socket.gethostname()`, and environment variable keys). For cloned VM/container snapshots where no `fork()` syscall occurs, per-call `random.SystemRandom` (`os.urandom`) salt and `time.time_ns()` provide defense-in-depth against state collisions.
|
|
45
45
|
* **Polyglot ORM Compatibility (Prisma & Drizzle)**: Generates 24-character base36 primary keys compatible with TypeScript/Node.js services using Prisma (`@default(cuid(2))` / `@onCreate(cuid(2))`) or Drizzle (`@paralleldrive/cuid2`), and replaces the deprecated CUIDv1 (`cuid`) format.
|
|
46
46
|
|
|
47
47
|
| Property | CUID2 (`cuid2`) | UUIDv7 (`uuid.uuid7`) | UUIDv4 (`uuid.uuid4`) | ULID (`python-ulid`) | NanoID (`nanoid`) |
|
|
@@ -77,6 +77,8 @@ short_cuid: str = create_id(length=10)
|
|
|
77
77
|
print(is_cuid(my_cuid)) # True
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
The CUID2 specification allows lengths 2..32; lengths `1` and `33`–`98` are still accepted in 2.x but emit a `FutureWarning` (shown as `cuid2: warning: ...` by the CLI), and 3.0.0 will reject them with `ValueError`.
|
|
81
|
+
|
|
80
82
|
> **Tip for Django & ORM defaults:** Pass `default=create_id` directly on model fields (e.g., `models.CharField(max_length=24, default=create_id)`). Because `create_id` is a top-level function backed by a lazy singleton generator, Django `makemigrations` can serialize it directly and `Cuid()` initialization is deferred until the first ID is generated in each worker process.
|
|
81
83
|
|
|
82
84
|
You can also generate and validate CUIDs directly in the terminal:
|
|
@@ -4,10 +4,10 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cuid2"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "Next generation GUIDs. Collision-resistant ids optimized for horizontal scaling and performance."
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
13
|
classifiers = [
|
|
@@ -15,7 +15,6 @@ classifiers = [
|
|
|
15
15
|
"Intended Audience :: Developers",
|
|
16
16
|
"Operating System :: OS Independent",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
|
-
"Programming Language :: Python :: 3.10",
|
|
19
18
|
"Programming Language :: Python :: 3.11",
|
|
20
19
|
"Programming Language :: Python :: 3.12",
|
|
21
20
|
"Programming Language :: Python :: 3.13",
|
|
@@ -44,7 +43,7 @@ Changelog = "https://github.com/gordon-code/cuid2/blob/main/CHANGELOG.md"
|
|
|
44
43
|
Issues = "https://github.com/gordon-code/cuid2/issues"
|
|
45
44
|
|
|
46
45
|
[project.scripts]
|
|
47
|
-
cuid2 = "cuid2.cli:
|
|
46
|
+
cuid2 = "cuid2.cli:_console_main"
|
|
48
47
|
|
|
49
48
|
[tool.uv]
|
|
50
49
|
default-groups = [
|
|
@@ -70,7 +69,7 @@ exclude = ["hack"]
|
|
|
70
69
|
[tool.ruff]
|
|
71
70
|
line-length = 120
|
|
72
71
|
src = ["src"]
|
|
73
|
-
target-version = "
|
|
72
|
+
target-version = "py311"
|
|
74
73
|
cache-dir = "local/.ruff_cache"
|
|
75
74
|
extend-exclude = ["hack"]
|
|
76
75
|
force-exclude = true
|
|
@@ -158,6 +157,7 @@ exclude = [
|
|
|
158
157
|
"^dist/",
|
|
159
158
|
"^hack/",
|
|
160
159
|
]
|
|
160
|
+
python_version = "3.11"
|
|
161
161
|
strict = true
|
|
162
162
|
|
|
163
163
|
[[tool.mypy.overrides]]
|
|
@@ -173,6 +173,7 @@ exclude = [
|
|
|
173
173
|
"dist",
|
|
174
174
|
"hack",
|
|
175
175
|
]
|
|
176
|
+
pythonVersion = "3.11"
|
|
176
177
|
strict = ["src"]
|
|
177
178
|
|
|
178
179
|
[dependency-groups]
|
|
@@ -182,12 +183,11 @@ lint = [
|
|
|
182
183
|
"prek==0.5.7",
|
|
183
184
|
"ruff==0.17.0",
|
|
184
185
|
"rumdl==0.3.0",
|
|
185
|
-
"zizmor==1.31.1
|
|
186
|
+
"zizmor==1.31.1",
|
|
186
187
|
]
|
|
187
188
|
test = [
|
|
188
189
|
"coverage[toml]==7.16.2",
|
|
189
|
-
"hypothesis==6.
|
|
190
|
-
"hypothesis==6.170.5; python_version >= '3.11'",
|
|
190
|
+
"hypothesis==6.170.5",
|
|
191
191
|
"keepachangelog==2.0.0",
|
|
192
192
|
"pytest==9.1.1",
|
|
193
193
|
"pytest-cov==7.1.0",
|
|
@@ -4,13 +4,13 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cuid2"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
authors = [
|
|
9
9
|
{name = "Will Gordon", email = "dev@gordoncode.dev"},
|
|
10
10
|
]
|
|
11
11
|
description = "Next generation GUIDs. Collision-resistant ids optimized for horizontal scaling and performance."
|
|
12
12
|
readme = "README.md"
|
|
13
|
-
requires-python = ">=3.
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
14
|
license = "MIT"
|
|
15
15
|
license-files = ["LICENSE"]
|
|
16
16
|
classifiers = [
|
|
@@ -18,7 +18,6 @@ classifiers = [
|
|
|
18
18
|
"Intended Audience :: Developers",
|
|
19
19
|
"Operating System :: OS Independent",
|
|
20
20
|
"Programming Language :: Python :: 3",
|
|
21
|
-
"Programming Language :: Python :: 3.10",
|
|
22
21
|
"Programming Language :: Python :: 3.11",
|
|
23
22
|
"Programming Language :: Python :: 3.12",
|
|
24
23
|
"Programming Language :: Python :: 3.13",
|
|
@@ -36,7 +35,7 @@ Changelog = "https://github.com/gordon-code/cuid2/blob/main/CHANGELOG.md"
|
|
|
36
35
|
Issues = "https://github.com/gordon-code/cuid2/issues"
|
|
37
36
|
|
|
38
37
|
[project.scripts]
|
|
39
|
-
cuid2 = "cuid2.cli:
|
|
38
|
+
cuid2 = "cuid2.cli:_console_main"
|
|
40
39
|
|
|
41
40
|
[tool.uv]
|
|
42
41
|
default-groups = ["build", "lint", "test", "typing"]
|
|
@@ -50,12 +49,11 @@ lint = [
|
|
|
50
49
|
"prek==0.5.7",
|
|
51
50
|
"ruff==0.17.0",
|
|
52
51
|
"rumdl==0.3.0",
|
|
53
|
-
"zizmor==1.31.1
|
|
52
|
+
"zizmor==1.31.1",
|
|
54
53
|
]
|
|
55
54
|
test = [
|
|
56
55
|
"coverage[toml]==7.16.2",
|
|
57
|
-
"hypothesis==6.
|
|
58
|
-
"hypothesis==6.170.5; python_version >= '3.11'",
|
|
56
|
+
"hypothesis==6.170.5",
|
|
59
57
|
"keepachangelog==2.0.0",
|
|
60
58
|
"pytest==9.1.1",
|
|
61
59
|
"pytest-cov==7.1.0",
|
|
@@ -82,7 +80,7 @@ exclude = ["hack"]
|
|
|
82
80
|
[tool.ruff]
|
|
83
81
|
line-length = 120
|
|
84
82
|
src = ["src"]
|
|
85
|
-
target-version = "
|
|
83
|
+
target-version = "py311"
|
|
86
84
|
cache-dir = "local/.ruff_cache"
|
|
87
85
|
extend-exclude = ["hack"]
|
|
88
86
|
force-exclude = true
|
|
@@ -152,6 +150,7 @@ exclude_also = [
|
|
|
152
150
|
cache_dir = "local/.mypy_cache"
|
|
153
151
|
files = ["."]
|
|
154
152
|
exclude = ["^build/", "^dist/", "^hack/"]
|
|
153
|
+
python_version = "3.11"
|
|
155
154
|
strict = true
|
|
156
155
|
|
|
157
156
|
[[tool.mypy.overrides]]
|
|
@@ -160,4 +159,5 @@ ignore_missing_imports = true
|
|
|
160
159
|
|
|
161
160
|
[tool.pyright]
|
|
162
161
|
exclude = ["build", "dist", "hack"]
|
|
162
|
+
pythonVersion = "3.11"
|
|
163
163
|
strict = ["src"]
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
+
import warnings
|
|
7
8
|
from argparse import SUPPRESS, Action, ArgumentParser, ArgumentTypeError, RawDescriptionHelpFormatter
|
|
8
9
|
from contextlib import suppress
|
|
9
10
|
from functools import partial
|
|
@@ -15,9 +16,10 @@ from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, MAXIMUM_LENGTH, MIN_LENGTH
|
|
|
15
16
|
__all__ = ["main"]
|
|
16
17
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
|
-
from _random import Random
|
|
19
19
|
from argparse import Namespace
|
|
20
20
|
from collections.abc import Iterable, Iterator, Sequence
|
|
21
|
+
from random import Random
|
|
22
|
+
from typing import TextIO
|
|
21
23
|
|
|
22
24
|
_SLUG_LENGTH: int = 5
|
|
23
25
|
|
|
@@ -93,6 +95,23 @@ def _fixed_fingerprint(random_generator: Random, *, fingerprint: str) -> str:
|
|
|
93
95
|
return fingerprint
|
|
94
96
|
|
|
95
97
|
|
|
98
|
+
def _show_cli_warning( # noqa: PLR0913, PLR0917
|
|
99
|
+
message: Warning | str,
|
|
100
|
+
category: type[Warning],
|
|
101
|
+
filename: str,
|
|
102
|
+
lineno: int,
|
|
103
|
+
file: TextIO | None = None,
|
|
104
|
+
line: str | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
# Prints warnings as one clean `cuid2: warning: <message>` stderr line, without the source path or code line.
|
|
107
|
+
del category, filename, lineno, line
|
|
108
|
+
stream = file if file is not None else sys.stderr
|
|
109
|
+
if stream is None: # No stderr (pythonw or fd 2 closed at startup): drop the warning like the stdlib does.
|
|
110
|
+
return
|
|
111
|
+
with suppress(OSError):
|
|
112
|
+
stream.write(f"cuid2: warning: {message}\n")
|
|
113
|
+
|
|
114
|
+
|
|
96
115
|
def _build_parser() -> ArgumentParser:
|
|
97
116
|
parser = ArgumentParser(
|
|
98
117
|
prog="cuid2",
|
|
@@ -246,7 +265,16 @@ def main(args: Sequence[str] | None = None) -> None:
|
|
|
246
265
|
------
|
|
247
266
|
SystemExit
|
|
248
267
|
Raised with exit code ``0`` or ``1`` in ``--check``/``--validate`` or
|
|
249
|
-
``--version``/``--help`` modes, or exit code ``2`` on invalid arguments
|
|
268
|
+
``--version``/``--help`` modes, or exit code ``2`` on invalid arguments,
|
|
269
|
+
including a length warning that a warnings filter turned into an error.
|
|
270
|
+
|
|
271
|
+
Warns
|
|
272
|
+
-----
|
|
273
|
+
FutureWarning
|
|
274
|
+
When generating IDs (not in ``--check``/``--validate`` mode) with a length
|
|
275
|
+
outside the CUID2 specification range (``MIN_LENGTH``..``BIG_LENGTH``,
|
|
276
|
+
i.e., 2..32). The ``cuid2`` console script and ``python -m cuid2`` print it
|
|
277
|
+
as a ``cuid2: warning: ...`` line.
|
|
250
278
|
"""
|
|
251
279
|
parser = _build_parser()
|
|
252
280
|
cli_args = parser.parse_args(args)
|
|
@@ -259,12 +287,24 @@ def main(args: Sequence[str] | None = None) -> None:
|
|
|
259
287
|
|
|
260
288
|
resolved_length = target_length if target_length is not None else DEFAULT_LENGTH
|
|
261
289
|
count = cli_args.count if cli_args.count is not None else 1
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
290
|
+
try:
|
|
291
|
+
cuid_generator = (
|
|
292
|
+
Cuid(length=resolved_length)
|
|
293
|
+
if cli_args.fingerprint is None
|
|
294
|
+
else Cuid(
|
|
295
|
+
length=resolved_length,
|
|
296
|
+
fingerprint=partial(_fixed_fingerprint, fingerprint=cli_args.fingerprint),
|
|
297
|
+
)
|
|
268
298
|
)
|
|
269
|
-
|
|
299
|
+
except FutureWarning as warning:
|
|
300
|
+
# A warnings filter such as -W error or PYTHONWARNINGS=error turned the length warning into an error.
|
|
301
|
+
parser.error(str(warning))
|
|
270
302
|
_write_lines(f"{cuid_generator.generate()}\n" for _ in range(count))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _console_main(args: Sequence[str] | None = None) -> None: # pyright: ignore[reportUnusedFunction]
|
|
306
|
+
# Entry point for the `cuid2` console script and `python -m cuid2`: prints warnings as `cuid2: warning:` lines.
|
|
307
|
+
# main() leaves warning delivery to its caller, so programmatic use never touches the global showwarning hook.
|
|
308
|
+
with warnings.catch_warnings():
|
|
309
|
+
warnings.showwarning = _show_cli_warning
|
|
310
|
+
main(args)
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import inspect
|
|
6
5
|
import os
|
|
7
6
|
import re
|
|
8
7
|
import threading
|
|
@@ -11,9 +10,8 @@ import warnings
|
|
|
11
10
|
from contextlib import suppress
|
|
12
11
|
from copy import copy, deepcopy
|
|
13
12
|
from functools import partial
|
|
14
|
-
from
|
|
15
|
-
from
|
|
16
|
-
from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple, Protocol
|
|
13
|
+
from random import SystemRandom # secrets.SystemRandom re-exports random.SystemRandom (os.urandom CSPRNG)
|
|
14
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
17
15
|
|
|
18
16
|
from cuid2 import utils
|
|
19
17
|
from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, INITIAL_COUNT_MAX, MAXIMUM_LENGTH, MIN_LENGTH
|
|
@@ -31,8 +29,9 @@ __all__ = [
|
|
|
31
29
|
]
|
|
32
30
|
|
|
33
31
|
if TYPE_CHECKING:
|
|
34
|
-
from _random import Random
|
|
35
32
|
from collections.abc import Callable
|
|
33
|
+
from random import Random
|
|
34
|
+
from typing import ClassVar, Final, Protocol
|
|
36
35
|
|
|
37
36
|
class FingerprintCallable(Protocol):
|
|
38
37
|
"""Protocol for fingerprint generator callables."""
|
|
@@ -72,7 +71,7 @@ def _validate_length(length: int) -> None:
|
|
|
72
71
|
|
|
73
72
|
Warns
|
|
74
73
|
-----
|
|
75
|
-
|
|
74
|
+
FutureWarning
|
|
76
75
|
If `length` is outside the CUID2 specification range (`MIN_LENGTH`..`BIG_LENGTH`, i.e., 2..32).
|
|
77
76
|
"""
|
|
78
77
|
if isinstance(length, bool) or length < 1:
|
|
@@ -82,16 +81,11 @@ def _validate_length(length: int) -> None:
|
|
|
82
81
|
msg = "Length must never exceed 98 characters."
|
|
83
82
|
raise ValueError(msg)
|
|
84
83
|
if length < MIN_LENGTH or length > BIG_LENGTH:
|
|
85
|
-
stacklevel: int = 1
|
|
86
|
-
frame = inspect.currentframe()
|
|
87
|
-
while frame is not None and frame.f_code.co_filename == __file__:
|
|
88
|
-
stacklevel += 1
|
|
89
|
-
frame = frame.f_back
|
|
90
84
|
warnings.warn(
|
|
91
85
|
f"Length {length} outside the CUID2 specification range ({MIN_LENGTH}..{BIG_LENGTH}) "
|
|
92
86
|
"is deprecated and will raise ValueError in v3.0.0.",
|
|
93
|
-
|
|
94
|
-
stacklevel=
|
|
87
|
+
FutureWarning,
|
|
88
|
+
stacklevel=3,
|
|
95
89
|
)
|
|
96
90
|
|
|
97
91
|
|
|
@@ -128,8 +122,9 @@ class _GeneratorState(NamedTuple):
|
|
|
128
122
|
"""Per-process generator state, replaced as a whole when a forked child process first generates an ID."""
|
|
129
123
|
|
|
130
124
|
pid: int
|
|
131
|
-
lock: threading.RLock
|
|
125
|
+
lock: threading.RLock | None
|
|
132
126
|
random: Random
|
|
127
|
+
draw: Callable[[int], tuple[str, str]]
|
|
133
128
|
counter: Callable[[], int]
|
|
134
129
|
fingerprint: str
|
|
135
130
|
|
|
@@ -165,8 +160,12 @@ class Cuid:
|
|
|
165
160
|
Parameters
|
|
166
161
|
----------
|
|
167
162
|
random_generator : Callable[[], "Random"], default=SystemRandom
|
|
168
|
-
Factory callable returning the random number generator instance. Defaults to `
|
|
169
|
-
|
|
163
|
+
Factory callable returning the random number generator instance. Defaults to `random.SystemRandom`
|
|
164
|
+
(`os.urandom`). Random bits are drawn with the instance's `.getrandbits(k)`. A generator without
|
|
165
|
+
`.getrandbits(k)`, or whose class defines `.random()` but not `.getrandbits(k)` (such as a `random.Random`
|
|
166
|
+
subclass overriding only `.random()`, the custom generator pattern described in the `random` module
|
|
167
|
+
documentation), is drawn from with one `.random()` call per character. Called again in a forked child
|
|
168
|
+
process (see Notes).
|
|
170
169
|
counter : Callable[[int], Callable[[], int]], default=utils.create_counter
|
|
171
170
|
The `counter` parameter is a callable that creates a counter returning an incremented value each time it
|
|
172
171
|
is called. The `create_counter` function from the `utils` module is used by default. Called again with a
|
|
@@ -175,7 +174,7 @@ class Cuid:
|
|
|
175
174
|
The length parameter is an integer that determines the maximum length of the generated string.
|
|
176
175
|
It has a default value of DEFAULT_LENGTH (24).
|
|
177
176
|
A length value less than 1 or greater than `MAXIMUM_LENGTH` (98 characters) will raise a ValueError.
|
|
178
|
-
Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a
|
|
177
|
+
Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a FutureWarning (a ValueError in 3.0.0).
|
|
179
178
|
fingerprint : "FingerprintCallable", default=utils.create_fingerprint
|
|
180
179
|
The "fingerprint" parameter is a callable function that generates a unique identifier. Called again in a
|
|
181
180
|
forked child process; the default includes the child's `os.getpid()`.
|
|
@@ -187,27 +186,30 @@ class Cuid:
|
|
|
187
186
|
|
|
188
187
|
Warns
|
|
189
188
|
-----
|
|
190
|
-
|
|
189
|
+
FutureWarning
|
|
191
190
|
If the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters).
|
|
192
191
|
|
|
193
192
|
Notes
|
|
194
193
|
-----
|
|
195
|
-
`generate()` may be called concurrently from multiple threads, including on free-threaded Python.
|
|
196
|
-
|
|
197
|
-
|
|
194
|
+
`generate()` may be called concurrently from multiple threads, including on free-threaded Python. When
|
|
195
|
+
`random_generator` returns a `random.SystemRandom` instance and `counter` is the default, both of which are
|
|
196
|
+
thread-safe on their own, no instance lock is taken. Otherwise, draws from the random generator and counter
|
|
197
|
+
are serialized per instance by a re-entrant lock, so a signal handler or finalizer that generates an ID on the
|
|
198
|
+
same thread does not deadlock.
|
|
198
199
|
|
|
199
200
|
Each instance records the process ID that built its state. When `generate()` first runs in a different
|
|
200
201
|
process, such as any `os.fork()` child (including pre-fork servers that fork without running Python's fork
|
|
201
|
-
hooks), it builds a new
|
|
202
|
-
`counter`, and `fingerprint` again before generating. If `random_generator`
|
|
203
|
-
the fork, its `seed()` method is called to reseed it from OS entropy; if it
|
|
204
|
-
raises `NotImplementedError` or `TypeError`, that generator keeps the
|
|
205
|
-
timestamp and, with the default `fingerprint`, by process ID.
|
|
206
|
-
`functools.partial(random.Random, 42)`) give every process the same stream and
|
|
207
|
-
single-process tests.
|
|
202
|
+
hooks), it builds a new random generator, counter, fingerprint, and (when one is used) lock once, by
|
|
203
|
+
calling `random_generator`, `counter`, and `fingerprint` again before generating. If `random_generator`
|
|
204
|
+
returns the same object as before the fork, its `seed()` method is called to reseed it from OS entropy; if it
|
|
205
|
+
has no `seed()` method or `seed()` raises `NotImplementedError` or `TypeError`, that generator keeps the
|
|
206
|
+
parent's stream, and IDs still differ by timestamp and, with the default `fingerprint`, by process ID.
|
|
207
|
+
Fixed-seed factories (such as `functools.partial(random.Random, 42)`) give every process the same stream and
|
|
208
|
+
are intended for deterministic single-process tests.
|
|
208
209
|
|
|
209
210
|
`copy.deepcopy()` returns an independent generator: the three factories are shared by reference, other
|
|
210
|
-
attributes are deep-copied, and the
|
|
211
|
+
attributes are deep-copied, and the random generator, counter, fingerprint, and (when one is used) lock are
|
|
212
|
+
built anew.
|
|
211
213
|
"""
|
|
212
214
|
_validate_length(length)
|
|
213
215
|
|
|
@@ -245,11 +247,19 @@ class Cuid:
|
|
|
245
247
|
if random_generator is previous_random:
|
|
246
248
|
with suppress(AttributeError, NotImplementedError, TypeError):
|
|
247
249
|
random_generator.seed()
|
|
250
|
+
# SystemRandom draws are stateless os.urandom reads and create_counter locks internally, so the defaults
|
|
251
|
+
# need no instance lock; any other generator or counter may keep unsynchronized state.
|
|
252
|
+
needs_lock: bool = (
|
|
253
|
+
type(random_generator) is not SystemRandom or self._counter_factory is not utils.create_counter
|
|
254
|
+
)
|
|
248
255
|
return _GeneratorState(
|
|
249
256
|
pid=os.getpid(),
|
|
250
|
-
lock=threading.RLock(),
|
|
257
|
+
lock=threading.RLock() if needs_lock else None,
|
|
251
258
|
random=random_generator,
|
|
252
|
-
|
|
259
|
+
draw=utils._letter_and_entropy_source(random_generator), # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
260
|
+
counter=self._counter_factory(
|
|
261
|
+
utils._create_initial_count(random_generator), # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
262
|
+
),
|
|
253
263
|
fingerprint=self._fingerprint_factory(random_generator=random_generator),
|
|
254
264
|
)
|
|
255
265
|
|
|
@@ -262,7 +272,7 @@ class Cuid:
|
|
|
262
272
|
The length parameter is an optional integer value that specifies the length of the generated string.
|
|
263
273
|
If it is not provided, the default length value provided during class initialization is used.
|
|
264
274
|
A length value less than 1 or greater than `MAXIMUM_LENGTH` (98 characters) will raise a ValueError.
|
|
265
|
-
Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a
|
|
275
|
+
Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a FutureWarning (a ValueError in 3.0.0).
|
|
266
276
|
|
|
267
277
|
Returns
|
|
268
278
|
-------
|
|
@@ -278,23 +288,33 @@ class Cuid:
|
|
|
278
288
|
|
|
279
289
|
Warns
|
|
280
290
|
-----
|
|
281
|
-
|
|
291
|
+
FutureWarning
|
|
282
292
|
If the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters).
|
|
283
293
|
"""
|
|
284
294
|
if length is not None:
|
|
285
295
|
_validate_length(length)
|
|
286
|
-
|
|
296
|
+
return self._generate(self._length if length is None else length)
|
|
287
297
|
|
|
298
|
+
def _generate(self: Cuid, resolved_length: int) -> str:
|
|
288
299
|
state: _GeneratorState = self._current_state()
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
300
|
+
lock: threading.RLock | None = state.lock
|
|
301
|
+
if lock is None:
|
|
302
|
+
first_letter, salt = state.draw(resolved_length)
|
|
303
|
+
count = state.counter()
|
|
304
|
+
else:
|
|
305
|
+
with lock:
|
|
306
|
+
first_letter, salt = state.draw(resolved_length)
|
|
307
|
+
count = state.counter()
|
|
308
|
+
|
|
309
|
+
base36_count: str = utils.base36_encode(count)
|
|
294
310
|
base36_time: str = utils.base36_encode(time.time_ns())
|
|
295
311
|
hash_input: str = base36_time + salt + base36_count + state.fingerprint
|
|
296
312
|
|
|
297
|
-
return first_letter + utils.
|
|
313
|
+
return first_letter + utils._create_hash_slice( # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
314
|
+
hash_input,
|
|
315
|
+
1,
|
|
316
|
+
resolved_length,
|
|
317
|
+
)
|
|
298
318
|
|
|
299
319
|
is_cuid: ClassVar[IsCuidCallable] = staticmethod(is_cuid)
|
|
300
320
|
|
|
@@ -310,7 +330,7 @@ def create_id(length: int | None = None) -> str:
|
|
|
310
330
|
length : int, optional
|
|
311
331
|
The optional length of the generated CUID2 string. Defaults to `DEFAULT_LENGTH` (24) when omitted.
|
|
312
332
|
A length value less than 1 or greater than `MAXIMUM_LENGTH` (98 characters) will raise a ValueError.
|
|
313
|
-
Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a
|
|
333
|
+
Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a FutureWarning (a ValueError in 3.0.0).
|
|
314
334
|
|
|
315
335
|
Returns
|
|
316
336
|
-------
|
|
@@ -324,10 +344,12 @@ def create_id(length: int | None = None) -> str:
|
|
|
324
344
|
|
|
325
345
|
Warns
|
|
326
346
|
-----
|
|
327
|
-
|
|
347
|
+
FutureWarning
|
|
328
348
|
If the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters).
|
|
329
349
|
"""
|
|
330
350
|
global _default_generator # noqa: PLW0603
|
|
351
|
+
if length is not None:
|
|
352
|
+
_validate_length(length)
|
|
331
353
|
generator = _default_generator
|
|
332
354
|
if generator is None:
|
|
333
355
|
with _process_lock(os.getpid()):
|
|
@@ -335,7 +357,7 @@ def create_id(length: int | None = None) -> str:
|
|
|
335
357
|
if generator is None:
|
|
336
358
|
generator = Cuid()
|
|
337
359
|
_default_generator = generator
|
|
338
|
-
return generator.
|
|
360
|
+
return generator._generate(DEFAULT_LENGTH if length is None else length) # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
339
361
|
|
|
340
362
|
|
|
341
363
|
def cuid_wrapper(length: int = DEFAULT_LENGTH) -> Callable[[], str]:
|
|
@@ -347,7 +369,7 @@ def cuid_wrapper(length: int = DEFAULT_LENGTH) -> Callable[[], str]:
|
|
|
347
369
|
The length parameter is an integer that determines the maximum length of the generated string.
|
|
348
370
|
It has a default value of DEFAULT_LENGTH (24).
|
|
349
371
|
A length value less than 1 or greater than `MAXIMUM_LENGTH` (98 characters) will raise a ValueError.
|
|
350
|
-
Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a
|
|
372
|
+
Lengths outside `MIN_LENGTH`..`BIG_LENGTH` (2..32) emit a FutureWarning (a ValueError in 3.0.0).
|
|
351
373
|
|
|
352
374
|
Returns
|
|
353
375
|
-------
|
|
@@ -361,7 +383,7 @@ def cuid_wrapper(length: int = DEFAULT_LENGTH) -> Callable[[], str]:
|
|
|
361
383
|
|
|
362
384
|
Warns
|
|
363
385
|
-----
|
|
364
|
-
|
|
386
|
+
FutureWarning
|
|
365
387
|
If the length parameter is outside `MIN_LENGTH`..`BIG_LENGTH` (2..32 characters).
|
|
366
388
|
"""
|
|
367
389
|
_validate_length(length)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Cryptographic hashing, entropy, counter, fingerprint, and base36 helpers for CUID2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from functools import partial
|
|
8
|
+
from hashlib import sha3_512
|
|
9
|
+
from math import floor
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from random import Random
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
# ~25.7k hosts before 50% chance of initial counter collision
|
|
18
|
+
# with a remaining counter range of 9.0e+15 in JavaScript.
|
|
19
|
+
INITIAL_COUNT_MAX: Final[int] = 476782367
|
|
20
|
+
MIN_LENGTH: Final[int] = 2
|
|
21
|
+
DEFAULT_LENGTH: Final[int] = 24
|
|
22
|
+
BIG_LENGTH: Final[int] = 32
|
|
23
|
+
MAXIMUM_LENGTH: Final[int] = 98
|
|
24
|
+
|
|
25
|
+
ASCII_LOWERCASE: Final[str] = "abcdefghijklmnopqrstuvwxyz"
|
|
26
|
+
ASCII_LOWERCASE_LEN: Final[int] = len(ASCII_LOWERCASE)
|
|
27
|
+
BASE36_ALPHABET: Final[str] = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
28
|
+
BASE36_RADIX: Final[int] = len(BASE36_ALPHABET)
|
|
29
|
+
|
|
30
|
+
_OVERSAMPLE_BITS: Final[int] = 64
|
|
31
|
+
_COUNTER_DRAW_BITS: Final[int] = INITIAL_COUNT_MAX.bit_length() + _OVERSAMPLE_BITS
|
|
32
|
+
_LETTER_DRAW_BITS: Final[int] = ASCII_LOWERCASE_LEN.bit_length() + _OVERSAMPLE_BITS
|
|
33
|
+
_POWERS_OF_36: Final[tuple[int, ...]] = tuple(BASE36_RADIX**i for i in range(101))
|
|
34
|
+
_HASH_MIN_99_DIGITS: Final[int] = _POWERS_OF_36[98]
|
|
35
|
+
_HASH_MIN_100_DIGITS: Final[int] = _POWERS_OF_36[99]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _uses_random_only(random_generator: Random) -> bool:
|
|
39
|
+
# Generators without getrandbits(), and random.Random subclasses whose nearest definition is random() rather
|
|
40
|
+
# than getrandbits() (mirroring random.Random.__init_subclass__), supply their own core generator through
|
|
41
|
+
# random() alone; they are drawn from with one floor(random() * n) call per character, never through the
|
|
42
|
+
# inherited getrandbits(), so random() of any resolution keeps its full per-character entropy.
|
|
43
|
+
if getattr(random_generator, "getrandbits", None) is None:
|
|
44
|
+
return True
|
|
45
|
+
for klass in type(random_generator).__mro__:
|
|
46
|
+
namespace = vars(klass)
|
|
47
|
+
if "getrandbits" in namespace:
|
|
48
|
+
return False
|
|
49
|
+
if "random" in namespace:
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _scale_randbits(bits: int, upper_bound: int, draw_bits: int) -> int:
|
|
55
|
+
return (bits * upper_bound) >> draw_bits
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _create_initial_count(random_generator: Random) -> int: # pyright: ignore[reportUnusedFunction]
|
|
59
|
+
if _uses_random_only(random_generator):
|
|
60
|
+
return floor(random_generator.random() * INITIAL_COUNT_MAX)
|
|
61
|
+
return _scale_randbits(
|
|
62
|
+
random_generator.getrandbits(_COUNTER_DRAW_BITS),
|
|
63
|
+
INITIAL_COUNT_MAX,
|
|
64
|
+
_COUNTER_DRAW_BITS,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _entropy_from_scaled(scaled: int, length: int) -> str:
|
|
69
|
+
alphabet: str = BASE36_ALPHABET
|
|
70
|
+
chars: list[str] = []
|
|
71
|
+
for _ in range(length):
|
|
72
|
+
scaled, mod = divmod(scaled, BASE36_RADIX)
|
|
73
|
+
chars.append(alphabet[mod])
|
|
74
|
+
return "".join(chars)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _letter_via_random(random_generator: Random) -> str:
|
|
78
|
+
return ASCII_LOWERCASE[floor(random_generator.random() * ASCII_LOWERCASE_LEN)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _entropy_via_random(random_generator: Random, length: int) -> str:
|
|
82
|
+
return "".join([BASE36_ALPHABET[floor(random_generator.random() * BASE36_RADIX)] for _ in range(length)])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _letter_and_entropy_via_getrandbits(getrandbits: Callable[[int], int], length: int) -> tuple[str, str]:
|
|
86
|
+
entropy_modulus: int = _POWERS_OF_36[length]
|
|
87
|
+
modulus: int = ASCII_LOWERCASE_LEN * entropy_modulus
|
|
88
|
+
draw_bits: int = modulus.bit_length() + _OVERSAMPLE_BITS
|
|
89
|
+
letter_idx, entropy_scaled = divmod(_scale_randbits(getrandbits(draw_bits), modulus, draw_bits), entropy_modulus)
|
|
90
|
+
return ASCII_LOWERCASE[letter_idx], _entropy_from_scaled(entropy_scaled, length)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _letter_and_entropy_via_random(random_generator: Random, length: int) -> tuple[str, str]:
|
|
94
|
+
return _letter_via_random(random_generator), _entropy_via_random(random_generator, length)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _letter_and_entropy_source( # pyright: ignore[reportUnusedFunction]
|
|
98
|
+
random_generator: Random,
|
|
99
|
+
) -> Callable[[int], tuple[str, str]]:
|
|
100
|
+
if _uses_random_only(random_generator):
|
|
101
|
+
return partial(_letter_and_entropy_via_random, random_generator)
|
|
102
|
+
return partial(_letter_and_entropy_via_getrandbits, random_generator.getrandbits)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _create_hash_slice(data: str, start: int, stop: int) -> str:
|
|
106
|
+
hashed_int: int = int.from_bytes(sha3_512(data.encode()).digest(), byteorder="big")
|
|
107
|
+
raw_start: int = start + 1
|
|
108
|
+
raw_stop: int = stop + 1
|
|
109
|
+
if hashed_int >= _HASH_MIN_99_DIGITS and stop < MAXIMUM_LENGTH:
|
|
110
|
+
total_digits: int = 100 if hashed_int >= _HASH_MIN_100_DIGITS else 99
|
|
111
|
+
return base36_encode(hashed_int // _POWERS_OF_36[total_digits - raw_stop])[raw_start:raw_stop]
|
|
112
|
+
return base36_encode(hashed_int)[raw_start:raw_stop]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_counter(count: int) -> Callable[[], int]:
|
|
116
|
+
"""Create a thread-safe counter that returns an incremented value each time it is called.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
count : int
|
|
121
|
+
Initial count value for the counter.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
Callable[[], int]
|
|
126
|
+
Closure function `counter` that takes no arguments and returns an integer. The closure function keeps track
|
|
127
|
+
of a count variable that is initialized with the value passed as an argument to `create_counter`. Each time
|
|
128
|
+
the closure function is called, it atomically increments the count variable under a lock and returns its
|
|
129
|
+
new value.
|
|
130
|
+
"""
|
|
131
|
+
lock = threading.RLock()
|
|
132
|
+
|
|
133
|
+
def counter() -> int:
|
|
134
|
+
nonlocal count
|
|
135
|
+
with lock:
|
|
136
|
+
count += 1
|
|
137
|
+
return count
|
|
138
|
+
|
|
139
|
+
return counter
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_fingerprint(random_generator: Random, fingerprint_data: str | None = "") -> str:
|
|
143
|
+
"""Create a host/process fingerprint combined with random entropy and hashed.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
random_generator : random.Random
|
|
148
|
+
Random number generator used to generate fingerprint entropy. Its `.getrandbits(k)` is used; a generator
|
|
149
|
+
without `.getrandbits(k)`, or whose class defines `.random()` but not `.getrandbits(k)` (such as a
|
|
150
|
+
`random.Random` subclass overriding only `.random()`), is drawn from with one `.random()` call per character.
|
|
151
|
+
fingerprint_data : str, optional
|
|
152
|
+
An optional parameter that contains data to be included in the fingerprint.
|
|
153
|
+
If it is not provided, the function combines process ID, hostname, and environment variables
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
str
|
|
158
|
+
The hashed value of the input `fingerprint_data` or default value concatenated with a randomly generated
|
|
159
|
+
entropy string.
|
|
160
|
+
The length of the returned string is trimmed to the constant `BIG_LENGTH` (32 characters).
|
|
161
|
+
"""
|
|
162
|
+
if not fingerprint_data:
|
|
163
|
+
import socket # noqa: PLC0415
|
|
164
|
+
|
|
165
|
+
process_id: int = os.getpid()
|
|
166
|
+
hostname: str = socket.gethostname()
|
|
167
|
+
env_variables: str = "".join(os.environ.keys())
|
|
168
|
+
|
|
169
|
+
fingerprint_data = str(process_id) + hostname + env_variables
|
|
170
|
+
|
|
171
|
+
fingerprint: str = str(fingerprint_data) + create_entropy(random_generator, BIG_LENGTH)
|
|
172
|
+
return _create_hash_slice(fingerprint, 0, BIG_LENGTH)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def create_entropy(random_generator: Random, length: int = 4) -> str:
|
|
176
|
+
"""Create a random string of specified length using base36 encoding.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
random_generator : random.Random
|
|
181
|
+
Random number generator used to generate a random string. Its `.getrandbits(k)` is used; a generator
|
|
182
|
+
without `.getrandbits(k)`, or whose class defines `.random()` but not `.getrandbits(k)` (such as a
|
|
183
|
+
`random.Random` subclass overriding only `.random()`), is drawn from with one `.random()` call per character.
|
|
184
|
+
length : int, default=4
|
|
185
|
+
The length parameter is an integer that specifies the length of the entropy string to be generated.
|
|
186
|
+
The default value is 4, but it can be set to any positive integer value.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
str
|
|
191
|
+
Random characters encoded as a base36 string. The length of the string is determined by
|
|
192
|
+
the `length` parameter passed to the function.
|
|
193
|
+
|
|
194
|
+
Raises
|
|
195
|
+
------
|
|
196
|
+
ValueError
|
|
197
|
+
If the input integer is less than 1.
|
|
198
|
+
"""
|
|
199
|
+
if length < 1:
|
|
200
|
+
msg = "Cannot create entropy without a length >= 1."
|
|
201
|
+
raise ValueError(msg)
|
|
202
|
+
|
|
203
|
+
if _uses_random_only(random_generator):
|
|
204
|
+
return _entropy_via_random(random_generator, length)
|
|
205
|
+
|
|
206
|
+
modulus: int = _POWERS_OF_36[length] if length < len(_POWERS_OF_36) else BASE36_RADIX**length
|
|
207
|
+
draw_bits: int = modulus.bit_length() + _OVERSAMPLE_BITS
|
|
208
|
+
scaled: int = _scale_randbits(random_generator.getrandbits(draw_bits), modulus, draw_bits)
|
|
209
|
+
return _entropy_from_scaled(scaled, length)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def create_hash(data: str = "") -> str:
|
|
213
|
+
"""Create a base36-encoded SHA3-512 hash of `data` with the leading character dropped.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
data : str, default=""
|
|
218
|
+
Data to be hashed. It is an optional parameter with a default value of an empty string.
|
|
219
|
+
If no value is provided for `data`, an empty string will be hashed.
|
|
220
|
+
|
|
221
|
+
Returns
|
|
222
|
+
-------
|
|
223
|
+
str
|
|
224
|
+
Base36 encoding of the SHA3-512 hash of the input string `data`, with the first character dropped.
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
hashed_value = sha3_512(data.encode())
|
|
228
|
+
hashed_int: int = int.from_bytes(hashed_value.digest(), byteorder="big")
|
|
229
|
+
|
|
230
|
+
# Drop the first character because it will bias the histogram to the left.
|
|
231
|
+
return base36_encode(hashed_int)[1:]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def create_letter(random_generator: Random) -> str:
|
|
235
|
+
"""Generate a random lowercase ASCII letter using the given random number generator.
|
|
236
|
+
|
|
237
|
+
Parameters
|
|
238
|
+
----------
|
|
239
|
+
random_generator : random.Random
|
|
240
|
+
Random number generator used to generate a random letter. Its `.getrandbits(k)` is used; a generator
|
|
241
|
+
without `.getrandbits(k)`, or whose class defines `.random()` but not `.getrandbits(k)` (such as a
|
|
242
|
+
`random.Random` subclass overriding only `.random()`), is drawn from with one `.random()` call.
|
|
243
|
+
|
|
244
|
+
Returns
|
|
245
|
+
-------
|
|
246
|
+
str
|
|
247
|
+
a randomly generated lowercase letter from the English alphabet.
|
|
248
|
+
"""
|
|
249
|
+
if _uses_random_only(random_generator):
|
|
250
|
+
return _letter_via_random(random_generator)
|
|
251
|
+
return ASCII_LOWERCASE[
|
|
252
|
+
_scale_randbits(
|
|
253
|
+
random_generator.getrandbits(_LETTER_DRAW_BITS),
|
|
254
|
+
ASCII_LOWERCASE_LEN,
|
|
255
|
+
_LETTER_DRAW_BITS,
|
|
256
|
+
)
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def base36_encode(number: int) -> str:
|
|
261
|
+
"""Encode a non-negative integer into a base36 string.
|
|
262
|
+
|
|
263
|
+
Parameters
|
|
264
|
+
----------
|
|
265
|
+
number : int
|
|
266
|
+
Integer to be encoded as a base36 string.
|
|
267
|
+
|
|
268
|
+
Returns
|
|
269
|
+
-------
|
|
270
|
+
str
|
|
271
|
+
A string that represents the base36 encoded input integer.
|
|
272
|
+
If the input integer is negative, a ValueError is raised.
|
|
273
|
+
If the input integer is 0, the function returns the string "0".
|
|
274
|
+
|
|
275
|
+
Raises
|
|
276
|
+
------
|
|
277
|
+
ValueError
|
|
278
|
+
If the input integer is negative.
|
|
279
|
+
"""
|
|
280
|
+
if number < 0:
|
|
281
|
+
msg = "Cannot encode negative integers."
|
|
282
|
+
raise ValueError(msg)
|
|
283
|
+
|
|
284
|
+
if number == 0:
|
|
285
|
+
return "0"
|
|
286
|
+
|
|
287
|
+
alphabet: str = BASE36_ALPHABET
|
|
288
|
+
encoded_chars: list[str] = []
|
|
289
|
+
while number != 0:
|
|
290
|
+
number, mod = divmod(number, BASE36_RADIX)
|
|
291
|
+
encoded_chars.append(alphabet[mod])
|
|
292
|
+
|
|
293
|
+
encoded_chars.reverse()
|
|
294
|
+
return "".join(encoded_chars)
|
cuid2-2.2.0/src/cuid2/utils.py
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
"""Cryptographic hashing, entropy, counter, fingerprint, and base36 helpers for CUID2."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
import socket
|
|
7
|
-
import string
|
|
8
|
-
import threading
|
|
9
|
-
from hashlib import sha3_512
|
|
10
|
-
from math import floor
|
|
11
|
-
from typing import TYPE_CHECKING, Final
|
|
12
|
-
|
|
13
|
-
# ~25.7k hosts before 50% chance of initial counter collision
|
|
14
|
-
# with a remaining counter range of 9.0e+15 in JavaScript.
|
|
15
|
-
INITIAL_COUNT_MAX: Final[int] = 476782367
|
|
16
|
-
MIN_LENGTH: Final[int] = 2
|
|
17
|
-
DEFAULT_LENGTH: Final[int] = 24
|
|
18
|
-
BIG_LENGTH: Final[int] = 32
|
|
19
|
-
MAXIMUM_LENGTH: Final[int] = 98
|
|
20
|
-
|
|
21
|
-
ASCII_LOWERCASE: Final[str] = string.ascii_lowercase
|
|
22
|
-
ASCII_LOWERCASE_LEN: Final[int] = len(ASCII_LOWERCASE)
|
|
23
|
-
BASE36_ALPHABET: Final[str] = string.digits + string.ascii_lowercase
|
|
24
|
-
BASE36_RADIX: Final[int] = len(BASE36_ALPHABET)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if TYPE_CHECKING:
|
|
28
|
-
from _random import Random
|
|
29
|
-
from collections.abc import Callable
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def create_counter(count: int) -> Callable[[], int]:
|
|
33
|
-
"""Create a thread-safe counter that returns an incremented value each time it is called.
|
|
34
|
-
|
|
35
|
-
Parameters
|
|
36
|
-
----------
|
|
37
|
-
count : int
|
|
38
|
-
Initial count value for the counter.
|
|
39
|
-
|
|
40
|
-
Returns
|
|
41
|
-
-------
|
|
42
|
-
Callable[[], int]
|
|
43
|
-
Closure function `counter` that takes no arguments and returns an integer. The closure function keeps track
|
|
44
|
-
of a count variable that is initialized with the value passed as an argument to `create_counter`. Each time
|
|
45
|
-
the closure function is called, it atomically increments the count variable under a lock and returns its
|
|
46
|
-
new value.
|
|
47
|
-
"""
|
|
48
|
-
lock = threading.RLock()
|
|
49
|
-
|
|
50
|
-
def counter() -> int:
|
|
51
|
-
nonlocal count
|
|
52
|
-
with lock:
|
|
53
|
-
count += 1
|
|
54
|
-
return count
|
|
55
|
-
|
|
56
|
-
return counter
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def create_fingerprint(random_generator: Random, fingerprint_data: str | None = "") -> str:
|
|
60
|
-
"""Create a host/process fingerprint combined with random entropy and hashed.
|
|
61
|
-
|
|
62
|
-
Parameters
|
|
63
|
-
----------
|
|
64
|
-
random_generator : "Random"
|
|
65
|
-
Used as the base generator to generate some entropy.
|
|
66
|
-
fingerprint_data : str, optional
|
|
67
|
-
An optional parameter that contains data to be included in the fingerprint.
|
|
68
|
-
If it is not provided, the function combines process ID, hostname, and environment variables
|
|
69
|
-
|
|
70
|
-
Returns
|
|
71
|
-
-------
|
|
72
|
-
str
|
|
73
|
-
The hashed value of the input `fingerprint_data` or default value concatenated with a randomly generated
|
|
74
|
-
entropy string.
|
|
75
|
-
The length of the returned string is trimmed to the constant `BIG_LENGTH` (32 characters).
|
|
76
|
-
"""
|
|
77
|
-
if not fingerprint_data:
|
|
78
|
-
process_id: int = os.getpid()
|
|
79
|
-
hostname: str = socket.gethostname()
|
|
80
|
-
env_variables: str = "".join(os.environ.keys())
|
|
81
|
-
|
|
82
|
-
fingerprint_data = str(process_id) + hostname + env_variables
|
|
83
|
-
|
|
84
|
-
fingerprint: str = str(fingerprint_data) + create_entropy(random_generator, BIG_LENGTH)
|
|
85
|
-
return create_hash(fingerprint)[0:BIG_LENGTH]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def create_entropy(random_generator: Random, length: int = 4) -> str:
|
|
89
|
-
"""Create a random string of specified length using base36 encoding.
|
|
90
|
-
|
|
91
|
-
Parameters
|
|
92
|
-
----------
|
|
93
|
-
random_generator : "Random"
|
|
94
|
-
Used as the base generator to generate a random string.
|
|
95
|
-
length : int, default=4
|
|
96
|
-
The length parameter is an integer that specifies the length of the entropy string to be generated.
|
|
97
|
-
The default value is 4, but it can be set to any positive integer value.
|
|
98
|
-
|
|
99
|
-
Returns
|
|
100
|
-
-------
|
|
101
|
-
str
|
|
102
|
-
Random characters encoded as a base36 string. The length of the string is determined by
|
|
103
|
-
the `length` parameter passed to the function.
|
|
104
|
-
|
|
105
|
-
Raises
|
|
106
|
-
------
|
|
107
|
-
ValueError
|
|
108
|
-
If the input integer is less than 1.
|
|
109
|
-
"""
|
|
110
|
-
if length < 1:
|
|
111
|
-
msg = "Cannot create entropy without a length >= 1."
|
|
112
|
-
raise ValueError(msg)
|
|
113
|
-
|
|
114
|
-
return "".join([BASE36_ALPHABET[floor(random_generator.random() * BASE36_RADIX)] for _ in range(length)])
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def create_hash(data: str = "") -> str:
|
|
118
|
-
"""Create a base36-encoded SHA3-512 hash of `data` with the leading character dropped.
|
|
119
|
-
|
|
120
|
-
Parameters
|
|
121
|
-
----------
|
|
122
|
-
data : str, default=""
|
|
123
|
-
Data to be hashed. It is an optional parameter with a default value of an empty string.
|
|
124
|
-
If no value is provided for `data`, an empty string will be hashed.
|
|
125
|
-
|
|
126
|
-
Returns
|
|
127
|
-
-------
|
|
128
|
-
str
|
|
129
|
-
Base36 encoding of the SHA3-512 hash of the input string `data`, with the first character dropped.
|
|
130
|
-
|
|
131
|
-
"""
|
|
132
|
-
hashed_value = sha3_512(data.encode())
|
|
133
|
-
hashed_int: int = int.from_bytes(hashed_value.digest(), byteorder="big")
|
|
134
|
-
|
|
135
|
-
# Drop the first character because it will bias the histogram to the left.
|
|
136
|
-
return base36_encode(hashed_int)[1:]
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def create_letter(random_generator: Random) -> str:
|
|
140
|
-
"""Generate a random lowercase ASCII letter using the given random number generator.
|
|
141
|
-
|
|
142
|
-
Parameters
|
|
143
|
-
----------
|
|
144
|
-
random_generator : "Random"
|
|
145
|
-
Used as the base generator to generate a random letter.
|
|
146
|
-
|
|
147
|
-
Returns
|
|
148
|
-
-------
|
|
149
|
-
str
|
|
150
|
-
a randomly generated lowercase letter from the English alphabet.
|
|
151
|
-
"""
|
|
152
|
-
return ASCII_LOWERCASE[floor(random_generator.random() * ASCII_LOWERCASE_LEN)]
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def base36_encode(number: int) -> str:
|
|
156
|
-
"""Encode a non-negative integer into a base36 string.
|
|
157
|
-
|
|
158
|
-
Parameters
|
|
159
|
-
----------
|
|
160
|
-
number : int
|
|
161
|
-
Integer to be encoded as a base36 string.
|
|
162
|
-
|
|
163
|
-
Returns
|
|
164
|
-
-------
|
|
165
|
-
str
|
|
166
|
-
A string that represents the base36 encoded input integer.
|
|
167
|
-
If the input integer is negative, a ValueError is raised.
|
|
168
|
-
If the input integer is 0, the function returns the string "0".
|
|
169
|
-
|
|
170
|
-
Raises
|
|
171
|
-
------
|
|
172
|
-
ValueError
|
|
173
|
-
If the input integer is negative.
|
|
174
|
-
"""
|
|
175
|
-
if number < 0:
|
|
176
|
-
msg = "Cannot encode negative integers."
|
|
177
|
-
raise ValueError(msg)
|
|
178
|
-
|
|
179
|
-
if number == 0:
|
|
180
|
-
return "0"
|
|
181
|
-
|
|
182
|
-
encoded_chars: list[str] = []
|
|
183
|
-
while number != 0:
|
|
184
|
-
number, mod = divmod(number, BASE36_RADIX)
|
|
185
|
-
encoded_chars.append(BASE36_ALPHABET[mod])
|
|
186
|
-
|
|
187
|
-
encoded_chars.reverse()
|
|
188
|
-
return "".join(encoded_chars)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|