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.
Files changed (91) hide show
  1. {rococo-1.1.2 → rococo-1.1.3}/PKG-INFO +1 -1
  2. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/versioned_model.py +43 -5
  3. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mongodb/mongodb_repository.py +1 -1
  4. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/surrealdb/person_organization_role_repository.py +13 -6
  5. {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/PKG-INFO +1 -1
  6. {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/SOURCES.txt +2 -1
  7. {rococo-1.1.2 → rococo-1.1.3}/setup.py +1 -1
  8. {rococo-1.1.2 → rococo-1.1.3}/tests/model_test.py +278 -5
  9. {rococo-1.1.2 → rococo-1.1.3}/tests/mongodb_repository_test.py +441 -18
  10. rococo-1.1.3/tests/test_calculated_properties_fix.py +143 -0
  11. {rococo-1.1.2 → rococo-1.1.3}/LICENSE +0 -0
  12. {rococo-1.1.2 → rococo-1.1.3}/README.md +0 -0
  13. {rococo-1.1.2 → rococo-1.1.3}/rococo/__init__.py +0 -0
  14. {rococo-1.1.2 → rococo-1.1.3}/rococo/auth/__init__.py +0 -0
  15. {rococo-1.1.2 → rococo-1.1.3}/rococo/auth/tokens.py +0 -0
  16. {rococo-1.1.2 → rococo-1.1.3}/rococo/config/__init__.py +0 -0
  17. {rococo-1.1.2 → rococo-1.1.3}/rococo/config/config.py +0 -0
  18. {rococo-1.1.2 → rococo-1.1.3}/rococo/data/__init__.py +0 -0
  19. {rococo-1.1.2 → rococo-1.1.3}/rococo/data/base.py +0 -0
  20. {rococo-1.1.2 → rococo-1.1.3}/rococo/data/mongodb.py +0 -0
  21. {rococo-1.1.2 → rococo-1.1.3}/rococo/data/mysql.py +0 -0
  22. {rococo-1.1.2 → rococo-1.1.3}/rococo/data/postgresql.py +0 -0
  23. {rococo-1.1.2 → rococo-1.1.3}/rococo/data/surrealdb.py +0 -0
  24. {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/__init__.py +0 -0
  25. {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/base.py +0 -0
  26. {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/config.py +0 -0
  27. {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/enums.py +0 -0
  28. {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/factory.py +0 -0
  29. {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/mailjet.py +0 -0
  30. {rococo-1.1.2 → rococo-1.1.3}/rococo/emailing/ses.py +0 -0
  31. {rococo-1.1.2 → rococo-1.1.3}/rococo/messaging/__init__.py +0 -0
  32. {rococo-1.1.2 → rococo-1.1.3}/rococo/messaging/base.py +0 -0
  33. {rococo-1.1.2 → rococo-1.1.3}/rococo/messaging/rabbitmq.py +0 -0
  34. {rococo-1.1.2 → rococo-1.1.3}/rococo/messaging/sqs.py +0 -0
  35. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/__init__.py +0 -0
  36. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/__init__.py +0 -0
  37. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/cli_base.py +0 -0
  38. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/migration_base.py +0 -0
  39. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/migration_runner.py +0 -0
  40. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/common/migration_template.py +0 -0
  41. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mongo/__init__.py +0 -0
  42. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mongo/cli.py +0 -0
  43. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mongo/migration.py +0 -0
  44. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mysql/__init__.py +0 -0
  45. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mysql/cli.py +0 -0
  46. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/mysql/migration.py +0 -0
  47. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/postgres/__init__.py +0 -0
  48. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/postgres/cli.py +0 -0
  49. {rococo-1.1.2 → rococo-1.1.3}/rococo/migrations/postgres/migration.py +0 -0
  50. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/__init__.py +0 -0
  51. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/email.py +0 -0
  52. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/login_method.py +0 -0
  53. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/non_versioned_model.py +0 -0
  54. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/organization.py +0 -0
  55. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/otp_method.py +0 -0
  56. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/person.py +0 -0
  57. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/person_organization_role.py +0 -0
  58. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/recovery_code.py +0 -0
  59. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/__init__.py +0 -0
  60. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/email.py +0 -0
  61. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/login_method.py +0 -0
  62. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/organization.py +0 -0
  63. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/otp_method.py +0 -0
  64. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/person.py +0 -0
  65. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/person_organization_role.py +0 -0
  66. {rococo-1.1.2 → rococo-1.1.3}/rococo/models/surrealdb/surreal_versioned_model.py +0 -0
  67. {rococo-1.1.2 → rococo-1.1.3}/rococo/plugins/__init__.py +0 -0
  68. {rococo-1.1.2 → rococo-1.1.3}/rococo/plugins/pooled_connection.py +0 -0
  69. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/__init__.py +0 -0
  70. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/base_repository.py +0 -0
  71. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mongodb/__init__.py +0 -0
  72. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mysql/__init__.py +0 -0
  73. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mysql/mysql_repository.py +0 -0
  74. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/mysql/organization_repository.py +0 -0
  75. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/postgresql/__init__.py +0 -0
  76. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/postgresql/postgresql_repository.py +0 -0
  77. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/surrealdb/__init__.py +0 -0
  78. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/surrealdb/organization_repository.py +0 -0
  79. {rococo-1.1.2 → rococo-1.1.3}/rococo/repositories/surrealdb/surreal_db_repository.py +0 -0
  80. {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/dependency_links.txt +0 -0
  81. {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/entry_points.txt +0 -0
  82. {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/requires.txt +0 -0
  83. {rococo-1.1.2 → rococo-1.1.3}/rococo.egg-info/top_level.txt +0 -0
  84. {rococo-1.1.2 → rococo-1.1.3}/setup.cfg +0 -0
  85. {rococo-1.1.2 → rococo-1.1.3}/tests/__init__.py +0 -0
  86. {rococo-1.1.2 → rococo-1.1.3}/tests/base_repository_test.py +0 -0
  87. {rococo-1.1.2 → rococo-1.1.3}/tests/config_test.py +0 -0
  88. {rococo-1.1.2 → rococo-1.1.3}/tests/migration_runner_test.py +0 -0
  89. {rococo-1.1.2 → rococo-1.1.3}/tests/mysql_repository_test.py +0 -0
  90. {rococo-1.1.2 → rococo-1.1.3}/tests/postgre_repository_test.py +0 -0
  91. {rococo-1.1.2 → rococo-1.1.3}/tests/surrealdb_repository_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rococo
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: A Python library to help build things the way we want them built
5
5
  Home-page: https://github.com/EcorRouge/rococo
6
6
  Author: Jay Grieves
@@ -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
- print(f"Invalid UUIDs in list for field '{f.name}'")
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
- print(f"'{v}' is not a valid UUID.")
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 = extra_data
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=True,
67
+ convert_datetime_to_iso_string=False,
68
68
  convert_uuids=True,
69
69
  export_properties=self.save_calculated_fields
70
70
  )
@@ -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, message_adapter, message_queue_name)
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", "person"] if get_member else ["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
- print(f"[find_organizations_by_owner] owner_id: {owner_id}", flush=True)
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", "person"] if get_owner else ["organization"]
39
- return self.get_many(conditions, fetch_related=fetch_related)
44
+ fetch_related: List[str] = ["organization",
45
+ "person"] if get_owner else ["organization"]
46
+ return self.get_many(conditions, fetch_related=fetch_related)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rococo
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: A Python library to help build things the way we want them built
5
5
  Home-page: https://github.com/EcorRouge/rococo
6
6
  Author: Jay Grieves
@@ -85,4 +85,5 @@ tests/model_test.py
85
85
  tests/mongodb_repository_test.py
86
86
  tests/mysql_repository_test.py
87
87
  tests/postgre_repository_test.py
88
- tests/surrealdb_repository_test.py
88
+ tests/surrealdb_repository_test.py
89
+ tests/test_calculated_properties_fix.py
@@ -48,7 +48,7 @@ extras_require["all"] = [
48
48
 
49
49
  setup(
50
50
  name='rococo',
51
- version='1.1.2',
51
+ version='1.1.3',
52
52
  packages=find_packages(),
53
53
  url='https://github.com/EcorRouge/rococo',
54
54
  license='MIT',
@@ -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 print error but not crash)
373
- with patch('builtins.print') as mock_print:
374
- invalid_data = {"entity_id": "invalid-uuid"}
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