rococo 1.1.2__tar.gz → 1.1.3__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.
- {rococo-1.1.2 → rococo-1.1.3}/PKG-INFO +1 -1
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/versioned_model.py +43 -5
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mongodb/mongodb_repository.py +1 -1
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/surrealdb/person_organization_role_repository.py +13 -6
- {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/PKG-INFO +1 -1
- {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/SOURCES.txt +2 -1
- {rococo-1.1.2 → rococo-1.1.3}/setup.py +1 -1
- {rococo-1.1.2 → rococo-1.1.3}/tests/model_test.py +278 -5
- {rococo-1.1.2 → rococo-1.1.3}/tests/mongodb_repository_test.py +441 -18
- rococo-1.1.3/tests/test_calculated_properties_fix.py +143 -0
- {rococo-1.1.2 → rococo-1.1.3}/LICENSE +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/README.md +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/auth/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/auth/tokens.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/config/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/config/config.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/data/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/data/base.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/data/mongodb.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/data/mysql.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/data/postgresql.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/data/surrealdb.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/base.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/config.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/enums.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/factory.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/mailjet.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/ses.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/messaging/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/messaging/base.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/messaging/rabbitmq.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/messaging/sqs.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/cli_base.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/migration_base.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/migration_runner.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/migration_template.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mongo/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mongo/cli.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mongo/migration.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mysql/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mysql/cli.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mysql/migration.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/postgres/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/postgres/cli.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/postgres/migration.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/email.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/login_method.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/non_versioned_model.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/organization.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/otp_method.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/person.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/person_organization_role.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/recovery_code.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/email.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/login_method.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/organization.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/otp_method.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/person.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/person_organization_role.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/surreal_versioned_model.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/plugins/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/plugins/pooled_connection.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/base_repository.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mongodb/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mysql/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mysql/mysql_repository.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mysql/organization_repository.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/postgresql/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/postgresql/postgresql_repository.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/surrealdb/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/surrealdb/organization_repository.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/surrealdb/surreal_db_repository.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/dependency_links.txt +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/entry_points.txt +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/requires.txt +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/top_level.txt +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/setup.cfg +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/tests/__init__.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/tests/base_repository_test.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/tests/config_test.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/tests/migration_runner_test.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/tests/mysql_repository_test.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/tests/postgre_repository_test.py +0 -0
- {rococo-1.1.2 → rococo-1.1.3}/tests/surrealdb_repository_test.py +0 -0
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import pkgutil
|
|
2
2
|
import os
|
|
3
3
|
import importlib
|
|
4
|
+
import logging
|
|
4
5
|
from uuid import uuid4, UUID
|
|
5
6
|
from dataclasses import dataclass, field, fields, InitVar, is_dataclass
|
|
6
7
|
from datetime import datetime, timezone
|
|
8
|
+
from dateutil.parser import isoparse
|
|
7
9
|
from typing import Any, Dict, List, Union, get_type_hints, get_origin, get_args
|
|
8
10
|
from enum import Enum
|
|
9
11
|
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
10
14
|
# Constants for VersionedModel field groups
|
|
11
15
|
BIG_6_FIELDS = {'entity_id', 'version', 'previous_version',
|
|
12
16
|
'changed_on', 'changed_by_id', 'active'}
|
|
@@ -148,7 +152,8 @@ class VersionedModel:
|
|
|
148
152
|
for u in value[1:-1].split(',') if u.strip()]
|
|
149
153
|
setattr(self, f.name, uuid_list)
|
|
150
154
|
except ValueError:
|
|
151
|
-
|
|
155
|
+
logger.info(
|
|
156
|
+
f"Invalid UUIDs in list for field '{f.name}'")
|
|
152
157
|
|
|
153
158
|
def __getattribute__(self, name):
|
|
154
159
|
"""
|
|
@@ -244,7 +249,15 @@ class VersionedModel:
|
|
|
244
249
|
Allow setting extra fields directly as attributes.
|
|
245
250
|
If the field is not a defined model field and allow_extra is True,
|
|
246
251
|
store it in the extra dict.
|
|
252
|
+
Skip setting calculated properties (properties without setters).
|
|
247
253
|
"""
|
|
254
|
+
# Check if this is a calculated property (property without setter)
|
|
255
|
+
if not name.startswith('_'):
|
|
256
|
+
attr = getattr(type(self), name, None)
|
|
257
|
+
if isinstance(attr, property) and attr.fset is None:
|
|
258
|
+
# This is a calculated property (read-only), skip setting it
|
|
259
|
+
return
|
|
260
|
+
|
|
248
261
|
# Get model fields (but handle the case where fields() might not be available yet)
|
|
249
262
|
try:
|
|
250
263
|
model_fields = self.fields()
|
|
@@ -464,9 +477,9 @@ class VersionedModel:
|
|
|
464
477
|
clean_data[k] = UUID(
|
|
465
478
|
v).hex if v and not isinstance(v, UUID) else v
|
|
466
479
|
except ValueError:
|
|
467
|
-
|
|
480
|
+
logger.info(f"'{v}' is not a valid UUID.")
|
|
468
481
|
|
|
469
|
-
# Handle enum conversion from string values
|
|
482
|
+
# Handle enum conversion from string values and datetime conversion from ISO strings
|
|
470
483
|
elif v is not None:
|
|
471
484
|
expected_type = hints.get(k)
|
|
472
485
|
if expected_type:
|
|
@@ -483,6 +496,15 @@ class VersionedModel:
|
|
|
483
496
|
except ValueError:
|
|
484
497
|
# If the string value doesn't match any enum value, leave as is
|
|
485
498
|
pass
|
|
499
|
+
# Find datetime type in the Union for Optional[datetime]
|
|
500
|
+
datetime_type = next(
|
|
501
|
+
(arg for arg in args if arg is datetime), None)
|
|
502
|
+
if datetime_type and isinstance(v, str):
|
|
503
|
+
try:
|
|
504
|
+
clean_data[k] = isoparse(v)
|
|
505
|
+
except (ValueError, TypeError):
|
|
506
|
+
# If the string value can't be parsed as datetime, leave as is
|
|
507
|
+
pass
|
|
486
508
|
# Handle direct enum types
|
|
487
509
|
elif isinstance(expected_type, type) and issubclass(expected_type, Enum) and isinstance(v, str):
|
|
488
510
|
try:
|
|
@@ -490,6 +512,13 @@ class VersionedModel:
|
|
|
490
512
|
except ValueError:
|
|
491
513
|
# If the string value doesn't match any enum value, leave as is
|
|
492
514
|
pass
|
|
515
|
+
# Handle direct datetime types
|
|
516
|
+
elif expected_type is datetime and isinstance(v, str):
|
|
517
|
+
try:
|
|
518
|
+
clean_data[k] = isoparse(v)
|
|
519
|
+
except (ValueError, TypeError):
|
|
520
|
+
# If the string value can't be parsed as datetime, leave as is
|
|
521
|
+
pass
|
|
493
522
|
|
|
494
523
|
# Handle dataclass conversion from dict values
|
|
495
524
|
f = next((f for f in fields(cls) if f.name == k), None)
|
|
@@ -522,10 +551,19 @@ class VersionedModel:
|
|
|
522
551
|
# Create the instance
|
|
523
552
|
instance = cls(**clean_data)
|
|
524
553
|
|
|
525
|
-
# Set extra fields directly (not nested)
|
|
554
|
+
# Set extra fields directly (not nested), but skip calculated properties
|
|
526
555
|
if extra_data:
|
|
556
|
+
filtered_extra_data = {}
|
|
557
|
+
for k, v in extra_data.items():
|
|
558
|
+
# Check if this is a calculated property (property without setter)
|
|
559
|
+
attr = getattr(cls, k, None)
|
|
560
|
+
if isinstance(attr, property) and attr.fset is None:
|
|
561
|
+
# This is a calculated property (read-only), skip setting it
|
|
562
|
+
continue
|
|
563
|
+
filtered_extra_data[k] = v
|
|
564
|
+
|
|
527
565
|
# Replace the entire extra dict to avoid nesting
|
|
528
|
-
instance.extra =
|
|
566
|
+
instance.extra = filtered_extra_data
|
|
529
567
|
|
|
530
568
|
return instance
|
|
531
569
|
|
|
@@ -64,7 +64,7 @@ class MongoDbRepository(BaseRepository):
|
|
|
64
64
|
"""
|
|
65
65
|
instance.prepare_for_save(changed_by_id=self.user_id)
|
|
66
66
|
data = instance.as_dict(
|
|
67
|
-
convert_datetime_to_iso_string=
|
|
67
|
+
convert_datetime_to_iso_string=False,
|
|
68
68
|
convert_uuids=True,
|
|
69
69
|
export_properties=self.save_calculated_fields
|
|
70
70
|
)
|
{rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/surrealdb/person_organization_role_repository.py
RENAMED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import copy
|
|
2
|
+
import logging
|
|
2
3
|
from typing import List, Union
|
|
3
4
|
from rococo.repositories.surrealdb import SurrealDbRepository
|
|
4
5
|
from rococo.models.surrealdb import PersonOrganizationRole
|
|
5
6
|
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
6
9
|
|
|
7
10
|
class PersonOrganizationRoleRepository(SurrealDbRepository):
|
|
8
11
|
def __init__(self, adapter, message_adapter, message_queue_name):
|
|
9
|
-
super().__init__(adapter, PersonOrganizationRole,
|
|
12
|
+
super().__init__(adapter, PersonOrganizationRole,
|
|
13
|
+
message_adapter, message_queue_name)
|
|
10
14
|
|
|
11
15
|
def find_organizations_by_member(self, member_id: str, get_member: bool = False) -> List[PersonOrganizationRole]:
|
|
12
16
|
"""finds all the organizations that a Person identified by member_id is a member of
|
|
@@ -19,9 +23,10 @@ class PersonOrganizationRoleRepository(SurrealDbRepository):
|
|
|
19
23
|
List[PersonOrganizationRole]: found PersonOrganizationRole
|
|
20
24
|
"""
|
|
21
25
|
conditions = {"person": member_id}
|
|
22
|
-
fetch_related: List[str] = ["organization",
|
|
26
|
+
fetch_related: List[str] = ["organization",
|
|
27
|
+
"person"] if get_member else ["organization"]
|
|
23
28
|
return self.get_many(conditions, fetch_related=fetch_related)
|
|
24
|
-
|
|
29
|
+
|
|
25
30
|
def find_organizations_by_owner(self, owner_id: str, get_owner: bool = False) -> List[PersonOrganizationRole]:
|
|
26
31
|
"""finds all the organizations that a Person identified by member_id is an owner of
|
|
27
32
|
|
|
@@ -32,8 +37,10 @@ class PersonOrganizationRoleRepository(SurrealDbRepository):
|
|
|
32
37
|
Returns:
|
|
33
38
|
List[PersonOrganizationRole]: found PersonOrganizationRole-s
|
|
34
39
|
"""
|
|
35
|
-
|
|
40
|
+
logger.info(
|
|
41
|
+
f"[find_organizations_by_owner] owner_id: {owner_id}")
|
|
36
42
|
OWNER: str = "OWNER"
|
|
37
43
|
conditions = {"person": owner_id, "role": OWNER}
|
|
38
|
-
fetch_related: List[str] = ["organization",
|
|
39
|
-
|
|
44
|
+
fetch_related: List[str] = ["organization",
|
|
45
|
+
"person"] if get_owner else ["organization"]
|
|
46
|
+
return self.get_many(conditions, fetch_related=fetch_related)
|
|
@@ -369,11 +369,9 @@ def test_from_dict_uuid_validation():
|
|
|
369
369
|
model = VersionedModel.from_dict(valid_data)
|
|
370
370
|
assert model.entity_id == UUID(int=1).hex
|
|
371
371
|
|
|
372
|
-
# Test with invalid UUID string (should
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
model = VersionedModel.from_dict(invalid_data)
|
|
376
|
-
mock_print.assert_called_with("'invalid-uuid' is not a valid UUID.")
|
|
372
|
+
# Test with invalid UUID string (should log error but not crash)
|
|
373
|
+
invalid_data = {"entity_id": "invalid-uuid"}
|
|
374
|
+
model = VersionedModel.from_dict(invalid_data)
|
|
377
375
|
|
|
378
376
|
|
|
379
377
|
def test_from_dict_field_filtering():
|
|
@@ -2141,3 +2139,278 @@ def test_field_alias_inheritance():
|
|
|
2141
2139
|
restored = DerivedModelWithAlias.from_dict(result)
|
|
2142
2140
|
assert restored.base_field == "base_value"
|
|
2143
2141
|
assert restored.derived_field == "derived_value"
|
|
2142
|
+
|
|
2143
|
+
|
|
2144
|
+
# Tests for datetime parsing in from_dict()
|
|
2145
|
+
def test_datetime_parsing_from_iso_string():
|
|
2146
|
+
"""Test that datetime fields are correctly parsed from ISO strings in from_dict()"""
|
|
2147
|
+
|
|
2148
|
+
@dataclass
|
|
2149
|
+
class TestModelWithDatetime(VersionedModel):
|
|
2150
|
+
created_at: datetime = field(
|
|
2151
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2152
|
+
updated_at: datetime = field(
|
|
2153
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2154
|
+
|
|
2155
|
+
# Test data with datetime fields as ISO strings (simulating data from MongoDB)
|
|
2156
|
+
test_data = {
|
|
2157
|
+
'entity_id': 'test123',
|
|
2158
|
+
'version': 'version123',
|
|
2159
|
+
'previous_version': None,
|
|
2160
|
+
'active': True,
|
|
2161
|
+
'changed_by_id': 'user123',
|
|
2162
|
+
'changed_on': '2024-01-15T10:30:00+00:00', # ISO string
|
|
2163
|
+
'created_at': '2024-01-15T09:00:00+00:00', # ISO string
|
|
2164
|
+
'updated_at': '2024-01-15T11:00:00+00:00', # ISO string
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
# Create model instance from dict
|
|
2168
|
+
model = TestModelWithDatetime.from_dict(test_data)
|
|
2169
|
+
|
|
2170
|
+
# Verify that datetime strings were parsed correctly
|
|
2171
|
+
assert isinstance(
|
|
2172
|
+
model.changed_on, datetime), f"changed_on should be datetime, got {type(model.changed_on)}"
|
|
2173
|
+
assert isinstance(
|
|
2174
|
+
model.created_at, datetime), f"created_at should be datetime, got {type(model.created_at)}"
|
|
2175
|
+
assert isinstance(
|
|
2176
|
+
model.updated_at, datetime), f"updated_at should be datetime, got {type(model.updated_at)}"
|
|
2177
|
+
|
|
2178
|
+
# Verify the actual datetime values
|
|
2179
|
+
expected_changed_on = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
|
2180
|
+
expected_created_at = datetime(2024, 1, 15, 9, 0, 0, tzinfo=timezone.utc)
|
|
2181
|
+
expected_updated_at = datetime(2024, 1, 15, 11, 0, 0, tzinfo=timezone.utc)
|
|
2182
|
+
|
|
2183
|
+
assert model.changed_on == expected_changed_on, f"changed_on mismatch: {model.changed_on} != {expected_changed_on}"
|
|
2184
|
+
assert model.created_at == expected_created_at, f"created_at mismatch: {model.created_at} != {expected_created_at}"
|
|
2185
|
+
assert model.updated_at == expected_updated_at, f"updated_at mismatch: {model.updated_at} != {expected_updated_at}"
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
def test_datetime_parsing_optional_fields():
|
|
2189
|
+
"""Test datetime parsing works with Optional[datetime] fields"""
|
|
2190
|
+
from typing import Optional
|
|
2191
|
+
|
|
2192
|
+
@dataclass
|
|
2193
|
+
class TestModelWithOptionalDatetime(VersionedModel):
|
|
2194
|
+
created_at: datetime = field(
|
|
2195
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2196
|
+
updated_at: Optional[datetime] = None
|
|
2197
|
+
deleted_at: Optional[datetime] = None
|
|
2198
|
+
|
|
2199
|
+
# Test with ISO string for optional field
|
|
2200
|
+
test_data = {
|
|
2201
|
+
'entity_id': 'test123',
|
|
2202
|
+
'created_at': '2024-01-15T09:00:00+00:00',
|
|
2203
|
+
'updated_at': '2024-01-15T11:00:00+00:00', # ISO string
|
|
2204
|
+
'deleted_at': None # None value
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
model = TestModelWithOptionalDatetime.from_dict(test_data)
|
|
2208
|
+
|
|
2209
|
+
# Required datetime field should be parsed
|
|
2210
|
+
assert isinstance(model.created_at, datetime)
|
|
2211
|
+
assert model.created_at == datetime(
|
|
2212
|
+
2024, 1, 15, 9, 0, 0, tzinfo=timezone.utc)
|
|
2213
|
+
|
|
2214
|
+
# Optional datetime field with ISO string should be parsed
|
|
2215
|
+
assert isinstance(model.updated_at, datetime)
|
|
2216
|
+
assert model.updated_at == datetime(
|
|
2217
|
+
2024, 1, 15, 11, 0, 0, tzinfo=timezone.utc)
|
|
2218
|
+
|
|
2219
|
+
# Optional datetime field with None should remain None
|
|
2220
|
+
assert model.deleted_at is None
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def test_datetime_parsing_preserves_existing_datetime():
|
|
2224
|
+
"""Test that existing datetime objects are preserved unchanged"""
|
|
2225
|
+
|
|
2226
|
+
@dataclass
|
|
2227
|
+
class TestModelWithDatetime(VersionedModel):
|
|
2228
|
+
created_at: datetime = field(
|
|
2229
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2230
|
+
|
|
2231
|
+
# Test with already datetime object
|
|
2232
|
+
existing_datetime = datetime(2024, 1, 15, 9, 0, 0, tzinfo=timezone.utc)
|
|
2233
|
+
test_data = {
|
|
2234
|
+
'entity_id': 'test123',
|
|
2235
|
+
'created_at': existing_datetime # Already a datetime object
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
model = TestModelWithDatetime.from_dict(test_data)
|
|
2239
|
+
|
|
2240
|
+
# Datetime object should be preserved unchanged
|
|
2241
|
+
assert isinstance(model.created_at, datetime)
|
|
2242
|
+
assert model.created_at == existing_datetime
|
|
2243
|
+
assert model.created_at is existing_datetime # Should be the same object
|
|
2244
|
+
|
|
2245
|
+
|
|
2246
|
+
def test_datetime_parsing_invalid_strings():
|
|
2247
|
+
"""Test that invalid datetime strings are left unchanged"""
|
|
2248
|
+
|
|
2249
|
+
@dataclass
|
|
2250
|
+
class TestModelWithDatetime(VersionedModel):
|
|
2251
|
+
created_at: datetime = field(
|
|
2252
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2253
|
+
|
|
2254
|
+
# Test with invalid datetime string
|
|
2255
|
+
test_data = {
|
|
2256
|
+
'entity_id': 'test123',
|
|
2257
|
+
'created_at': 'invalid-date-string'
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
model = TestModelWithDatetime.from_dict(test_data)
|
|
2261
|
+
|
|
2262
|
+
# Invalid datetime string should be left as-is
|
|
2263
|
+
assert model.created_at == 'invalid-date-string'
|
|
2264
|
+
assert isinstance(model.created_at, str)
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
def test_datetime_parsing_various_iso_formats():
|
|
2268
|
+
"""Test datetime parsing works with various ISO format strings"""
|
|
2269
|
+
|
|
2270
|
+
@dataclass
|
|
2271
|
+
class TestModelWithDatetime(VersionedModel):
|
|
2272
|
+
datetime1: datetime = field(
|
|
2273
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2274
|
+
datetime2: datetime = field(
|
|
2275
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2276
|
+
datetime3: datetime = field(
|
|
2277
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2278
|
+
datetime4: datetime = field(
|
|
2279
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2280
|
+
|
|
2281
|
+
# Test with various ISO format strings
|
|
2282
|
+
test_data = {
|
|
2283
|
+
'entity_id': 'test123',
|
|
2284
|
+
'datetime1': '2024-01-15T10:30:00Z', # UTC with Z
|
|
2285
|
+
'datetime2': '2024-01-15T10:30:00+00:00', # UTC with +00:00
|
|
2286
|
+
'datetime3': '2024-01-15T10:30:00.123456Z', # With microseconds
|
|
2287
|
+
'datetime4': '2024-01-15T10:30:00-05:00', # With timezone offset
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
model = TestModelWithDatetime.from_dict(test_data)
|
|
2291
|
+
|
|
2292
|
+
# All should be parsed as datetime objects
|
|
2293
|
+
assert isinstance(model.datetime1, datetime)
|
|
2294
|
+
assert isinstance(model.datetime2, datetime)
|
|
2295
|
+
assert isinstance(model.datetime3, datetime)
|
|
2296
|
+
assert isinstance(model.datetime4, datetime)
|
|
2297
|
+
|
|
2298
|
+
# Verify specific values
|
|
2299
|
+
assert model.datetime1 == datetime(
|
|
2300
|
+
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
|
2301
|
+
assert model.datetime2 == datetime(
|
|
2302
|
+
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
|
2303
|
+
assert model.datetime3.year == 2024
|
|
2304
|
+
assert model.datetime3.month == 1
|
|
2305
|
+
assert model.datetime3.day == 15
|
|
2306
|
+
assert model.datetime3.hour == 10
|
|
2307
|
+
assert model.datetime3.minute == 30
|
|
2308
|
+
assert model.datetime3.second == 0
|
|
2309
|
+
assert model.datetime3.microsecond == 123456
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
def test_datetime_parsing_roundtrip_consistency():
|
|
2313
|
+
"""Test that datetime parsing maintains consistency through roundtrip conversion"""
|
|
2314
|
+
|
|
2315
|
+
@dataclass
|
|
2316
|
+
class TestModelWithDatetime(VersionedModel):
|
|
2317
|
+
created_at: datetime = field(
|
|
2318
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2319
|
+
updated_at: datetime = field(
|
|
2320
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2321
|
+
|
|
2322
|
+
# Create original model with datetime objects
|
|
2323
|
+
original_created = datetime(2024, 1, 15, 9, 0, 0, tzinfo=timezone.utc)
|
|
2324
|
+
original_updated = datetime(2024, 1, 15, 11, 30, 45, tzinfo=timezone.utc)
|
|
2325
|
+
|
|
2326
|
+
original = TestModelWithDatetime(
|
|
2327
|
+
created_at=original_created,
|
|
2328
|
+
updated_at=original_updated
|
|
2329
|
+
)
|
|
2330
|
+
|
|
2331
|
+
# Convert to dict with ISO strings
|
|
2332
|
+
dict_data = original.as_dict(convert_datetime_to_iso_string=True)
|
|
2333
|
+
|
|
2334
|
+
# Verify datetime fields are converted to ISO strings
|
|
2335
|
+
assert isinstance(dict_data['created_at'], str)
|
|
2336
|
+
assert isinstance(dict_data['updated_at'], str)
|
|
2337
|
+
|
|
2338
|
+
# Convert back from dict (should parse ISO strings back to datetime)
|
|
2339
|
+
restored = TestModelWithDatetime.from_dict(dict_data)
|
|
2340
|
+
|
|
2341
|
+
# Verify datetime objects are correctly restored
|
|
2342
|
+
assert isinstance(restored.created_at, datetime)
|
|
2343
|
+
assert isinstance(restored.updated_at, datetime)
|
|
2344
|
+
assert restored.created_at == original_created
|
|
2345
|
+
assert restored.updated_at == original_updated
|
|
2346
|
+
|
|
2347
|
+
|
|
2348
|
+
def test_datetime_parsing_with_other_field_types():
|
|
2349
|
+
"""Test that datetime parsing works alongside other field type conversions"""
|
|
2350
|
+
from enum import Enum
|
|
2351
|
+
from typing import Optional
|
|
2352
|
+
|
|
2353
|
+
class Status(Enum):
|
|
2354
|
+
active = "active"
|
|
2355
|
+
inactive = "inactive"
|
|
2356
|
+
|
|
2357
|
+
@dataclass
|
|
2358
|
+
class ComplexModelWithDatetime(VersionedModel):
|
|
2359
|
+
status: Optional[Status] = Status.active
|
|
2360
|
+
created_at: datetime = field(
|
|
2361
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2362
|
+
score: int = 100
|
|
2363
|
+
name: str = "test"
|
|
2364
|
+
|
|
2365
|
+
# Test data with mixed field types including datetime as ISO string
|
|
2366
|
+
test_data = {
|
|
2367
|
+
'entity_id': 'test123',
|
|
2368
|
+
'status': 'inactive', # Should become enum
|
|
2369
|
+
'created_at': '2024-01-15T09:00:00+00:00', # Should become datetime
|
|
2370
|
+
'score': 95, # Should remain int
|
|
2371
|
+
'name': 'test_model' # Should remain string
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
model = ComplexModelWithDatetime.from_dict(test_data)
|
|
2375
|
+
|
|
2376
|
+
# Verify all field types are correctly converted
|
|
2377
|
+
assert model.status == Status.inactive
|
|
2378
|
+
assert isinstance(model.status, Status)
|
|
2379
|
+
|
|
2380
|
+
assert isinstance(model.created_at, datetime)
|
|
2381
|
+
assert model.created_at == datetime(
|
|
2382
|
+
2024, 1, 15, 9, 0, 0, tzinfo=timezone.utc)
|
|
2383
|
+
|
|
2384
|
+
assert model.score == 95
|
|
2385
|
+
assert isinstance(model.score, int)
|
|
2386
|
+
|
|
2387
|
+
assert model.name == 'test_model'
|
|
2388
|
+
assert isinstance(model.name, str)
|
|
2389
|
+
|
|
2390
|
+
|
|
2391
|
+
def test_datetime_parsing_none_values():
|
|
2392
|
+
"""Test that None values for datetime fields are handled correctly"""
|
|
2393
|
+
from typing import Optional
|
|
2394
|
+
|
|
2395
|
+
@dataclass
|
|
2396
|
+
class TestModelWithOptionalDatetime(VersionedModel):
|
|
2397
|
+
required_datetime: datetime = field(
|
|
2398
|
+
default_factory=lambda: datetime.now(timezone.utc))
|
|
2399
|
+
optional_datetime: Optional[datetime] = None
|
|
2400
|
+
|
|
2401
|
+
# Test with None values
|
|
2402
|
+
test_data = {
|
|
2403
|
+
'entity_id': 'test123',
|
|
2404
|
+
'required_datetime': '2024-01-15T09:00:00+00:00',
|
|
2405
|
+
'optional_datetime': None
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
model = TestModelWithOptionalDatetime.from_dict(test_data)
|
|
2409
|
+
|
|
2410
|
+
# Required datetime should be parsed from string
|
|
2411
|
+
assert isinstance(model.required_datetime, datetime)
|
|
2412
|
+
assert model.required_datetime == datetime(
|
|
2413
|
+
2024, 1, 15, 9, 0, 0, tzinfo=timezone.utc)
|
|
2414
|
+
|
|
2415
|
+
# Optional datetime should remain None
|
|
2416
|
+
assert model.optional_datetime is None
|