diracx-db 0.0.1a49__tar.gz → 0.0.2__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.
Files changed (51) hide show
  1. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/PKG-INFO +4 -1
  2. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/pyproject.toml +2 -0
  3. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/__main__.py +1 -0
  4. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/auth/db.py +69 -2
  5. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/auth/schema.py +6 -2
  6. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/utils/__init__.py +15 -11
  7. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/utils/base.py +38 -1
  8. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/auth/test_refresh_token.py +0 -2
  9. diracx_db-0.0.2/tests/utils/test_uuid.py +221 -0
  10. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/.gitignore +0 -0
  11. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/README.md +0 -0
  12. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/__init__.py +0 -0
  13. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/exceptions.py +0 -0
  14. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/os/__init__.py +0 -0
  15. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/os/job_parameters.py +0 -0
  16. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/os/utils.py +0 -0
  17. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/py.typed +0 -0
  18. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/__init__.py +0 -0
  19. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/auth/__init__.py +0 -0
  20. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/dummy/__init__.py +0 -0
  21. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/dummy/db.py +0 -0
  22. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/dummy/schema.py +0 -0
  23. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/job/__init__.py +0 -0
  24. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/job/db.py +0 -0
  25. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/job/schema.py +0 -0
  26. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/job_logging/__init__.py +0 -0
  27. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/job_logging/db.py +0 -0
  28. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/job_logging/schema.py +0 -0
  29. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/pilot_agents/__init__.py +0 -0
  30. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/pilot_agents/db.py +0 -0
  31. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/pilot_agents/schema.py +0 -0
  32. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/sandbox_metadata/__init__.py +0 -0
  33. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/sandbox_metadata/db.py +0 -0
  34. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/sandbox_metadata/schema.py +0 -0
  35. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/task_queue/__init__.py +0 -0
  36. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/task_queue/db.py +0 -0
  37. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/task_queue/schema.py +0 -0
  38. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/utils/functions.py +0 -0
  39. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/src/diracx/db/sql/utils/types.py +0 -0
  40. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/auth/test_authorization_flow.py +0 -0
  41. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/auth/test_device_flow.py +0 -0
  42. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/jobs/test_job_db.py +0 -0
  43. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/jobs/test_job_logging_db.py +0 -0
  44. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/jobs/test_sandbox_metadata.py +0 -0
  45. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/opensearch/test_connection.py +0 -0
  46. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/opensearch/test_index_template.py +0 -0
  47. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/opensearch/test_search.py +0 -0
  48. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/pilot_agents/__init__.py +0 -0
  49. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/pilot_agents/test_pilot_agents_db.py +0 -0
  50. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/test_dummy_db.py +0 -0
  51. {diracx_db-0.0.1a49 → diracx_db-0.0.2}/tests/test_freeze_time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diracx-db
3
- Version: 0.0.1a49
3
+ Version: 0.0.2
4
4
  Summary: TODO
5
5
  License: GPL-3.0-only
6
6
  Classifier: Intended Audience :: Science/Research
@@ -12,8 +12,11 @@ Requires-Python: >=3.11
12
12
  Requires-Dist: diracx-core
13
13
  Requires-Dist: opensearch-py[async]
14
14
  Requires-Dist: pydantic>=2.10
15
+ Requires-Dist: python-dateutil
15
16
  Requires-Dist: sqlalchemy[aiomysql,aiosqlite]>=2
16
17
  Requires-Dist: uuid-utils
17
18
  Provides-Extra: testing
18
19
  Requires-Dist: diracx-testing; extra == 'testing'
19
20
  Requires-Dist: freezegun; extra == 'testing'
21
+ Provides-Extra: types
22
+ Requires-Dist: types-python-dateutil; extra == 'types'
@@ -18,11 +18,13 @@ dependencies = [
18
18
  "pydantic >=2.10",
19
19
  "sqlalchemy[aiomysql,aiosqlite] >= 2",
20
20
  "uuid-utils",
21
+ "python-dateutil",
21
22
  ]
22
23
  dynamic = ["version"]
23
24
 
24
25
  [project.optional-dependencies]
25
26
  testing = ["diracx-testing", "freezegun"]
27
+ types = ["types-python-dateutil"]
26
28
 
