cuid2 2.2.0__tar.gz → 2.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuid2
3
- Version: 2.2.0
3
+ Version: 2.2.1
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
@@ -45,7 +45,7 @@ by [Parallel Drive](https://github.com/paralleldrive) to Python 3.
45
45
 
46
46
  ## What is CUID2?
47
47
 
48
- * Secure: It's not feasible to guess the next ID (unguessability derives from the `secrets.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).
48
+ * 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
49
  * Collision resistant: It's extremely unlikely to generate the same ID twice.
50
50
  * Horizontally scalable: Generate IDs on multiple machines without coordination.
51
51
  * Offline-compatible: Generate IDs without a network connection.
@@ -69,7 +69,7 @@ For more information on the improvements of CUID2 over CUID, see the
69
69
  * **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
70
  * **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
71
  * **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 lock, RNG state, 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 `secrets.SystemRandom` (`os.urandom`) salt and `time.time_ns()` provide defense-in-depth against state collisions.
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 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
73
  * **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
74
 
75
75
  | Property | CUID2 (`cuid2`) | UUIDv7 (`uuid.uuid7`) | UUIDv4 (`uuid.uuid4`) | ULID (`python-ulid`) | NanoID (`nanoid`) |
@@ -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 `secrets.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).
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 lock, RNG state, 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 `secrets.SystemRandom` (`os.urandom`) salt and `time.time_ns()` provide defense-in-depth against state collisions.
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`) |
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "cuid2"
7
- version = "2.2.0"
7
+ version = "2.2.1"
8
8
  description = "Next generation GUIDs. Collision-resistant ids optimized for horizontal scaling and performance."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "cuid2"
7
- version = "2.2.0"
7
+ version = "2.2.1"
8
8
  authors = [
9
9
  {name = "Will Gordon", email = "dev@gordoncode.dev"},
10
10
  ]
@@ -15,9 +15,9 @@ from cuid2.utils import BIG_LENGTH, DEFAULT_LENGTH, MAXIMUM_LENGTH, MIN_LENGTH
15
15
  __all__ = ["main"]
16
16
 
17
17
  if TYPE_CHECKING:
18
- from _random import Random
19
18
  from argparse import Namespace
20
19
  from collections.abc import Iterable, Iterator, Sequence
20
+ from random import Random
21
21
 
22
22
  _SLUG_LENGTH: int = 5
23
23
 
@@ -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 math import floor
15
- from secrets import SystemRandom
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."""
@@ -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
87
  DeprecationWarning,
94
- stacklevel=stacklevel,
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 `secrets.SystemRandom`.
169
- Called again in a forked child process (see Notes).
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
@@ -192,22 +191,25 @@ class Cuid:
192
191
 
193
192
  Notes
194
193
  -----
195
- `generate()` may be called concurrently from multiple threads, including on free-threaded Python. Draws from
196
- the random generator and counter are serialized per instance by a re-entrant lock, so a signal handler or
197
- finalizer that generates an ID on the same thread does not deadlock.
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 lock, random generator, counter, and fingerprint once, by calling `random_generator`,
202
- `counter`, and `fingerprint` again before generating. If `random_generator` returns the same object as before
203
- the fork, its `seed()` method is called to reseed it from OS entropy; if it has no `seed()` method or `seed()`
204
- raises `NotImplementedError` or `TypeError`, that generator keeps the parent's stream, and IDs still differ by
205
- timestamp and, with the default `fingerprint`, by process ID. Fixed-seed factories (such as
206
- `functools.partial(random.Random, 42)`) give every process the same stream and are intended for deterministic
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 lock, random generator, counter, and fingerprint are built anew.
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
- counter=self._counter_factory(floor(random_generator.random() * INITIAL_COUNT_MAX)),
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
 
@@ -283,18 +293,28 @@ class Cuid:
283
293
  """
284
294
  if length is not None:
285
295
  _validate_length(length)
286
- resolved_length: int = self._length if length is None else length
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
- with state.lock:
290
- first_letter: str = utils.create_letter(random_generator=state.random)
291
- base36_count: str = utils.base36_encode(state.counter())
292
- salt: str = utils.create_entropy(length=resolved_length, random_generator=state.random)
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.create_hash(hash_input)[1:resolved_length]
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
 
@@ -328,6 +348,8 @@ def create_id(length: int | None = None) -> str:
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.generate(length=length)
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]:
@@ -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)
@@ -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
File without changes