diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0ac935a..1e989ec 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,9 @@ jobs: with: python-version: "3.11" + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Install uv uses: astral-sh/setup-uv@v3 @@ -22,6 +25,13 @@ jobs: run: | uv sync --locked --all-groups + - name: Build & install native extension (maturin develop) + uses: PyO3/maturin-action@v1 + with: + command: develop + args: --release + manylinux: "2_28" + - name: Run linters run: | make check-linting @@ -37,6 +47,9 @@ jobs: with: python-version: "3.11" + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Install uv uses: astral-sh/setup-uv@v3 @@ -44,10 +57,21 @@ jobs: run: | uv sync --locked --all-groups + - name: Build & install native extension (maturin develop) + uses: PyO3/maturin-action@v1 + with: + command: develop + args: --release + manylinux: "2_28" + + - name: Sanity check (import Rust extension) + run: | + uv run python -c "import typeid; import typeid._base32; print('Rust extension OK')" + - name: Run tests run: | make test - + - name: Run doc tests run: | make test-docs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 94cd41a..2c1bd8c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,14 +8,83 @@ permissions: contents: write id-token: write +env: + PROJECT_NAME: typeid-python + jobs: test: - uses: ./.github/workflows/test.yml + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 9 + submodules: false + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Sync dependencies (locked) + run: | + uv sync --locked --all-groups + + - name: Run tests + run: | + make test + + - name: Run linters + run: | + make check-linting + + test-native: + name: Build + import native extension (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + needs: + - test + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + # Build & install the package in-place with the extension + - name: Build & install (maturin develop) + uses: PyO3/maturin-action@v1 + with: + command: develop + args: --release + + - name: Sanity check (import extension) + run: | + python -c "import typeid; import typeid._base32; print('native ext OK')" + build-wheels: - name: Build wheels (${{ matrix.os }}) + name: Build wheels (${{ matrix.os }}, py${{ matrix.python-version }}) runs-on: ${{ matrix.os }} - needs: test + needs: test-native strategy: fail-fast: false matrix: @@ -36,10 +105,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Install uv - uses: astral-sh/setup-uv@v3 - - - name: Build wheels (Rust-enabled) + # Build typeid-python wheel (includes rust extension) + - name: Build wheels (maturin) uses: PyO3/maturin-action@v1 with: command: build @@ -51,7 +118,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: wheels-${{ matrix.os }}-py${{ matrix.python-version }} - path: rust-base32/dist/*.whl + path: dist/*.whl build-sdist: runs-on: ubuntu-latest @@ -67,49 +134,25 @@ jobs: with: python-version: "3.11" - - name: Install uv - uses: astral-sh/setup-uv@v3 - - - name: Sync deps (including build tools) - run: | - uv sync --all-extras --dev - - - name: Build sdist - run: | - make build-sdist + - name: Install Rust + uses: dtolnay/rust-toolchain@stable - - name: Upload sdist - uses: actions/upload-artifact@v4 - with: - name: sdist - path: dist/*.tar.gz - - build-rust-sdist: - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - uses: dtolnay/rust-toolchain@stable - - name: Build rust sdist + # Build typeid-python sdist (maturin) + - name: Build sdist (maturin) uses: PyO3/maturin-action@v1 with: command: sdist args: --out dist - working-directory: rust-base32 - - uses: actions/upload-artifact@v4 + - name: Upload sdist + uses: actions/upload-artifact@v4 with: - name: rust-sdist - path: rust-base32/dist/*.tar.gz + name: sdist + path: dist/*.tar.gz publish-package: runs-on: ubuntu-latest - needs: - - build-sdist - - build-rust-sdist + needs: build-sdist permissions: contents: read id-token: write @@ -149,4 +192,4 @@ jobs: uv sync --locked --all-groups - name: Deploy to Pages - run: uv run mkdocs gh-deploy --force \ No newline at end of file + run: uv run mkdocs gh-deploy --force diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 784fcba..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Run Tests - -on: - workflow_call: - - push: - branches: [main] - paths-ignore: - - '**.md' - -env: - PROJECT_NAME: typeid-python - -jobs: - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 9 - submodules: false - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install uv - uses: astral-sh/setup-uv@v3 - - - name: Sync dependencies (locked) - run: | - uv sync --locked --all-groups - - - name: Run tests - run: | - make test - - - name: Run linters - run: | - make check-linting - - test-rust-accel: - runs-on: ${{ matrix.os }} - needs: - - test - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.11"] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install uv - uses: astral-sh/setup-uv@v3 - - - name: Sync deps (locked, no extras) - run: | - uv sync --locked --all-groups - - - name: Build & install Rust extension (maturin develop) - uses: PyO3/maturin-action@v1 - with: - command: develop - args: --release - working-directory: rust-base32 - - # Install the remaining deps of the rust extra WITHOUT trying to fetch typeid-base32 - - name: Install rust extra deps (without fetching typeid-base32) - shell: bash - run: | - uv pip install "uuid-utils>=0.12.0" - uv pip install -e ".[rust]" --no-deps - - - name: Sanity check (import extension) - run: | - uv run python -c "import typeid_base32; print('typeid_base32 OK')" diff --git a/README.md b/README.md index 41d406d..6d52f35 100644 --- a/README.md +++ b/README.md @@ -56,25 +56,17 @@ See [`Docs: Performance`](https://akhundmurad.github.io/typeid-python/performanc ## Installation -### Core (pure Python) +### Core ```console $ pip install typeid-python ``` -### With Rust acceleration (recommended) - -```console -$ pip install typeid-python[rust] -``` - -This enables: +Included: * Rust base32 encode/decode * `uuid-utils` for fast UUIDv7 generation -If Rust is unavailable, TypeID automatically falls back to the pure-Python implementation. - ### Other optional extras ```console @@ -111,7 +103,7 @@ assert tid.prefix == "user" ```python from typeid import TypeID -from uuid6 import uuid7 +from uuid_utils import uuid7 u = uuid7() tid = TypeID.from_uuid(prefix="user", suffix=u) diff --git a/bench/README.md b/bench/README.md index 3758842..d7ad9e8 100644 --- a/bench/README.md +++ b/bench/README.md @@ -17,9 +17,7 @@ Benchmarks were run with: - OS: macOS / Linux - CPU: Apple Silicon / x86_64 - Tooling: `pytest-benchmark` -- UUID backends: - - `uuid-utils` (Rust, optional) - - `uuid6` (Python, legacy comparison) +- UUID backend: `uuid-utils` (Rust) Exact environment details are embedded in each JSON result file. @@ -64,7 +62,7 @@ These files are the **source of truth**. ## Comparison summary (mean time, µs) -| Benchmark | 0001 – Before Rust | 0002 – Rust + optimizations | Speedup (0004 vs 0001) | +| Benchmark | 0001 – Before Rust | 0002 – Rust + optimizations | Speedup (0002 vs 0001) | | ------------------- | -----------------: | --------------------------: | ---------------------: | | **TypeID generate** | 3.467 µs | **0.701 µs** | **4.94× faster** | | **TypeID parse** | 2.076 µs | **1.296 µs** | **1.60× faster** | @@ -88,24 +86,6 @@ These files are the **source of truth**. Result: parse and workflow became faster than the original Python baseline. -## UUID backend comparison (context) - -These numbers represent the approximate lower bound: - -| Operation | uuid-utils | uuid6 | -| --------- | ---------: | -------: | -| Generate | ~0.08 µs | ~1.51 µs | -| Parse | ~0.12 µs | ~0.53 µs | - -TypeID adds overhead for: - -* base32 encoding -* prefix handling -* validation -* safety guarantees - -This overhead is now reduced to approximately **1–2 µs**, depending on the operation. - ## Cold path vs warm path All benchmarks measure cold-path performance: each iteration operates on a new identifier. diff --git a/bench/benchmarks/test_bench_generate.py b/bench/benchmarks/test_bench_generate.py index 8f02766..66c06ed 100644 --- a/bench/benchmarks/test_bench_generate.py +++ b/bench/benchmarks/test_bench_generate.py @@ -1,5 +1,5 @@ from typeid import TypeID -import uuid6 +import uuid import uuid_utils @@ -7,8 +7,8 @@ def test_typeid_generate(benchmark): benchmark(TypeID, "user") -def test_uuid6_generate(benchmark): - benchmark(uuid6.uuid7) +def test_uuid4_generate(benchmark): + benchmark(uuid.uuid4) def test_uuid_utils_generate(benchmark): diff --git a/bench/benchmarks/test_bench_parse.py b/bench/benchmarks/test_bench_parse.py index adaafec..3aa60bd 100644 --- a/bench/benchmarks/test_bench_parse.py +++ b/bench/benchmarks/test_bench_parse.py @@ -1,10 +1,10 @@ from typeid import TypeID -import uuid6 +import uuid import uuid_utils TYPEID_STR = str(TypeID("user")) -UUID_STR = str(uuid6.uuid7()) +UUID_STR = str(uuid_utils.uuid7()) def test_typeid_parse(benchmark): @@ -16,9 +16,5 @@ def test_typeid_parse_reuse(benchmark): benchmark(lambda: TypeID.from_string(s)) -def test_uuid6_parse(benchmark): - benchmark(uuid6.UUID, UUID_STR) - - def test_uuid_utils_parse(benchmark): benchmark(uuid_utils.UUID, UUID_STR) diff --git a/docs/quickstart.md b/docs/quickstart.md index 6dfbd7e..2643e17 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -10,18 +10,12 @@ It is intentionally short. The goal is to get you productive quickly, not to exp Install the package using your preferred tool. -- Standard installation (uses `uuid6` library): +- Standard installation: ```console $ pip install typeid-python ``` -- Rust acceleration (`uuid-utils` + Rust base32 encode/decode): - - ```console - $ pip install "typeid-python[rust]" - ``` - - YAML schemas support: ```console @@ -125,14 +119,13 @@ tid.uuid ``` > **_NOTE:_** -> The exact Python type returned by `tid.uuid` depends on the available backend. > For time-related information, prefer `typeid explain` or derived properties (`.created_at`) -> over backend-specific UUID attributes. +> over specific UUID attributes. And you can always reconstruct a TypeID from a UUID: ```python -from uuid6 import uuid7 +from uuid_utils import uuid7 from typeid import TypeID u = uuid7() diff --git a/pyproject.toml b/pyproject.toml index 10b775b..df57f67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,9 @@ license = "MIT" keywords = [ "typeid", "uuid", - "uuid6", + "rust", "guid", + "uuid7", ] classifiers = [ "Development Status :: 3 - Alpha", @@ -22,12 +23,11 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", ] -dependencies = ["uuid6>=2024.7.10,<2026.0.0"] +dependencies = ["uuid-utils>=0.12.0"] [project.optional-dependencies] cli = ["click"] yaml = ["PyYAML"] -rust = ["uuid-utils>=0.12.0", "typeid-base32>=0.3.5,<0.4.0"] [project.urls] Homepage = "https://github.com/akhundMurad/typeid-python" @@ -57,12 +57,12 @@ dev = [ "maturin>=1.5; platform_system != 'Windows'" ] -[tool.hatch.build.targets.sdist] -include = ["typeid"] - -[tool.hatch.build.targets.wheel] -include = ["typeid"] - [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["maturin>=1.5"] +build-backend = "maturin" + +[tool.maturin] +python-source = "." +manifest-path = "rust-base32/Cargo.toml" +module-name = "typeid._base32" +features = ["pyo3/extension-module"] diff --git a/rust-base32/pyproject.toml b/rust-base32/pyproject.toml deleted file mode 100644 index 2404d8e..0000000 --- a/rust-base32/pyproject.toml +++ /dev/null @@ -1,20 +0,0 @@ -[build-system] -requires = ["maturin>=1.5"] -build-backend = "maturin" - -[project] -name = "typeid-base32" -version = "0.3.6" -requires-python = ">=3.10" -description = "Rust-accelerated base32 codec for typeid-python" -license = { text = "MIT" } -classifiers = [ - "Programming Language :: Python", - "Programming Language :: Rust", - "Operating System :: OS Independent", -] - -[tool.maturin] -# This is the *import/module name* in Python: `import typeid_base32` -module-name = "typeid_base32" -features = ["pyo3/extension-module"] diff --git a/rust-base32/src/lib.rs b/rust-base32/src/lib.rs index 975ddd0..287c704 100644 --- a/rust-base32/src/lib.rs +++ b/rust-base32/src/lib.rs @@ -107,7 +107,7 @@ fn decode(s: &str) -> PyResult> { } #[pymodule] -fn typeid_base32(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { +fn _base32(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(encode, m)?)?; m.add_function(wrap_pyfunction!(decode, m)?)?; Ok(()) diff --git a/tests/test_base32.py b/tests/test_base32.py index 47b7908..4b4cb36 100644 --- a/tests/test_base32.py +++ b/tests/test_base32.py @@ -1,8 +1,3 @@ -import builtins -import importlib -import sys - -import pytest from typeid.base32 import decode, encode @@ -18,40 +13,3 @@ def test_encode_decode_logic() -> None: decoded_data = decode(encoded_data) assert decoded_data == original_data - - -def _reload_base32(): - sys.modules.pop("typeid.base32", None) - return importlib.import_module("typeid.base32") - - -def test_uses_rust_if_available(): - """ - If the native module is importable, typeid.base32 should pick Rust backend. - If native module is not installed in this environment, skip (can't force it). - """ - try: - import typeid_base32 # noqa: F401 - except Exception: - pytest.skip("Rust extension typeid_base32 not installed in this environment") - - base32 = _reload_base32() - assert base32._HAS_RUST - - -def test_falls_back_to_python_when_rust_missing(monkeypatch): - """ - Force ImportError for typeid_base32 and re-import typeid.base32. - This must select Python backend. - """ - real_import = builtins.__import__ - - def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): - if name == "typeid_base32": - raise ImportError("blocked by test") - return real_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr(builtins, "__import__", blocked_import) - - base32 = _reload_base32() - assert not base32._HAS_RUST diff --git a/tests/test_spec.py b/tests/test_spec.py index f009344..49d3a7a 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -1,5 +1,5 @@ import pytest -from uuid6 import UUID +from uuid_utils import UUID from typeid import TypeID from typeid.errors import TypeIDException diff --git a/tests/test_typeid.py b/tests/test_typeid.py index 25cf011..835cbbb 100644 --- a/tests/test_typeid.py +++ b/tests/test_typeid.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from uuid import UUID import pytest -import uuid6 +import uuid_utils from typeid import TypeID from typeid.errors import SuffixValidationException @@ -104,7 +104,7 @@ def test_construct_type_from_invalid_string() -> None: def test_construct_type_from_uuid() -> None: - uuid = uuid6.uuid7() + uuid = uuid_utils.uuid7() typeid = TypeID.from_uuid(suffix=uuid, prefix="") @@ -114,7 +114,7 @@ def test_construct_type_from_uuid() -> None: def test_construct_type_from_uuid_with_prefix() -> None: - uuid = uuid6.uuid7() + uuid = uuid_utils.uuid7() prefix = "prefix" typeid = TypeID.from_uuid(prefix=prefix, suffix=uuid) @@ -137,7 +137,7 @@ def test_hash_type_id() -> None: def test_uuid_property() -> None: - uuid = uuid6.uuid7() + uuid = uuid_utils.uuid7() typeid = TypeID.from_uuid(suffix=uuid) print(type(typeid.uuid)) diff --git a/tests/test_uuid_backend.py b/tests/test_uuid_backend.py deleted file mode 100644 index 713b633..0000000 --- a/tests/test_uuid_backend.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest - -from typeid._uuid_backend import get_uuid_backend - - -@pytest.mark.parametrize( - "value,expected", - [ - ("uuid6", "uuid6"), - ("uuid-utils", "uuid-utils"), - ], -) -def test_backend_forced(monkeypatch, value, expected): - try: - import uuid_utils # noqa: F401 - except Exception: - pytest.skip("Rust extension uuid_utils not installed in this environment") - - monkeypatch.setenv("TYPEID_UUID_BACKEND", value) - backend = get_uuid_backend() - assert backend.name == expected - - -def test_backend_invalid_value(monkeypatch): - monkeypatch.setenv("TYPEID_UUID_BACKEND", "nope") - with pytest.raises(RuntimeError): - get_uuid_backend() diff --git a/tests/test_validation.py b/tests/test_validation.py index 9260d59..cf71aa6 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,5 +1,5 @@ import pytest -from uuid6 import uuid7 +from uuid_utils import uuid7 from typeid import base32 from typeid.errors import PrefixValidationException, SuffixValidationException diff --git a/typeid/_uuid_backend.py b/typeid/_uuid_backend.py deleted file mode 100644 index e6cfc30..0000000 --- a/typeid/_uuid_backend.py +++ /dev/null @@ -1,62 +0,0 @@ -import os -import uuid as std_uuid -from dataclasses import dataclass -from typing import Callable, Literal, Type - - -BackendName = Literal["uuid-utils", "uuid6"] - - -@dataclass(frozen=True) -class UUIDBackend: - name: BackendName - uuid7: Callable[[], std_uuid.UUID] - UUID: Type[std_uuid.UUID] - - -def _load_uuid_utils() -> UUIDBackend: - import uuid_utils as uuid # type: ignore - - return UUIDBackend(name="uuid-utils", uuid7=uuid.uuid7, UUID=uuid.UUID) # type: ignore - - -def _load_uuid6() -> UUIDBackend: - import uuid6 # type: ignore - - return UUIDBackend(name="uuid6", uuid7=uuid6.uuid7, UUID=uuid6.UUID) # type: ignore - - -def get_uuid_backend() -> UUIDBackend: - """ - Select UUIDv7 backend. - - Selection order: - 1) If TYPEID_UUID_BACKEND is set, force that backend (or fail with a clear error). - 2) Otherwise prefer uuid-utils if installed, else fallback to uuid6. - - Allowed values: - TYPEID_UUID_BACKEND=uuid-utils|uuid6 - """ - forced = os.getenv("TYPEID_UUID_BACKEND") - if forced: - forced = forced.strip() - if forced not in ("uuid-utils", "uuid6"): - raise RuntimeError(f"Invalid TYPEID_UUID_BACKEND={forced!r}. " "Allowed values: 'uuid-utils' or 'uuid6'.") - try: - return _load_uuid_utils() if forced == "uuid-utils" else _load_uuid6() - except Exception as e: - raise RuntimeError( - f"TYPEID_UUID_BACKEND is set to {forced!r}, but that backend " - "is not available. Install the required dependency." - ) from e - - # Auto mode - try: - return _load_uuid_utils() - except Exception: - pass - - try: - return _load_uuid6() - except Exception as e: - raise RuntimeError("No UUIDv7 backend available. Install one of: uuid-utils (recommended) or uuid6.") from e diff --git a/typeid/base32.py b/typeid/base32.py index f032224..94a7061 100644 --- a/typeid/base32.py +++ b/typeid/base32.py @@ -1,237 +1,9 @@ -try: - from typeid_base32 import encode as _encode_rust, decode as _decode_rust # type: ignore - - _HAS_RUST = True -except Exception: - _HAS_RUST = False - _encode_rust = None - _decode_rust = None - -from typing import Union -from typeid.constants import SUFFIX_LEN - -ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" - -# TABLE maps ASCII byte -> 0..31 or 0xFF if invalid -TABLE = [ - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0x00, - 0x01, - 0x02, - 0x03, - 0x04, - 0x05, - 0x06, - 0x07, - 0x08, - 0x09, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0x0A, - 0x0B, - 0x0C, - 0x0D, - 0x0E, - 0x0F, - 0x10, - 0x11, - 0xFF, - 0x12, - 0x13, - 0xFF, - 0x14, - 0x15, - 0xFF, - 0x16, - 0x17, - 0x18, - 0x19, - 0x1A, - 0xFF, - 0x1B, - 0x1C, - 0x1D, - 0x1E, - 0x1F, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0x0A, - 0x0B, - 0x0C, - 0x0D, - 0x0E, - 0x0F, - 0x10, - 0x11, - 0xFF, - 0x12, - 0x13, - 0xFF, - 0x14, - 0x15, - 0xFF, - 0x16, - 0x17, - 0x18, - 0x19, - 0x1A, - 0xFF, - 0x1B, - 0x1C, - 0x1D, - 0x1E, - 0x1F, - 0xFF, - 0xFF, - 0xFF, - 0xFF, - 0xFF, -] + [0xFF] * (256 - 128) - - -BytesLike = Union[bytes, bytearray, memoryview] - - -def _encode_py(src: BytesLike) -> str: - mv = memoryview(src) - if mv.nbytes != 16: - raise RuntimeError("Invalid length.") - - # Pre-allocate output chars - dst = [""] * SUFFIX_LEN - - # Timestamp (6 bytes => 10 chars) - dst[0] = ALPHABET[(mv[0] & 0b11100000) >> 5] - dst[1] = ALPHABET[mv[0] & 0b00011111] - dst[2] = ALPHABET[(mv[1] & 0b11111000) >> 3] - dst[3] = ALPHABET[((mv[1] & 0b00000111) << 2) | ((mv[2] & 0b11000000) >> 6)] - dst[4] = ALPHABET[(mv[2] & 0b00111110) >> 1] - dst[5] = ALPHABET[((mv[2] & 0b00000001) << 4) | ((mv[3] & 0b11110000) >> 4)] - dst[6] = ALPHABET[((mv[3] & 0b00001111) << 1) | ((mv[4] & 0b10000000) >> 7)] - dst[7] = ALPHABET[(mv[4] & 0b01111100) >> 2] - dst[8] = ALPHABET[((mv[4] & 0b00000011) << 3) | ((mv[5] & 0b11100000) >> 5)] - dst[9] = ALPHABET[mv[5] & 0b00011111] - - # Entropy (10 bytes => 16 chars) - dst[10] = ALPHABET[(mv[6] & 0b11111000) >> 3] - dst[11] = ALPHABET[((mv[6] & 0b00000111) << 2) | ((mv[7] & 0b11000000) >> 6)] - dst[12] = ALPHABET[(mv[7] & 0b00111110) >> 1] - dst[13] = ALPHABET[((mv[7] & 0b00000001) << 4) | ((mv[8] & 0b11110000) >> 4)] - dst[14] = ALPHABET[((mv[8] & 0b00001111) << 1) | ((mv[9] & 0b10000000) >> 7)] - dst[15] = ALPHABET[(mv[9] & 0b01111100) >> 2] - dst[16] = ALPHABET[((mv[9] & 0b00000011) << 3) | ((mv[10] & 0b11100000) >> 5)] - dst[17] = ALPHABET[mv[10] & 0b00011111] - dst[18] = ALPHABET[(mv[11] & 0b11111000) >> 3] - dst[19] = ALPHABET[((mv[11] & 0b00000111) << 2) | ((mv[12] & 0b11000000) >> 6)] - dst[20] = ALPHABET[(mv[12] & 0b00111110) >> 1] - dst[21] = ALPHABET[((mv[12] & 0b00000001) << 4) | ((mv[13] & 0b11110000) >> 4)] - dst[22] = ALPHABET[((mv[13] & 0b00001111) << 1) | ((mv[14] & 0b10000000) >> 7)] - dst[23] = ALPHABET[(mv[14] & 0b01111100) >> 2] - dst[24] = ALPHABET[((mv[14] & 0b00000011) << 3) | ((mv[15] & 0b11100000) >> 5)] - dst[25] = ALPHABET[mv[15] & 0b00011111] - - return "".join(dst) - - -def _decode_py(s: str) -> bytes: - if len(s) != SUFFIX_LEN: - raise RuntimeError("Invalid length.") - - v = s.encode("utf-8") - tbl = TABLE - - for b in v: - if tbl[b] == 0xFF: - raise RuntimeError("Invalid base32 character") - - out = bytearray(16) - - # 6 bytes timestamp (48 bits) - out[0] = (tbl[v[0]] << 5) | tbl[v[1]] - out[1] = (tbl[v[2]] << 3) | (tbl[v[3]] >> 2) - out[2] = ((tbl[v[3]] & 3) << 6) | (tbl[v[4]] << 1) | (tbl[v[5]] >> 4) - out[3] = ((tbl[v[5]] & 15) << 4) | (tbl[v[6]] >> 1) - out[4] = ((tbl[v[6]] & 1) << 7) | (tbl[v[7]] << 2) | (tbl[v[8]] >> 3) - out[5] = ((tbl[v[8]] & 7) << 5) | tbl[v[9]] - - # 10 bytes entropy (80 bits) - out[6] = (tbl[v[10]] << 3) | (tbl[v[11]] >> 2) - out[7] = ((tbl[v[11]] & 3) << 6) | (tbl[v[12]] << 1) | (tbl[v[13]] >> 4) - out[8] = ((tbl[v[13]] & 15) << 4) | (tbl[v[14]] >> 1) - out[9] = ((tbl[v[14]] & 1) << 7) | (tbl[v[15]] << 2) | (tbl[v[16]] >> 3) - out[10] = ((tbl[v[16]] & 7) << 5) | tbl[v[17]] - out[11] = (tbl[v[18]] << 3) | (tbl[v[19]] >> 2) - out[12] = ((tbl[v[19]] & 3) << 6) | (tbl[v[20]] << 1) | (tbl[v[21]] >> 4) - out[13] = ((tbl[v[21]] & 15) << 4) | (tbl[v[22]] >> 1) - out[14] = ((tbl[v[22]] & 1) << 7) | (tbl[v[23]] << 2) | (tbl[v[24]] >> 3) - out[15] = ((tbl[v[24]] & 7) << 5) | tbl[v[25]] - - return bytes(out) +from typeid._base32 import encode as _encode_rust, decode as _decode_rust # type: ignore def encode(src: bytes) -> str: - if _HAS_RUST: - return _encode_rust(src) - return _encode_py(src) + return _encode_rust(src) def decode(s: str) -> bytes: - if _HAS_RUST: - return _decode_rust(s) - return _decode_py(s) + return _decode_rust(s) diff --git a/typeid/cli.py b/typeid/cli.py index 8706d3b..b6c9707 100644 --- a/typeid/cli.py +++ b/typeid/cli.py @@ -2,7 +2,7 @@ from typing import Optional import click -from uuid6 import UUID +from uuid_utils import UUID from typeid import TypeID, base32, from_uuid, get_prefix_and_suffix from typeid.explain.discovery import discover_schema_path diff --git a/typeid/constants.py b/typeid/constants.py index 150f4df..af81b48 100644 --- a/typeid/constants.py +++ b/typeid/constants.py @@ -1,3 +1,5 @@ SUFFIX_LEN = 26 PREFIX_MAX_LEN = 63 + +ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" diff --git a/typeid/explain/engine.py b/typeid/explain/engine.py index b3dd6e6..98757af 100644 --- a/typeid/explain/engine.py +++ b/typeid/explain/engine.py @@ -68,7 +68,7 @@ def explain( if enable_schema and schema_lookup is not None and parsed.prefix: try: schema = schema_lookup(parsed.prefix) - except Exception as e: # never let schema backend break explain + except Exception as e: exp.warnings.append(f"Schema lookup failed: {e!s}") schema = None @@ -128,7 +128,7 @@ def _parse_typeid(id_str: str) -> ParsedTypeID: ) # Derived facts from the validated TypeID - uuid_obj = tid.uuid # library returns a UUID object (uuid6.UUID) + uuid_obj = tid.uuid # library returns a UUID object uuid_str = str(uuid_obj) ver = _uuid_version(uuid_obj) @@ -173,7 +173,6 @@ def _uuid7_created_at(uuid_obj: Any) -> Optional[datetime]: UTC datetime or None if extraction fails. """ try: - # uuid_obj is likely uuid6.UUID, but supports .int like uuid.UUID u_int = int(uuid_obj.int) unix_ms = u_int >> 80 unix_s = unix_ms / 1000.0 @@ -251,7 +250,6 @@ def _apply_derived_provenance(exp: Explanation) -> None: def _uuid_version(u: Any) -> Optional[int]: try: - # uuid.UUID and uuid6.UUID both usually expose .version return int(u.version) except Exception: return None diff --git a/typeid/explain/model.py b/typeid/explain/model.py index e5cdb90..8295028 100644 --- a/typeid/explain/model.py +++ b/typeid/explain/model.py @@ -50,7 +50,7 @@ class ParsedTypeID: errors: List[ParseError] = field(default_factory=list) # Derived (best-effort) - uuid: Optional[str] = None # keep as string to avoid uuid/uuid6 typing bleed + uuid: Optional[str] = None created_at: Optional[datetime] = None sortable: Optional[bool] = None # TypeIDs w/ UUIDv7 are typically sortable diff --git a/typeid/typeid.py b/typeid/typeid.py index 1b130a5..10fa6cc 100644 --- a/typeid/typeid.py +++ b/typeid/typeid.py @@ -1,31 +1,21 @@ from datetime import datetime, timezone import warnings +import uuid_utils from typing import Generic, Optional, TypeVar -import uuid as std_uuid - from typeid import base32 from typeid.errors import InvalidTypeIDStringException from typeid.validation import validate_prefix, validate_suffix_and_decode -from typeid._uuid_backend import get_uuid_backend - -_backend = get_uuid_backend() PrefixT = TypeVar("PrefixT", bound=str) -def _uuid_from_bytes_v7(uuid_bytes: bytes) -> std_uuid.UUID: +def _uuid_from_bytes_v7(uuid_bytes: bytes) -> uuid_utils.UUID: """ Construct a UUID object from bytes. - Prefer uuid6 (if installed) to preserve UUIDv7 semantics like `.time`. """ - try: - import uuid6 # type: ignore - - uuid_int = int.from_bytes(uuid_bytes, "big") - return uuid6.UUID(int=uuid_int) - except Exception: - return std_uuid.UUID(bytes=uuid_bytes) + uuid_int = int.from_bytes(uuid_bytes, "big") + return uuid_utils.UUID(int=uuid_int) class TypeID(Generic[PrefixT]): @@ -75,12 +65,12 @@ def __init__(self, prefix: Optional[PrefixT] = None, suffix: Optional[str] = Non self._prefix: Optional[PrefixT] = prefix self._str: Optional[str] = None - self._uuid: Optional[std_uuid.UUID] = None + self._uuid: Optional[uuid_utils.UUID] = None self._uuid_bytes: Optional[bytes] = None if not suffix: # generate uuid (fast path) - u = _backend.uuid7() + u = uuid_utils.uuid7() uuid_bytes = u.bytes suffix = base32.encode(uuid_bytes) # Cache UUID object (keep original type for user expectations) @@ -117,7 +107,7 @@ def from_string(cls, string: str) -> "TypeID": return cls(suffix=suffix, prefix=prefix) @classmethod - def from_uuid(cls, suffix: std_uuid.UUID, prefix: Optional[PrefixT] = None) -> "TypeID": + def from_uuid(cls, suffix: uuid_utils.UUID, prefix: Optional[PrefixT] = None) -> "TypeID": """ Construct a TypeID from an existing UUID. @@ -142,7 +132,7 @@ def from_uuid(cls, suffix: std_uuid.UUID, prefix: Optional[PrefixT] = None) -> " obj._prefix = prefix obj._suffix = suffix_str obj._uuid_bytes = uuid_bytes - obj._uuid = suffix # keep original object type (uuid6/uuid_utils/stdlib) + obj._uuid = suffix # keep original object type obj._str = None return obj @@ -172,7 +162,7 @@ def prefix(self) -> str: return self._prefix or "" @property - def uuid(self) -> std_uuid.UUID: + def uuid(self) -> uuid_utils.UUID: """ The UUID represented by this TypeID. @@ -194,9 +184,6 @@ def uuid_bytes(self) -> bytes: in this TypeID. The value is derived lazily from the suffix and cached on first access. - This property is backend-agnostic and independent of the concrete - UUID implementation used internally. - Returns: A 16-byte ``bytes`` object representing the UUID. """ @@ -307,7 +294,7 @@ def from_string(string: str) -> TypeID: return TypeID.from_string(string=string) -def from_uuid(suffix: std_uuid.UUID, prefix: Optional[str] = None) -> TypeID: +def from_uuid(suffix: uuid_utils.UUID, prefix: Optional[str] = None) -> TypeID: warnings.warn("Consider TypeID.from_uuid instead.", DeprecationWarning) return TypeID.from_uuid(suffix=suffix, prefix=prefix) diff --git a/typeid/validation.py b/typeid/validation.py index 0fe30e1..e41b372 100644 --- a/typeid/validation.py +++ b/typeid/validation.py @@ -1,7 +1,7 @@ import re from typeid import base32 -from typeid.constants import SUFFIX_LEN +from typeid.constants import SUFFIX_LEN, ALPHABET from typeid.errors import PrefixValidationException, SuffixValidationException _PREFIX_RE = re.compile(r"^([a-z]([a-z0-9_]{0,61}[a-z0-9])?)?$") # allow digits too (spec-like) @@ -23,7 +23,7 @@ def validate_suffix_and_decode(suffix: str) -> bytes: or suffix == "" or " " in suffix or (not suffix.isdigit() and not suffix.islower()) - or any([symbol not in base32.ALPHABET for symbol in suffix]) + or any([symbol not in ALPHABET for symbol in suffix]) or suffix[0] > "7" ): raise SuffixValidationException(f"Invalid suffix: {suffix}.") diff --git a/uv.lock b/uv.lock index 46eba93..1241ccf 100644 --- a/uv.lock +++ b/uv.lock @@ -1297,31 +1297,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, ] -[[package]] -name = "typeid-base32" -version = "0.3.5" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/dd/1dcc54960bb9a014dfcb646946e35ebf777e9b08b9da8ff8aca986a0ea1a/typeid_base32-0.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4087efae9a82edec788aef1b31ee64bfd1a94d8d5a6b5da077e710d3c014ad6", size = 208486, upload-time = "2026-01-03T15:56:22.982Z" }, - { url = "https://files.pythonhosted.org/packages/02/8e/52c9468e49ac9db141ca85e5a31240fa63259e0e9982d38473ac28c40453/typeid_base32-0.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:e7ef97f472d7cf166130d5f56711331fb6367283326d03750660fdf239493234", size = 99966, upload-time = "2026-01-03T15:56:24.363Z" }, -] - [[package]] name = "typeid-python" version = "0.3.6" source = { editable = "." } dependencies = [ - { name = "uuid6" }, + { name = "uuid-utils" }, ] [package.optional-dependencies] cli = [ { name = "click" }, ] -rust = [ - { name = "typeid-base32" }, - { name = "uuid-utils" }, -] yaml = [ { name = "pyyaml" }, ] @@ -1350,11 +1337,9 @@ dev = [ requires-dist = [ { name = "click", marker = "extra == 'cli'" }, { name = "pyyaml", marker = "extra == 'yaml'" }, - { name = "typeid-base32", marker = "extra == 'rust'", specifier = ">=0.3.5,<0.4.0" }, - { name = "uuid-utils", marker = "extra == 'rust'", specifier = ">=0.12.0" }, - { name = "uuid6", specifier = ">=2024.7.10,<2026.0.0" }, + { name = "uuid-utils", specifier = ">=0.12.0" }, ] -provides-extras = ["cli", "yaml", "rust"] +provides-extras = ["cli", "yaml"] [package.metadata.requires-dev] dev = [ @@ -1432,15 +1417,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/c7/e3f3ce05c5af2bf86a0938d22165affe635f4dcbfd5687b1dacc042d3e0e/uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:84e5c0eba209356f7f389946a3a47b2cc2effd711b3fc7c7f155ad9f7d45e8a3", size = 360693, upload-time = "2025-12-01T17:29:54.558Z" }, ] -[[package]] -name = "uuid6" -version = "2025.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/b7/4c0f736ca824b3a25b15e8213d1bcfc15f8ac2ae48d1b445b310892dc4da/uuid6-2025.0.1.tar.gz", hash = "sha256:cd0af94fa428675a44e32c5319ec5a3485225ba2179eefcf4c3f205ae30a81bd", size = 13932, upload-time = "2025-07-04T18:30:35.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/b2/93faaab7962e2aa8d6e174afb6f76be2ca0ce89fde14d3af835acebcaa59/uuid6-2025.0.1-py3-none-any.whl", hash = "sha256:80530ce4d02a93cdf82e7122ca0da3ebbbc269790ec1cb902481fa3e9cc9ff99", size = 6979, upload-time = "2025-07-04T18:30:34.001Z" }, -] - [[package]] name = "watchdog" version = "6.0.0"