Skip to content

Commit a6e275d

Browse files
lajxwcofin
andauthored
feat: Add support for UUID6 and UUID7 based on Python version (#714)
Adds support for native UUID6/7 in supported Python installations. Close #713 Signed-off-by: lajxw <46254113+lajxw@users.noreply.github.com> Co-authored-by: Cody Fincher <cody@litestar.dev>
1 parent c3d3e2e commit a6e275d

3 files changed

Lines changed: 146 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ offering:
4444
- Unified interface for various storage backends ([`fsspec`](https://filesystem-spec.readthedocs.io/en/latest/) and [`obstore`](https://developmentseed.org/obstore/latest/))
4545
- Optional lifecycle event hooks integrated with SQLAlchemy's event system to automatically save and delete files as records are inserted, updated, or deleted.
4646
- Optimized JSON types including a custom JSON type for Oracle
47-
- Integrated support for UUID6 and UUID7 using [`uuid-utils`](https://github.com/aminalaee/uuid-utils) (install with the `uuid` extra)
47+
- Integrated support for UUID6 and UUID7 ([`uuid-utils`](https://github.com/aminalaee/uuid-utils) when installed, otherwise native in Python 3.14+, with UUID4 fallback on older versions).
4848
- Integrated support for Nano ID using [`fastnanoid`](https://github.com/oliverlambson/fastnanoid) (install with the `nanoid` extra)
4949
- Custom encrypted text type with multiple backend support including [`pgcrypto`](https://www.postgresql.org/docs/current/pgcrypto.html) for PostgreSQL and the Fernet implementation from [`cryptography`](https://cryptography.io/en/latest/) for other databases
5050
- Custom password hashing type with multiple backend support including [`Argon2`](https://github.com/P-H-C/phc-winner-argon2), [`Passlib`](https://passlib.readthedocs.io/en/stable/), and [`Pwdlib`](https://pwdlib.readthedocs.io/en/stable/) with automatic salt generation

advanced_alchemy/mixins/uuid.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import logging
2-
from typing import TYPE_CHECKING, Any
3-
from uuid import UUID, uuid4
2+
import sys
3+
from typing import TYPE_CHECKING, Any, Callable
4+
from uuid import UUID
45

56
from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column
67

78
from advanced_alchemy.mixins.sentinel import SentinelMixin
89
from advanced_alchemy.types import UUID_UTILS_INSTALLED
910

11+
_PYTHON_SUPPORTS_UUID6_7 = sys.version_info >= (3, 14)
12+
1013
if UUID_UTILS_INSTALLED and not TYPE_CHECKING:
1114
from uuid_utils.compat import ( # type: ignore[no-redef,unused-ignore] # pyright: ignore[reportMissingImports]
1215
uuid4,
1316
uuid6,
1417
uuid7,
1518
)
19+
elif _PYTHON_SUPPORTS_UUID6_7:
20+
from uuid import uuid4, uuid6, uuid7 # type: ignore[attr-defined]
21+
22+
uuid6: Callable[[], UUID] # type: ignore[no-redef]
23+
uuid7: Callable[[], UUID] # type: ignore[no-redef]
1624
else:
1725
from uuid import uuid4 # type: ignore[no-redef,unused-ignore]
1826

@@ -36,10 +44,14 @@ class UUIDv6PrimaryKey(SentinelMixin):
3644

3745
def __init_subclass__(cls, **kwargs: Any) -> None:
3846
super().__init_subclass__(**kwargs)
39-
if not UUID_UTILS_INSTALLED and not cls.__module__.startswith("advanced_alchemy"): # pragma: no cover
47+
if (
48+
not _PYTHON_SUPPORTS_UUID6_7
49+
and not UUID_UTILS_INSTALLED
50+
and not cls.__module__.startswith("advanced_alchemy")
51+
): # pragma: no cover
4052
logger.warning("`uuid-utils` not installed, falling back to `uuid4` for UUID v6 generation.")
4153

42-
id: Mapped[UUID] = mapped_column(default=uuid6, primary_key=True, sort_order=-100)
54+
id: Mapped[UUID] = mapped_column(default=uuid6, primary_key=True, sort_order=-100) # pyright: ignore[reportUnknownArgumentType]
4355
"""UUID Primary key column."""
4456

4557

@@ -49,8 +61,12 @@ class UUIDv7PrimaryKey(SentinelMixin):
4961

5062
def __init_subclass__(cls, **kwargs: Any) -> None:
5163
super().__init_subclass__(**kwargs)
52-
if not UUID_UTILS_INSTALLED and not cls.__module__.startswith("advanced_alchemy"): # pragma: no cover
64+
if (
65+
not _PYTHON_SUPPORTS_UUID6_7
66+
and not UUID_UTILS_INSTALLED
67+
and not cls.__module__.startswith("advanced_alchemy")
68+
): # pragma: no cover
5369
logger.warning("`uuid-utils` not installed, falling back to `uuid4` for UUID v7 generation.")
5470

55-
id: Mapped[UUID] = mapped_column(default=uuid7, primary_key=True, sort_order=-100)
71+
id: Mapped[UUID] = mapped_column(default=uuid7, primary_key=True, sort_order=-100) # pyright: ignore[reportUnknownArgumentType]
5672
"""UUID Primary key column."""

tests/unit/test_base.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,65 @@
11
# pyright: reportUnusedImport=false
22
from __future__ import annotations
33

4+
import importlib
5+
import sys
6+
import types
7+
import uuid as uuid_module
48
import warnings
59
from typing import cast
610

11+
import pytest
712
from sqlalchemy import Table, create_engine
813
from sqlalchemy.dialects import mssql, oracle, postgresql
914
from sqlalchemy.orm import declarative_mixin
1015
from sqlalchemy.schema import CreateTable
1116

1217
from tests.helpers import purge_module
1318

19+
_MISSING = object()
20+
21+
22+
def _reload_uuid_mixin(
23+
monkeypatch: pytest.MonkeyPatch,
24+
*,
25+
uuid_utils_installed: bool,
26+
fake_uuid_utils_compat: types.ModuleType | None = None,
27+
version_info: tuple[int, int, int, str, int] = (3, 14, 0, "final", 0),
28+
) -> types.ModuleType:
29+
import advanced_alchemy.types as alchemy_types
30+
31+
module_names = ("advanced_alchemy.mixins.uuid", "uuid_utils.compat", "uuid_utils")
32+
previous_modules = {name: sys.modules.get(name, _MISSING) for name in module_names}
33+
mixins_package = sys.modules.get("advanced_alchemy.mixins")
34+
previous_uuid_attr = getattr(mixins_package, "uuid", _MISSING) if mixins_package is not None else _MISSING
35+
36+
monkeypatch.setattr(sys, "version_info", version_info)
37+
monkeypatch.setattr(alchemy_types, "UUID_UTILS_INSTALLED", object() if uuid_utils_installed else None)
38+
39+
try:
40+
for name in module_names:
41+
sys.modules.pop(name, None)
42+
if mixins_package is not None and hasattr(mixins_package, "uuid"):
43+
delattr(mixins_package, "uuid")
44+
if fake_uuid_utils_compat is not None:
45+
fake_uuid_utils = types.ModuleType("uuid_utils")
46+
fake_uuid_utils.__path__ = [] # type: ignore[attr-defined]
47+
sys.modules["uuid_utils"] = fake_uuid_utils
48+
sys.modules["uuid_utils.compat"] = fake_uuid_utils_compat
49+
return importlib.import_module("advanced_alchemy.mixins.uuid")
50+
finally:
51+
for name, previous_module in previous_modules.items():
52+
if previous_module is _MISSING:
53+
sys.modules.pop(name, None)
54+
else:
55+
sys.modules[name] = cast(types.ModuleType, previous_module)
56+
if mixins_package is not None:
57+
if previous_uuid_attr is _MISSING:
58+
if hasattr(mixins_package, "uuid"):
59+
delattr(mixins_package, "uuid")
60+
else:
61+
setattr(mixins_package, "uuid", previous_uuid_attr)
62+
1463

1564
def test_deprecated_classes_functionality() -> None:
1665
"""Test that mixins classes maintain have base functionality."""
@@ -49,6 +98,80 @@ def test_deprecated_classes_functionality() -> None:
4998
assert hasattr(audit, "updated_at")
5099

51100

101+
def test_uuid_utils_generators_are_preferred_when_installed_on_python_314(
102+
monkeypatch: pytest.MonkeyPatch,
103+
) -> None:
104+
"""Test that installing uuid-utils keeps uuid generation on uuid-utils."""
105+
106+
def native_uuid6() -> uuid_module.UUID:
107+
return uuid_module.uuid4()
108+
109+
def native_uuid7() -> uuid_module.UUID:
110+
return uuid_module.uuid4()
111+
112+
def uuid_utils_uuid4() -> uuid_module.UUID:
113+
return uuid_module.uuid4()
114+
115+
def uuid_utils_uuid6() -> uuid_module.UUID:
116+
return uuid_module.uuid4()
117+
118+
def uuid_utils_uuid7() -> uuid_module.UUID:
119+
return uuid_module.uuid4()
120+
121+
monkeypatch.setattr(uuid_module, "uuid6", native_uuid6, raising=False)
122+
monkeypatch.setattr(uuid_module, "uuid7", native_uuid7, raising=False)
123+
124+
fake_compat = types.ModuleType("uuid_utils.compat")
125+
fake_compat.uuid4 = uuid_utils_uuid4 # type: ignore[attr-defined]
126+
fake_compat.uuid6 = uuid_utils_uuid6 # type: ignore[attr-defined]
127+
fake_compat.uuid7 = uuid_utils_uuid7 # type: ignore[attr-defined]
128+
129+
uuid_mixin = _reload_uuid_mixin(
130+
monkeypatch,
131+
uuid_utils_installed=True,
132+
fake_uuid_utils_compat=fake_compat,
133+
)
134+
135+
assert uuid_mixin.uuid4 is uuid_utils_uuid4
136+
assert uuid_mixin.uuid6 is uuid_utils_uuid6
137+
assert uuid_mixin.uuid7 is uuid_utils_uuid7
138+
139+
140+
def test_native_uuid_generators_are_used_on_python_314_without_uuid_utils(
141+
monkeypatch: pytest.MonkeyPatch,
142+
) -> None:
143+
"""Test that Python 3.14 native UUID generators are used without uuid-utils."""
144+
145+
def native_uuid6() -> uuid_module.UUID:
146+
return uuid_module.uuid4()
147+
148+
def native_uuid7() -> uuid_module.UUID:
149+
return uuid_module.uuid4()
150+
151+
monkeypatch.setattr(uuid_module, "uuid6", native_uuid6, raising=False)
152+
monkeypatch.setattr(uuid_module, "uuid7", native_uuid7, raising=False)
153+
154+
uuid_mixin = _reload_uuid_mixin(monkeypatch, uuid_utils_installed=False)
155+
156+
assert uuid_mixin.uuid6 is native_uuid6
157+
assert uuid_mixin.uuid7 is native_uuid7
158+
159+
160+
def test_uuid_generators_fall_back_to_uuid4_before_python_314_without_uuid_utils(
161+
monkeypatch: pytest.MonkeyPatch,
162+
) -> None:
163+
"""Test that UUID6/7 generators fall back to UUID4 before Python 3.14."""
164+
165+
uuid_mixin = _reload_uuid_mixin(
166+
monkeypatch,
167+
uuid_utils_installed=False,
168+
version_info=(3, 13, 0, "final", 0),
169+
)
170+
171+
assert uuid_mixin.uuid6 is uuid_mixin.uuid4
172+
assert uuid_mixin.uuid7 is uuid_mixin.uuid4
173+
174+
52175
def test_identity_primary_key_generates_identity_ddl() -> None:
53176
"""Test that IdentityPrimaryKey generates proper IDENTITY DDL for PostgreSQL."""
54177
from advanced_alchemy.base import BigIntBase

0 commit comments

Comments
 (0)