27
29
  [project.entry-points."diracx.dbs.sql"]
28
30
  AuthDB = "diracx.db.sql:AuthDB"
@@ -39,6 +39,7 @@ async def init_sql():
39
39
  if db._db_url.startswith("sqlite"):
40
40
  await conn.exec_driver_sql("PRAGMA foreign_keys=ON")
41
41
  await conn.run_sync(db.metadata.create_all)
42
+ await db.post_create(conn)
42
43
 
43
44
 
44
45
  async def init_os():
@@ -1,16 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import secrets
5
+ from datetime import UTC, datetime
6
+ from itertools import pairwise
4
7
 
5
- from sqlalchemy import insert, select, update
8
+ from dateutil.rrule import MONTHLY, rrule
9
+ from sqlalchemy import insert, select, text, update
6
10
  from sqlalchemy.exc import IntegrityError, NoResultFound
11
+ from sqlalchemy.ext.asyncio import AsyncConnection
7
12
  from uuid_utils import UUID, uuid7
8
13
 
9
14
  from diracx.core.exceptions import (
10
15
  AuthorizationError,
11
16
  TokenNotFoundError,
12
17
  )
13
- from diracx.db.sql.utils import BaseSQLDB, hash, substract_date
18
+ from diracx.db.sql.utils import BaseSQLDB, hash, substract_date, uuid7_from_datetime
14
19
 
