Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"python.testing.pytestArgs": ["local"],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,49 @@
[![PyPi](https://img.shields.io/pypi/dm/cuid2.svg)](https://pypi.python.org/pypi/cuid2)
[![cuid2](https://snyk.io/advisor/python/cuid2/badge.svg)](https://snyk.io/advisor/python/cuid2)

CUID2 for Python 3. Next generation GUIDs. Collision-resistant ids optimized for
CUID2 for Python 3. Next generation GUIDs. Collision-resistant ids optimized for
horizontal scaling and performance.

A port of the [CUID2 reference implementation](https://github.com/paralleldrive/cuid2)
A port of the [CUID2 reference implementation](https://github.com/paralleldrive/cuid2)
by [Parallel Drive](https://github.com/paralleldrive) to Python 3.

> :memo: Note: Originally taken from https://github.com/overflowdigital before it
> :memo: Note: Originally taken from https://github.com/overflowdigital before it
> was unpublished. Thank you to @joshuathompsonlindley for your original contribution!

## What is CUID2?

* Secure: It's not possible to guess the next ID.
* Collision resistant: It's extremely unlikely to generate the same ID twice.
* Horizontally scalable: Generate IDs on multiple machines without coordination.
* Offline-compatible: Generate IDs without a network connection.
* URL and name-friendly: No special characters.
- Secure: It's not possible to guess the next ID.
- Collision resistant: It's extremely unlikely to generate the same ID twice.
- Horizontally scalable: Generate IDs on multiple machines without coordination.
- Offline-compatible: Generate IDs without a network connection.
- URL and name-friendly: No special characters.

## Why?

For more information on the theory and usage of CUID2, see the
For more information on the theory and usage of CUID2, see the
[following](https://github.com/paralleldrive/cuid2#why).

## Improvements Over CUID

For more information on the improvements of CUID2 over CUID, see the
For more information on the improvements of CUID2 over CUID, see the
[following](https://github.com/paralleldrive/cuid2#improvements-over-cuid).


## Install

```
pip install cuid2
```

## Usage

You can generate CUIDs directly in the terminal with the following:

```bash
$ cuid2
```

Or you can rely on a CUID wrapper if you don't need any customizations:

```python
from typing import Callable
from cuid2 import cuid_wrapper
Expand All @@ -58,6 +60,7 @@ def main():
```

Finally, for more explicit control of the CUID generator, you can instantiate the class directly:

```python
from cuid2 import Cuid

Expand All @@ -66,4 +69,7 @@ CUID_GENERATOR: Cuid = Cuid(length=10)
def main():
my_cuid: str = CUID_GENERATOR.generate()
next_cuid: str = CUID_GENERATOR.generate()

# Validate CUID
print(Cuid.is_cuid(my_cuid)) # True
```
62 changes: 56 additions & 6 deletions local/tests/test_cuid.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ def test_constructor_default_values(self: "TestCuid") -> None:
assert isinstance(cuid._fingerprint, str)

# Tests that the random_generator and fingerprint functions are called correctly when mocked.
def test_mock_random_generator_and_fingerprint(self: "TestCuid", mocker: "Mock") -> None:
def test_mock_random_generator_and_fingerprint(
self: "TestCuid", mocker: "Mock"
) -> None:
mock_random_class = mocker.Mock()
mock_random = mocker.Mock()
mock_random.random.return_value = 0.5
Expand All @@ -109,7 +111,9 @@ def test_mock_random_generator_and_fingerprint(self: "TestCuid", mocker: "Mock")

# Generate a Cuid and assert that the mocked functions were called correctly
cuid_str = cuid.generate()
assert cuid_str == "locked_hash" # first letter is "l" from mocked create_letter function
assert (
cuid_str == "locked_hash"
) # first letter is "l" from mocked create_letter function
assert cuid._fingerprint == "mocked_fingerprint"
assert (
cuid._counter() == 238391185
Expand All @@ -132,11 +136,17 @@ def test_constructor_initializes_members(self: "TestCuid", mocker: "Mock") -> No
mock_fingerprint = mocker.Mock()

# Act
cuid = Cuid(random_generator=mock_random_class, counter=mock_counter, fingerprint=mock_fingerprint)
cuid = Cuid(
random_generator=mock_random_class,
counter=mock_counter,
fingerprint=mock_fingerprint,
)

# Assert
assert cuid._random == mock_random_class()
assert cuid._counter == mock_counter(floor(cuid._random.random() * INITIAL_COUNT_MAX))
assert cuid._counter == mock_counter(
floor(cuid._random.random() * INITIAL_COUNT_MAX)
)
assert cuid._length == DEFAULT_LENGTH
assert cuid._fingerprint == mock_fingerprint(random_generator=cuid._random)

Expand All @@ -150,10 +160,16 @@ def test_generate_uses_base36_encoding(self: "TestCuid", mocker: "Mock") -> None
mock_counter = mocker.Mock()
mock_counter.return_value = lambda: 123456789
mock_fingerprint = mocker.Mock(return_value="abcdefg")
mocker.patch("time.time_ns", return_value=1627584000000000000) # 2021-07-30 00:00:00 UTC
mocker.patch(
"time.time_ns", return_value=1627584000000000000
) # 2021-07-30 00:00:00 UTC
mocker.patch("cuid2.utils.create_letter", return_value="l")

cuid = Cuid(random_generator=mock_random_class, counter=mock_counter, fingerprint=mock_fingerprint)
cuid = Cuid(
random_generator=mock_random_class,
counter=mock_counter,
fingerprint=mock_fingerprint,
)

# Act
result = cuid.generate()
Expand All @@ -163,6 +179,40 @@ def test_generate_uses_base36_encoding(self: "TestCuid", mocker: "Mock") -> None
assert len(result) == DEFAULT_LENGTH
assert result == "l9j3ikop1bi8tcvzme3x3yv7"

def test_is_cuid_invalid(self: "TestCuid") -> None:
"""Tests that is_cuid returns False for invalid cuid cuid and False for invalid cuid."""
# Arrange
cuid = Cuid()
valid_cuid = cuid.generate()

# Act and Assert
assert cuid.is_cuid("") == False
assert cuid.is_cuid("1") == False
assert cuid.is_cuid("c") == False
assert cuid.is_cuid(valid_cuid + " ") == False
assert cuid.is_cuid(valid_cuid.upper()) == False
assert (
cuid.is_cuid("cjld2cjxh0000qzrmn831i7rn!") == False
) # Hard coded test - almost correct but not quite

# Test min and max lengths with *valid* CUID2 entry (but invalid required length)
assert cuid.is_cuid("dlwvlnik2o0lh47uh4kg6q8j", max_length=5) == False
assert cuid.is_cuid("dlwvlnik2o0lh47uh4kg6q8j", min_length=25) == False

def test_is_cuid_valid(self: "TestCuid") -> None:
# Arrange
cuid = Cuid()
valid_cuid = cuid.generate()

assert cuid.is_cuid(valid_cuid) == True
assert (
cuid.is_cuid("cjld2cjxh0000qzrmn831i7r") == True
) # Hard coded test - correct

# Test min / max length with a valid CUID2 string
assert cuid.is_cuid("dlwvlnik2o0lh47uh4kg6q8j", max_length=25) == True
assert cuid.is_cuid("dlwvlnik2o0lh47uh4kg6q8j", min_length=20) == True


class TestCuidWrapper:
def test_cuid_wrapper_is_callable(self: "TestCuidWrapper") -> None:
Expand Down
41 changes: 40 additions & 1 deletion src/cuid2/generator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import time
import re
from math import floor
from secrets import SystemRandom
from typing import TYPE_CHECKING, Callable, Final, Optional, Protocol
Expand Down Expand Up @@ -57,7 +58,9 @@ def __init__(
raise ValueError(msg)

self._random: Random = random_generator()
self._counter: Callable[[], int] = counter(floor(self._random.random() * INITIAL_COUNT_MAX))
self._counter: Callable[[], int] = counter(
floor(self._random.random() * INITIAL_COUNT_MAX)
)
self._length: int = length
self._fingerprint: str = fingerprint(random_generator=self._random)

Expand Down Expand Up @@ -98,6 +101,42 @@ def generate(self: Cuid, length: Optional[int] = None) -> str:

return first_letter + utils.create_hash(hash_input)[1 : length or self._length]

@staticmethod
def is_cuid(id: str, min_length: int = 2, max_length: Optional[int] = None) -> bool:
"""
Check if the given id is a CUID.
Port on 2025-02-12 from: https://github.com/paralleldrive/cuid2/blob/main/src/index.js

:param id: The id to check.
:param min_length: The minimum length of the id.
:param max_length: The maximum length of the id.
:return: True if the id is a CUID, False otherwise.

# Example usage
# print(is_cuid("1")) # False
# print(is_cuid("cjld2cjxh0000qzrmn831i7rn")) # True
"""
if max_length is None:
max_length = float(
"inf"
) # Set max_length to a very large number if not provided

length = len(id)
regex = re.compile(r"^[a-z][0-9a-z]+$")

try:
if (
isinstance(id, str)
and length >= min_length
and length <= max_length
and regex.match(id)
):
return True
finally:
pass

return False


def cuid_wrapper() -> Callable[[], str]:
"""Wrap a single Cuid class instance and return a callable that generates a CUID string.
Expand Down