diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..88361b7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.testing.pytestArgs": ["local"], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} diff --git a/README.md b/README.md index 72ee125..d3c54bd 100644 --- a/README.md +++ b/README.md @@ -5,35 +5,35 @@ [![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 ``` @@ -41,11 +41,13 @@ 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 @@ -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 @@ -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 ``` diff --git a/local/tests/test_cuid.py b/local/tests/test_cuid.py index 83de65a..401743a 100644 --- a/local/tests/test_cuid.py +++ b/local/tests/test_cuid.py @@ -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 @@ -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 @@ -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) @@ -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() @@ -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: diff --git a/src/cuid2/generator.py b/src/cuid2/generator.py index 1b8a442..6eadf08 100644 --- a/src/cuid2/generator.py +++ b/src/cuid2/generator.py @@ -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 @@ -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) @@ -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.