15
20
  from .schema import (
16
21
  AuthorizationFlows,
@@ -25,10 +30,72 @@ from .schema import Base as AuthDBBase
25
30
  USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"
26
31
  MAX_RETRY = 5
27
32
 
33
+ logger = logging.getLogger(__name__)
34
+
28
35
 
29
36
  class AuthDB(BaseSQLDB):
30
37
  metadata = AuthDBBase.metadata
31
38
 
39
+ @classmethod
40
+ async def post_create(cls, conn: AsyncConnection) -> None:
41
+ """Create partitions if it is a MySQL DB and it does not have
42
+ it yet and the table does not have any data yet.
43
+ We do this as a post_create step as sqlalchemy does not support
44
+ partition so well.
45
+ """
46
+ if conn.dialect.name == "mysql":
47
+ check_partition_query = text(
48
+ "SELECT PARTITION_NAME FROM information_schema.partitions "
49
+ "WHERE TABLE_NAME = 'RefreshTokens' AND PARTITION_NAME is not NULL"
50
+ )
51
+ partition_names = (await conn.execute(check_partition_query)).all()
52
+
53
+ if not partition_names:
54
+ # Create a monthly partition from today until 2 years
55
+ # The partition are named p_<year>_<month>
56
+ start_date = datetime.now(tz=UTC).replace(
57
+ day=1, hour=0, minute=0, second=0, microsecond=0
58
+ )
59
+ end_date = start_date.replace(year=start_date.year + 2)
60
+
61
+ dates = [
62
+ dt for dt in rrule(MONTHLY, dtstart=start_date, until=end_date)
63
+ ]
64
+
65
+ partition_list = []
66
+ for name, limit in pairwise(dates):
67
+ partition_list.append(
68
+ f"PARTITION p_{name.year}_{name.month} "
69
+ f"VALUES LESS THAN ('{str(uuid7_from_datetime(limit, randomize=False)).replace('-', '')}')"
70
+ )
71
+ partition_list.append("PARTITION p_future VALUES LESS THAN (MAXVALUE)")
72
+
73
+ alter_query = text(
74
+ f"ALTER TABLE RefreshTokens PARTITION BY RANGE COLUMNS (JTI) ({','.join(partition_list)})"
75
+ )
76
+
77
+ check_table_empty_query = text("SELECT * FROM RefreshTokens LIMIT 1")
78
+ refresh_table_content = (
79
+ await conn.execute(check_table_empty_query)
80
+ ).all()
81
+ if refresh_table_content:
82
+ logger.warning(
83
+ "RefreshTokens table not empty. Run the following query yourself"
84
+ )
85
+ logger.warning(alter_query)
86
+ return
87
+
88
+ await conn.execute(alter_query)
89
+
90
+ partition_names = (
91
+ await conn.execute(
92
+ check_partition_query, {"table_name": "RefreshTokens"}
93
+ )
94
+ ).all()
95
+ assert partition_names, (
96
+ f"There should be partitions now {partition_names}"
97
+ )
98
+
32
99
  async def device_flow_validate_user_code(
33
100
  self, user_code: str, max_validity: int
34
101
  ) -> str:
@@ -10,7 +10,12 @@ from sqlalchemy import (
10
10
  )
11
11
  from sqlalchemy.orm import declarative_base
12
12
 
13
- from diracx.db.sql.utils import Column, DateNowColumn, EnumColumn, NullColumn
13
+ from diracx.db.sql.utils import (
14
+ Column,
15
+ DateNowColumn,
16
+ EnumColumn,
17
+ NullColumn,
18
+ )
14
19
 
15
20
  USER_CODE_LENGTH = 8
16
21
 
@@ -92,7 +97,6 @@ class RefreshTokens(Base):
92
97
  status = EnumColumn(
93
98
  "Status", RefreshTokenStatus, server_default=RefreshTokenStatus.CREATED.name
94
99
  )
95
- creation_time = DateNowColumn("CreationTime", index=True)
96
100
  scope = Column("Scope", String(1024))
97
101
 
98
102
  # User attributes bound to the refresh token
@@ -1,16 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from .base import (
4
- BaseSQLDB,
5
- SQLDBUnavailableError,
6
- _get_columns,
7
- apply_search_filters,
8
- apply_sort_constraints,
9
- )
10
- from .functions import hash, substract_date, utcnow
11
- from .types import Column, DateNowColumn, EnumBackedBool, EnumColumn, NullColumn
12
-
13
- __all__ = (
3
+ __all__ = [
14
4
  "_get_columns",
15
5
  "utcnow",
16
6
  "Column",
@@ -24,4 +14,18 @@ __all__ = (
24
14
  "substract_date",
25
15
  "hash",
26
16
  "SQLDBUnavailableError",
17
+ "uuid7_from_datetime",
18
+ "uuid7_to_datetime",
19
+ ]
20
+
21
+ from .base import (
22
+ BaseSQLDB,
23
+ SQLDBUnavailableError,
24
+ _get_columns,
25
+ apply_search_filters,
26
+ apply_sort_constraints,
27
+ uuid7_from_datetime,
28
+ uuid7_to_datetime,
27
29
  )
30
+ from .functions import hash, substract_date, utcnow
31
+ from .types import Column, DateNowColumn, EnumBackedBool, EnumColumn, NullColumn
@@ -7,13 +7,15 @@ import re
7
7
  from abc import ABCMeta
8
8
  from collections.abc import AsyncIterator
9
9
  from contextvars import ContextVar
10
- from datetime import datetime
10
+ from datetime import datetime, timezone
11
11
  from typing import Any, Self, cast
12
+ from uuid import UUID as StdUUID # noqa: N811
12
13
 
13
14
  from pydantic import TypeAdapter
14
15
  from sqlalchemy import DateTime, MetaData, func, select
15
16
  from sqlalchemy.exc import OperationalError
16
17
  from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
18
+ from uuid_utils import UUID, uuid7
17
19
 
18
20
  from diracx.core.exceptions import InvalidQueryError
19
21
  from diracx.core.extensions import select_from_extension
@@ -153,6 +155,11 @@ class BaseSQLDB(metaclass=ABCMeta):
153
155
  raise
154
156
  return db_urls
155
157
 
158
+ @classmethod
159
+ async def post_create(cls, conn: AsyncConnection) -> None:
160
+ """Execute actions after the schema has been created."""
161
+ return
162
+
156
163
  @classmethod
157
164
  def transaction(cls) -> Self:
158
165
  raise NotImplementedError("This should never be called")
@@ -416,3 +423,33 @@ def apply_sort_constraints(column_mapping, stmt, sorts):
416
423
  if sort_columns:
417
424
  stmt = stmt.order_by(*sort_columns)
418
425
  return stmt
426
+
427
+
428
+ def uuid7_to_datetime(uuid: UUID | StdUUID | str) -> datetime:
429
+ """Convert a UUIDv7 to a datetime."""
430
+ if isinstance(uuid, StdUUID):
431
+ # Convert stdlib UUID to uuid_utils.UUID
432
+ uuid = UUID(str(uuid))
433
+ elif not isinstance(uuid, UUID):
434
+ # Convert string or other types to uuid_utils.UUID
435
+ uuid = UUID(uuid)
436
+ if uuid.version != 7:
437
+ raise ValueError(f"UUID {uuid} is not a UUIDv7")
438
+ return datetime.fromtimestamp(uuid.timestamp / 1000.0, tz=timezone.utc)
439
+
440
+
441
+ def uuid7_from_datetime(dt: datetime, *, randomize: bool = True) -> UUID:
442
+ """Generate a UUIDv7 corresponding to the given datetime.
443
+
444
+ If randomize is True, the standard uuid7 function is used resulting in the
445
+ lowest 62-bits being random. If randomize is False, the UUIDv7 will be the
446
+ lowest possible UUIDv7 for the given datetime.
447
+ """
448
+ timestamp = dt.timestamp()
449
+ if randomize:
450
+ uuid = uuid7(int(timestamp), int((timestamp % 1) * 1e9))
451
+ else:
452
+ time_high = int(timestamp * 1000) >> 16
453
+ time_low = int(timestamp * 1000) & 0xFFFF
454
+ uuid = UUID.from_fields((time_high, time_low, 0x7000, 0x80, 0, 0))
455
+ return uuid
@@ -56,7 +56,6 @@ async def test_get(auth_db: AuthDB):
56
56
  refresh_token_details["sub"],
57
57
  refresh_token_details["scope"],
58
58
  )
59
- creation_time = (await auth_db.get_refresh_token(jti))["CreationTime"]
60
59
 
61
60
  # Enrich the dict with the generated refresh token attributes
62
61
  expected_refresh_token = {
@@ -64,7 +63,6 @@ async def test_get(auth_db: AuthDB):
64
63
  "Scope": refresh_token_details["scope"],
65
64
  "JTI": jti,
66
65
  "Status": RefreshTokenStatus.CREATED,
67
- "CreationTime": creation_time,
68
66
  }
69
67
 
70
68
  # Get refresh token details
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from uuid import UUID as StdUUID # noqa: N811
5
+
6
+ import freezegun
7
+ import pytest
8
+ from uuid_utils import UUID, uuid7
9
+
10
+ from diracx.db.sql.utils import uuid7_from_datetime, uuid7_to_datetime
11
+
12
+
13
+ def frozen_uuid7() -> UUID:
14
+ """Create a UUID7 in a way which respects the freezegun context."""
15
+ timestamp = datetime.now(tz=timezone.utc).timestamp()
16
+ return uuid7(int(timestamp), int((timestamp % 1) * 1e9))
17
+
18
+
19
+ class TestDatetimeToUuid7:
20
+ """Test cases for the datetime_to_uuid7 function."""
21
+
22
+ def test_datetime_to_uuid7_random(self):
23
+ """Test that the datetime_to_uuid7 function returns a UUID7 with the current timestamp."""
24
+ dt = datetime.fromisoformat("2024-01-15T12:30:45.123456+00:00")
25
+ result = uuid7_from_datetime(dt, randomize=True)
26
+ assert str(result)[:15] == "018d0d1a-5183-7"
27
+ assert len(set(str(result)[15:])) > 4
28
+
29
+ def test_datetime_to_uuid7_deterministic(self):
30
+ """Test that the datetime_to_uuid7 function returns a UUID7 with the current timestamp."""
31
+ dt = datetime.fromisoformat("2024-01-15T12:30:45.123456+00:00")
32
+ result = uuid7_from_datetime(dt, randomize=False)
33
+ assert str(result) == "018d0d1a-5183-7000-8000-000000000000"
34
+
35
+
36
+ class TestUuid7ToDatetime:
37
+ """Test cases for the uuid7_to_datetime function."""
38
+
39
+ def test_uuid7_now(self):
40
+ """Test that the uuid7 function returns a UUID7 with the current timestamp."""
41
+ test_uuid = frozen_uuid7()
42
+ result = uuid7_to_datetime(test_uuid)
43
+ assert result.tzinfo == timezone.utc
44
+ assert result - datetime.now(tz=timezone.utc) < timedelta(milliseconds=2)
45
+
46
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
47
+ def test_uuid7_to_datetime_with_uuid_utils_uuid(self):
48
+ """Test conversion with uuid_utils.UUID object."""
49
+ test_uuid = frozen_uuid7()
50
+ result = uuid7_to_datetime(test_uuid)
51
+
52
+ assert isinstance(result, datetime)
53
+ assert result.tzinfo == timezone.utc
54
+ assert result.isoformat() == "2024-01-15T12:30:45.123000+00:00"
55
+
56
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
57
+ def test_uuid7_to_datetime_with_stdlib_uuid(self):
58
+ """Test conversion with standard library UUID object."""
59
+ test_uuid = frozen_uuid7()
60
+ stdlib_uuid = StdUUID(str(test_uuid))
61
+
62
+ result = uuid7_to_datetime(stdlib_uuid)
63
+ assert isinstance(result, datetime)
64
+ assert result.tzinfo == timezone.utc
65
+
66
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
67
+ def test_uuid7_to_datetime_with_string_uuid(self):
68
+ """Test conversion with string UUID that gets converted to uuid_utils.UUID."""
69
+ test_uuid = frozen_uuid7()
70
+ uuid_string = str(test_uuid)
71
+
72
+ result = uuid7_to_datetime(uuid_string)
73
+ assert isinstance(result, datetime)
74
+ assert result.tzinfo == timezone.utc
75
+
76
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
77
+ def test_uuid7_to_datetime_precision(self):
78
+ """Test that the conversion maintains reasonable precision."""
79
+ test_uuid = frozen_uuid7()
80
+ result = uuid7_to_datetime(test_uuid)
81
+
82
+ assert isinstance(result, datetime)
83
+ assert result.tzinfo == timezone.utc
84
+ assert result.isoformat() == "2024-01-15T12:30:45.123000+00:00"
85
+
86
+ @freezegun.freeze_time("1970-01-01 00:00:00.000000")
87
+ def test_uuid7_to_datetime_epoch_boundary(self):
88
+ """Test conversion with UUID7 at epoch boundary."""
89
+ test_uuid = frozen_uuid7()
90
+ result = uuid7_to_datetime(test_uuid)
91
+
92
+ assert isinstance(result, datetime)
93
+ assert result.tzinfo == timezone.utc
94
+ assert result.isoformat() == "1970-01-01T00:00:00+00:00"
95
+
96
+ @freezegun.freeze_time("2100-01-01 00:00:00.000000")
97
+ def test_uuid7_to_datetime_future_timestamp(self):
98
+ """Test conversion with future timestamp."""
99
+ test_uuid = frozen_uuid7()
100
+ result = uuid7_to_datetime(test_uuid)
101
+
102
+ assert isinstance(result, datetime)
103
+ assert result.tzinfo == timezone.utc
104
+ assert result.isoformat() == "2100-01-01T00:00:00+00:00"
105
+
106
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
107
+ def test_uuid7_to_datetime_consistency(self):
108
+ """Test that multiple calls with the same UUID return the same result."""
109
+ test_uuid = frozen_uuid7()
110
+
111
+ result1 = uuid7_to_datetime(test_uuid)
112
+ result2 = uuid7_to_datetime(test_uuid)
113
+
114
+ assert result1.isoformat() == result2.isoformat()
115
+ assert result1 == result2
116
+
117
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
118
+ def test_uuid7_to_datetime_different_input_types_same_result(self):
119
+ """Test that different input types representing the same UUID return the same result."""
120
+ test_uuid = frozen_uuid7()
121
+ uuid_string = str(test_uuid)
122
+ stdlib_uuid = StdUUID(uuid_string)
123
+
124
+ result_from_uuid_utils = uuid7_to_datetime(test_uuid)
125
+ result_from_string = uuid7_to_datetime(uuid_string)
126
+ result_from_stdlib = uuid7_to_datetime(stdlib_uuid)
127
+
128
+ # All results should be identical
129
+ assert result_from_uuid_utils == result_from_string == result_from_stdlib
130
+
131
+ def test_uuid7_to_datetime_invalid_input_type(self):
132
+ """Test that invalid input types raise appropriate errors."""
133
+ with pytest.raises(TypeError):
134
+ uuid7_to_datetime(123) # type: ignore
135
+
136
+ with pytest.raises(TypeError):
137
+ uuid7_to_datetime(123.45) # type: ignore
138
+
139
+ with pytest.raises(TypeError):
140
+ uuid7_to_datetime([]) # type: ignore
141
+
142
+ with pytest.raises(TypeError):
143
+ uuid7_to_datetime({}) # type: ignore
144
+
145
+ def test_uuid7_to_datetime_invalid_uuid_string(self):
146
+ """Test that invalid UUID strings raise appropriate errors."""
147
+ with pytest.raises(ValueError):
148
+ uuid7_to_datetime("not-a-uuid")
149
+
150
+ with pytest.raises(ValueError):
151
+ uuid7_to_datetime("12345")
152
+
153
+ with pytest.raises(ValueError):
154
+ uuid7_to_datetime("")
155
+
156
+ def test_uuid7_to_datetime_non_uuid7_uuid(self):
157
+ """Test behavior with non-UUID7 UUIDs."""
158
+ uuid4 = StdUUID("550e8400-e29b-41d4-a716-446655440000")
159
+
160
+ with pytest.raises(ValueError, match="is not a UUIDv7"):
161
+ uuid7_to_datetime(uuid4)
162
+
163
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
164
+ def test_uuid7_to_datetime_timezone_awareness(self):
165
+ """Test that the returned datetime is always timezone-aware and in UTC."""
166
+ test_uuid = frozen_uuid7()
167
+ result = uuid7_to_datetime(test_uuid)
168
+
169
+ assert result.tzinfo is not None
170
+
171
+ assert result.tzinfo == timezone.utc
172
+
173
+ assert result.tzinfo.utcoffset(result) is not None
174
+
175
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
176
+ def test_uuid7_to_datetime_millisecond_precision(self):
177
+ """Test that the conversion handles millisecond precision correctly."""
178
+ uuids = [frozen_uuid7() for _ in range(5)]
179
+ results = [uuid7_to_datetime(uuid) for uuid in uuids]
180
+
181
+ for result in results:
182
+ assert isinstance(result, datetime)
183
+ assert result.tzinfo == timezone.utc
184
+
185
+ timestamps = [r.timestamp() for r in results]
186
+ assert timestamps == sorted(timestamps)
187
+
188
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
189
+ def test_uuid7_to_datetime_timestamp_property(self):
190
+ """Test that the function correctly uses the timestamp property."""
191
+ test_uuid = frozen_uuid7()
192
+ result = uuid7_to_datetime(test_uuid)
193
+
194
+ expected_timestamp = test_uuid.timestamp / 1000.0
195
+ actual_timestamp = result.timestamp()
196
+
197
+ assert abs(actual_timestamp - expected_timestamp) < 0.001
198
+
199
+ @freezegun.freeze_time("2024-01-15 12:30:45.123456")
200
+ def test_uuid7_to_datetime_deterministic_with_freeze(self):
201
+ """Test that the function works deterministically with freezegun."""
202
+ with freezegun.freeze_time("2024-01-15 12:30:45.123000"):
203
+ uuid1 = frozen_uuid7()
204
+ result1 = uuid7_to_datetime(uuid1)
205
+
206
+ with freezegun.freeze_time("2024-01-15 12:30:45.123000"):
207
+ uuid2 = frozen_uuid7()
208
+ result2 = uuid7_to_datetime(uuid2)
209
+
210
+ time_diff = abs((result1 - result2).total_seconds())
211
+ assert time_diff < 1.0
212
+
213
+ @freezegun.freeze_time("1970-01-01 00:00:00")
214
+ def test_uuid7_to_datetime_edge_case_zero_timestamp(self):
215
+ """Test edge case with very small timestamp values."""
216
+ test_uuid = frozen_uuid7()
217
+ result = uuid7_to_datetime(test_uuid)
218
+
219
+ assert isinstance(result, datetime)
220
+ assert result.tzinfo == timezone.utc
221
+ assert result.isoformat() == "1970-01-01T00:00:00+00:00"
File without changes
File without changes