plexus-python-common 1.0.65__py3-none-any.whl → 1.0.67__py3-none-any.whl

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.
@@ -21,7 +21,9 @@ __all__ = [
21
21
  ]
22
22
 
23
23
 
24
- def json_datetime_decoder(v: Any) -> datetime.datetime:
24
+ def json_datetime_decoder(v: Any) -> datetime.datetime | None:
25
+ if v is None:
26
+ return None
25
27
  if isinstance(v, str):
26
28
  return json_datetime_decoder(dt_parse(v, extended_format(with_us=True, with_tz=True)))
27
29
  if isinstance(v, datetime.datetime):
@@ -29,7 +31,9 @@ def json_datetime_decoder(v: Any) -> datetime.datetime:
29
31
  raise ValueError("unexpected type of value for datetime decoder")
30
32
 
31
33
 
32
- def json_datetime_encoder(v: Any) -> str:
34
+ def json_datetime_encoder(v: Any) -> str | None:
35
+ if v is None:
36
+ return None
33
37
  if isinstance(v, str):
34
38
  return json_datetime_encoder(dt_parse(v, extended_format(with_us=True, with_tz=True)))
35
39
  if isinstance(v, datetime.datetime):
@@ -28,6 +28,10 @@ __all__ = [
28
28
  "ChangingModelMixinProtocol",
29
29
  "SnapshotModelMixinProtocol",
30
30
  "RevisionModelMixinProtocol",
31
+ "SQLiteDateTime",
32
+ "model_sqn_type",
33
+ "model_datetime_tz_type",
34
+ "model_revision_type",
31
35
  "SequenceModelMixin",
32
36
  "ChangingModelMixin",
33
37
  "SnapshotModelMixin",
@@ -391,12 +395,26 @@ class RevisionModelMixinProtocol(SequenceModelMixinProtocol):
391
395
  ...
392
396
 
393
397
 
394
- # At the present time, we cannot express intersection of Protocol and SQLModel directly.
395
- # Thus, we define union types here for the mixins.
396
- SequenceModelMixin = SequenceModelMixinProtocol | SQLModel
397
- ChangingModelMixin = ChangingModelMixinProtocol | SQLModel
398
- SnapshotModelMixin = SnapshotModelMixinProtocol | SQLModel
399
- RevisionModelMixin = RevisionModelMixinProtocol | SQLModel
398
+ class SQLiteDateTime(sa.TypeDecorator):
399
+ """
400
+ Custom SQLAlchemy type decorator for handling timezone-aware datetimes in SQLite.
401
+ """
402
+ impl = sa.DateTime
403
+ cache_ok = True
404
+
405
+ def process_bind_param(self, value: datetime.datetime | None, dialect) -> datetime.datetime | None:
406
+ if value is None:
407
+ return None
408
+ if value.tzinfo is None:
409
+ return value.replace(tzinfo=datetime.timezone.utc)
410
+ if value.tzinfo == datetime.timezone.utc:
411
+ return value
412
+ raise ValueError("Only UTC timezone-aware datetimes are supported")
413
+
414
+ def process_result_value(self, value: datetime.datetime | None, dialect) -> datetime.datetime | None:
415
+ if value is None:
416
+ return None
417
+ return value.replace(tzinfo=datetime.timezone.utc)
400
418
 
401
419
 
402
420
  def model_sqn_type(dialect: str | None = None) -> sa.types.TypeEngine[int]:
@@ -428,7 +446,7 @@ def model_datetime_tz_type(dialect: str | None = None) -> sa.types.TypeEngine[da
428
446
  if dialect == dialects.postgresql:
429
447
  return sa_pg.TIMESTAMP(timezone=True)
430
448
  if dialect == dialects.sqlite:
431
- return sa_sqlite.TIMESTAMP(timezone=True)
449
+ return SQLiteDateTime
432
450
  raise ValueError(f"unsupported database dialect '{dialect}'")
433
451
 
434
452
 
@@ -448,6 +466,14 @@ def model_revision_type(dialect: str | None = None) -> sa.types.TypeEngine[int]:
448
466
  raise ValueError(f"unsupported database dialect '{dialect}'")
449
467
 
450
468
 
469
+ # At the present time, we cannot express intersection of Protocol and SQLModel directly.
470
+ # Thus, we define union types here for the mixins.
471
+ SequenceModelMixin = SequenceModelMixinProtocol | pdt.BaseModel
472
+ ChangingModelMixin = ChangingModelMixinProtocol | pdt.BaseModel
473
+ SnapshotModelMixin = SnapshotModelMixinProtocol | pdt.BaseModel
474
+ RevisionModelMixin = RevisionModelMixinProtocol | pdt.BaseModel
475
+
476
+
451
477
  def make_sequence_model_mixin(dialect: str | None = None) -> type[SequenceModelMixin]:
452
478
  """
453
479
  Creates a mixin class for SQLModel models that adds a unique identifier field ``sqn``.
@@ -457,7 +483,7 @@ def make_sequence_model_mixin(dialect: str | None = None) -> type[SequenceModelM
457
483
  :return: A mixin class that can be used with SQLModel models to add the ``sqn`` field.
458
484
  """
459
485
 
460
- class ModelMixin(SQLModel):
486
+ class ModelMixin(pdt.BaseModel):
461
487
  sqn: int | None = Field(
462
488
  sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
463
489
  default=None,
@@ -477,7 +503,7 @@ def make_changing_model_mixin(dialect: str | None = None) -> type[ChangingModelM
477
503
  updatable records.
478
504
  """
479
505
 
480
- class ModelMixin(SQLModel):
506
+ class ModelMixin(pdt.BaseModel):
481
507
  sqn: int | None = Field(
482
508
  sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
483
509
  default=None,
@@ -496,14 +522,14 @@ def make_changing_model_mixin(dialect: str | None = None) -> type[ChangingModelM
496
522
 
497
523
  @pdt.field_validator("created_at", mode="after")
498
524
  @classmethod
499
- def validate_created_at(cls, v: datetime.datetime) -> datetime.datetime:
525
+ def validate_created_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
500
526
  if v is not None:
501
527
  validate_dt_timezone(v)
502
528
  return v
503
529
 
504
530
  @pdt.field_validator("updated_at", mode="after")
505
531
  @classmethod
506
- def validate_updated_at(cls, v: datetime.datetime) -> datetime.datetime:
532
+ def validate_updated_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
507
533
  if v is not None:
508
534
  validate_dt_timezone(v)
509
535
  return v
@@ -547,7 +573,7 @@ def make_snapshot_model_mixin(dialect: str | None = None) -> type[SnapshotModelM
547
573
  record snapshots.
548
574
  """
549
575
 
550
- class ModelMixin(SQLModel):
576
+ class ModelMixin(pdt.BaseModel):
551
577
  sqn: int | None = Field(
552
578
  sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
553
579
  default=None,
@@ -571,14 +597,14 @@ def make_snapshot_model_mixin(dialect: str | None = None) -> type[SnapshotModelM
571
597
 
572
598
  @pdt.field_validator("created_at", mode="after")
573
599
  @classmethod
574
- def validate_created_at(cls, v: datetime.datetime) -> datetime.datetime:
600
+ def validate_created_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
575
601
  if v is not None:
576
602
  validate_dt_timezone(v)
577
603
  return v
578
604
 
579
605
  @pdt.field_validator("expired_at", mode="after")
580
606
  @classmethod
581
- def validate_expired_at(cls, v: datetime.datetime) -> datetime.datetime:
607
+ def validate_expired_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
582
608
  if v is not None:
583
609
  validate_dt_timezone(v)
584
610
  return v
@@ -650,7 +676,7 @@ def make_revision_model_mixin(dialect: str | None = None) -> type[RevisionModelM
650
676
  record revisions.
651
677
  """
652
678
 
653
- class ModelMixin(SQLModel):
679
+ class ModelMixin(pdt.BaseModel):
654
680
  sqn: int | None = Field(
655
681
  sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
656
682
  default=None,
@@ -684,28 +710,28 @@ def make_revision_model_mixin(dialect: str | None = None) -> type[RevisionModelM
684
710
 
685
711
  @pdt.field_validator("created_at", mode="after")
686
712
  @classmethod
687
- def validate_created_at(cls, v: datetime.datetime) -> datetime.datetime:
713
+ def validate_created_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
688
714
  if v is not None:
689
715
  validate_dt_timezone(v)
690
716
  return v
691
717
 
692
718
  @pdt.field_validator("updated_at", mode="after")
693
719
  @classmethod
694
- def validate_updated_at(cls, v: datetime.datetime) -> datetime.datetime:
720
+ def validate_updated_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
695
721
  if v is not None:
696
722
  validate_dt_timezone(v)
697
723
  return v
698
724
 
699
725
  @pdt.field_validator("expired_at", mode="after")
700
726
  @classmethod
701
- def validate_expired_at(cls, v: datetime.datetime) -> datetime.datetime:
727
+ def validate_expired_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
702
728
  if v is not None:
703
729
  validate_dt_timezone(v)
704
730
  return v
705
731
 
706
732
  @pdt.field_validator("revision", mode="after")
707
733
  @classmethod
708
- def validate_revision(cls, v: int) -> int:
734
+ def validate_revision(cls, v: int | None) -> int | None:
709
735
  if v is not None and not v > 0:
710
736
  raise ValueError("revision number must be positive integer")
711
737
  return v
@@ -941,11 +967,12 @@ def clone_sequence_model_instance[SequenceModelT: SequenceModelMixin](
941
967
  model: type[SequenceModelT],
942
968
  instance: SequenceModelMixin,
943
969
  *,
970
+ validate_only: bool = False,
944
971
  clear_meta_fields: bool = True,
945
- inplace: bool = False,
946
972
  ) -> SequenceModelT:
947
973
  result = model.model_validate(instance)
948
- result = instance if inplace else result
974
+ if validate_only:
975
+ return instance
949
976
  if clear_meta_fields:
950
977
  result.sqn = None
951
978
  return result
@@ -955,11 +982,12 @@ def clone_changing_model_instance[ChangingModelT: ChangingModelMixin](
955
982
  model: type[ChangingModelT],
956
983
  instance: ChangingModelMixin,
957
984
  *,
985
+ validate_only: bool = False,
958
986
  clear_meta_fields: bool = True,
959
- inplace: bool = False,
960
987
  ) -> ChangingModelT:
961
988
  result = model.model_validate(instance)
962
- result = instance if inplace else result
989
+ if validate_only:
990
+ return instance
963
991
  if clear_meta_fields:
964
992
  result.sqn = None
965
993
  result.created_at = None
@@ -971,11 +999,12 @@ def clone_snapshot_model_instance[SnapshotModelT: SnapshotModelMixin](
971
999
  model: type[SnapshotModelT],
972
1000
  instance: SnapshotModelMixin,
973
1001
  *,
1002
+ validate_only: bool = False,
974
1003
  clear_meta_fields: bool = True,
975
- inplace: bool = False,
976
1004
  ) -> SnapshotModelT:
977
1005
  result = model.model_validate(instance)
978
- result = instance if inplace else result
1006
+ if validate_only:
1007
+ return instance
979
1008
  if clear_meta_fields:
980
1009
  result.sqn = None
981
1010
  result.created_at = None
@@ -988,11 +1017,12 @@ def clone_revision_model_instance[RevisionModelT: RevisionModelMixin](
988
1017
  model: type[RevisionModelT],
989
1018
  instance: RevisionModelMixin,
990
1019
  *,
1020
+ validate_only: bool = False,
991
1021
  clear_meta_fields: bool = True,
992
- inplace: bool = False,
993
1022
  ) -> RevisionModelT:
994
1023
  result = model.model_validate(instance)
995
- result = instance if inplace else result
1024
+ if validate_only:
1025
+ return instance
996
1026
  if clear_meta_fields:
997
1027
  result.sqn = None
998
1028
  result.created_at = None
@@ -1081,7 +1111,7 @@ def db_update_sequence_model[SequenceModelT: SequenceModelMixin](
1081
1111
  raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified sqn '{sqn}' not found")
1082
1112
 
1083
1113
  db_instance = model_copy_from(db_instance, clone_sequence_model_instance(model, instance), exclude_none=True)
1084
- db_instance = clone_sequence_model_instance(model, db_instance, clear_meta_fields=False, inplace=True)
1114
+ clone_sequence_model_instance(model, db_instance, validate_only=True)
1085
1115
  db.flush()
1086
1116
 
1087
1117
  return db_instance
@@ -1146,7 +1176,7 @@ def db_update_changing_model[ChangingModelT: ChangingModelMixin](
1146
1176
 
1147
1177
  db_instance = model_copy_from(db_instance, clone_changing_model_instance(model, instance), exclude_none=True)
1148
1178
  db_instance.updated_at = updated_at
1149
- db_instance = clone_changing_model_instance(model, db_instance, clear_meta_fields=False, inplace=True)
1179
+ clone_changing_model_instance(model, db_instance, validate_only=True)
1150
1180
  db.flush()
1151
1181
 
1152
1182
  return db_instance
@@ -1304,13 +1334,14 @@ def db_update_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1304
1334
  raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1305
1335
 
1306
1336
  db_instance.expired_at = updated_at
1307
- db_instance = clone_snapshot_model_instance(model, db_instance, clear_meta_fields=False, inplace=True)
1337
+ clone_snapshot_model_instance(model, db_instance, validate_only=True)
1308
1338
  db.flush()
1309
1339
 
1310
1340
  db_new_instance = clone_snapshot_model_instance(model, instance)
1311
1341
  db_new_instance.record_sqn = record_sqn
1312
1342
  db_new_instance.created_at = updated_at
1313
1343
  db_new_instance.expired_at = None
1344
+ clone_snapshot_model_instance(model, db_new_instance, validate_only=True)
1314
1345
  db.add(db_new_instance)
1315
1346
  db.flush()
1316
1347
 
@@ -1333,7 +1364,7 @@ def db_expire_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1333
1364
  raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1334
1365
 
1335
1366
  db_instance.expired_at = updated_at
1336
- db_instance = clone_snapshot_model_instance(model, db_instance, clear_meta_fields=False, inplace=True)
1367
+ clone_snapshot_model_instance(model, db_instance, validate_only=True)
1337
1368
  db.flush()
1338
1369
 
1339
1370
  return db_instance
@@ -1364,9 +1395,10 @@ def db_activate_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1364
1395
  db_new_instance.record_sqn = record_sqn
1365
1396
  db_new_instance.created_at = db_instance.expired_at
1366
1397
  db_new_instance.expired_at = updated_at
1367
- db_new_instance = clone_snapshot_model_instance(model, db_new_instance, clear_meta_fields=False, inplace=True)
1398
+ clone_snapshot_model_instance(model, db_new_instance, validate_only=True)
1368
1399
  db_new_instance.created_at = updated_at
1369
1400
  db_new_instance.expired_at = None
1401
+ clone_snapshot_model_instance(model, db_new_instance, validate_only=True)
1370
1402
  db.add(db_new_instance)
1371
1403
  db.flush()
1372
1404
 
@@ -1529,7 +1561,7 @@ def db_update_revision_model[RevisionModelT: RevisionModelMixin](
1529
1561
  raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1530
1562
 
1531
1563
  db_instance.expired_at = updated_at
1532
- db_instance = clone_revision_model_instance(model, db_instance, clear_meta_fields=False, inplace=True)
1564
+ clone_revision_model_instance(model, db_instance, validate_only=True)
1533
1565
  db.flush()
1534
1566
 
1535
1567
  db_new_instance = clone_revision_model_instance(model, instance)
@@ -1538,6 +1570,7 @@ def db_update_revision_model[RevisionModelT: RevisionModelMixin](
1538
1570
  db_new_instance.updated_at = updated_at
1539
1571
  db_new_instance.expired_at = None
1540
1572
  db_new_instance.revision = db_instance.revision + 1
1573
+ clone_revision_model_instance(model, db_new_instance, validate_only=True)
1541
1574
  db.add(db_new_instance)
1542
1575
  db.flush()
1543
1576
 
@@ -1560,7 +1593,7 @@ def db_expire_revision_model[RevisionModelT: RevisionModelMixin](
1560
1593
  raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1561
1594
 
1562
1595
  db_instance.expired_at = updated_at
1563
- db_instance = clone_revision_model_instance(model, db_instance, clear_meta_fields=False, inplace=True)
1596
+ clone_revision_model_instance(model, db_instance, validate_only=True)
1564
1597
  db.flush()
1565
1598
 
1566
1599
  return db_instance
@@ -1593,9 +1626,10 @@ def db_activate_revision_model[RevisionModelT: RevisionModelMixin](
1593
1626
  db_new_instance.updated_at = db_instance.expired_at
1594
1627
  db_new_instance.expired_at = updated_at
1595
1628
  db_new_instance.revision = db_instance.revision + 1
1596
- db_new_instance = clone_revision_model_instance(model, db_new_instance, clear_meta_fields=False, inplace=True)
1629
+ clone_revision_model_instance(model, db_new_instance, validate_only=True)
1597
1630
  db_new_instance.updated_at = updated_at
1598
1631
  db_new_instance.expired_at = None
1632
+ clone_revision_model_instance(model, db_new_instance, validate_only=True)
1599
1633
  db.add(db_new_instance)
1600
1634
  db.flush()
1601
1635
 
@@ -27,12 +27,13 @@ from iker.common.utils.iterutils import dicttree_children, dicttree_lineage, dic
27
27
  from iker.common.utils.jsonutils import JsonObject, JsonType
28
28
  from iker.common.utils.randutils import randomizer
29
29
  from iker.common.utils.strutils import is_blank
30
- from sqlmodel import Field, SQLModel
30
+ from sqlmodel import Field
31
31
 
32
32
  from plexus.common.resources.tags import predefined_tagset_specs
33
33
  from plexus.common.utils.datautils import validate_colon_tag, validate_snake_case, validate_vehicle_name
34
34
  from plexus.common.utils.datautils import validate_dt_timezone, validate_semver, validate_slash_tag
35
35
  from plexus.common.utils.jsonutils import json_datetime_encoder
36
+ from plexus.common.utils.ormutils import SQLiteDateTime
36
37
  from plexus.common.utils.ormutils import SequenceModelMixinProtocol
37
38
  from plexus.common.utils.ormutils import clone_sequence_model_instance, make_base_model, make_sequence_model_mixin
38
39
  from plexus.common.utils.sqlutils import escape_sql_like
@@ -45,6 +46,8 @@ __all__ = [
45
46
  "populate_tagset",
46
47
  "predefined_tagsets",
47
48
  "render_tagset_markdown_readme",
49
+ "make_tag_target_model_mixin",
50
+ "make_tag_record_model_mixin",
48
51
  "TagTarget",
49
52
  "TagRecord",
50
53
  "TagTargetTable",
@@ -375,173 +378,187 @@ def render_tagset_markdown_readme(tagset: Tagset) -> str:
375
378
  return env.from_string(template_str).render(tagset=tagset)
376
379
 
377
380
 
378
- BaseModel = make_base_model()
379
-
380
-
381
- class TagTarget(BaseModel):
382
- identifier: str = Field(
383
- sa_column=sa.Column(sa_sqlite.VARCHAR(256), nullable=False, unique=True),
384
- description="Identifier of the tag target",
385
- )
386
- tagger_name: str = Field(
387
- sa_column=sa.Column(sa_sqlite.VARCHAR(128), nullable=False),
388
- description="Name of the tagger that generates the tag records for the target",
389
- )
390
- tagger_version: str = Field(
391
- sa_column=sa.Column(sa_sqlite.VARCHAR(32), nullable=False),
392
- description="Version of the tagger that generates the tag records for the target",
393
- )
394
- vehicle_name: str = Field(
395
- sa_column=sa.Column(sa_sqlite.VARCHAR(128), nullable=False),
396
- description="Vehicle name associated with the tag record",
397
- )
398
- begin_dt: datetime.datetime = Field(
399
- sa_column=sa.Column(sa_sqlite.TIMESTAMP, nullable=False),
400
- description="Begin datetime of the target range associated with the tag record",
401
- )
402
- end_dt: datetime.datetime = Field(
403
- sa_column=sa.Column(sa_sqlite.TIMESTAMP, nullable=False),
404
- description="End datetime of the target range associated with the tag record",
405
- )
406
-
407
- @pdt.field_validator("identifier", mode="after")
408
- @classmethod
409
- def validate_identifier(cls, v: str) -> str:
410
- validate_slash_tag(v)
411
- return v
412
-
413
- @pdt.field_validator("tagger_name", mode="after")
414
- @classmethod
415
- def validate_tagger_name(cls, v: str) -> str:
416
- validate_snake_case(v)
417
- return v
418
-
419
- @pdt.field_validator("tagger_version", mode="after")
420
- @classmethod
421
- def validate_tagger_version(cls, v: str) -> str:
422
- validate_semver(v)
423
- return v
424
-
425
- @pdt.field_validator("vehicle_name", mode="after")
426
- @classmethod
427
- def validate_vehicle_name(cls, v: str) -> str:
428
- validate_vehicle_name(v)
429
- return v
430
-
431
- @pdt.field_validator("begin_dt", mode="after")
432
- @classmethod
433
- def validate_begin_dt(cls, v: datetime.datetime) -> datetime.datetime:
434
- validate_dt_timezone(v, allow_naive=True)
435
- return v
436
-
437
- @pdt.field_validator("end_dt", mode="after")
438
- @classmethod
439
- def validate_end_dt(cls, v: datetime.datetime) -> datetime.datetime:
440
- validate_dt_timezone(v, allow_naive=True)
441
- return v
442
-
443
- @pdt.model_validator(mode="after")
444
- def validate_begin_dt_end_dt(self) -> Self:
445
- if self.begin_dt > self.end_dt:
446
- raise ValueError(f"begin_dt '{self.begin_dt}' is greater than end_dt '{self.end_dt}'")
447
- return self
381
+ def make_tag_target_model_mixin() -> type[pdt.BaseModel]:
382
+ class ModelMixin(pdt.BaseModel):
383
+ identifier: str = Field(
384
+ sa_column=sa.Column(sa_sqlite.VARCHAR(256), nullable=False, unique=True),
385
+ description="Identifier of the tag target",
386
+ )
387
+ tagger_name: str = Field(
388
+ sa_column=sa.Column(sa_sqlite.VARCHAR(128), nullable=False),
389
+ description="Name of the tagger that generates the tag records for the target",
390
+ )
391
+ tagger_version: str = Field(
392
+ sa_column=sa.Column(sa_sqlite.VARCHAR(32), nullable=False),
393
+ description="Version of the tagger that generates the tag records for the target",
394
+ )
395
+ vehicle_name: str = Field(
396
+ sa_column=sa.Column(sa_sqlite.VARCHAR(128), nullable=False),
397
+ description="Vehicle name associated with the tag record",
398
+ )
399
+ begin_dt: datetime.datetime = Field(
400
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
401
+ description="Begin datetime of the target range associated with the tag record",
402
+ )
403
+ end_dt: datetime.datetime = Field(
404
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
405
+ description="End datetime of the target range associated with the tag record",
406
+ )
448
407
 
449
- @pdt.field_serializer("begin_dt", mode="plain")
450
- def serialize_begin_dt(self, v: datetime.datetime) -> str:
451
- return json_datetime_encoder(v)
408
+ @pdt.field_validator("identifier", mode="after")
409
+ @classmethod
410
+ def validate_identifier(cls, v: str) -> str:
411
+ validate_slash_tag(v)
412
+ return v
452
413
 
453
- @pdt.field_serializer("end_dt", mode="plain")
454
- def serialize_end_dt(self, v: datetime.datetime) -> str:
455
- return json_datetime_encoder(v)
414
+ @pdt.field_validator("tagger_name", mode="after")
415
+ @classmethod
416
+ def validate_tagger_name(cls, v: str) -> str:
417
+ validate_snake_case(v)
418
+ return v
456
419
 
420
+ @pdt.field_validator("tagger_version", mode="after")
421
+ @classmethod
422
+ def validate_tagger_version(cls, v: str) -> str:
423
+ validate_semver(v)
424
+ return v
425
+
426
+ @pdt.field_validator("vehicle_name", mode="after")
427
+ @classmethod
428
+ def validate_vehicle_name(cls, v: str) -> str:
429
+ validate_vehicle_name(v)
430
+ return v
431
+
432
+ @pdt.field_validator("begin_dt", mode="after")
433
+ @classmethod
434
+ def validate_begin_dt(cls, v: datetime.datetime) -> datetime.datetime:
435
+ validate_dt_timezone(v)
436
+ return v
437
+
438
+ @pdt.field_validator("end_dt", mode="after")
439
+ @classmethod
440
+ def validate_end_dt(cls, v: datetime.datetime) -> datetime.datetime:
441
+ validate_dt_timezone(v)
442
+ return v
443
+
444
+ @pdt.model_validator(mode="after")
445
+ def validate_begin_dt_end_dt(self) -> Self:
446
+ if self.begin_dt > self.end_dt:
447
+ raise ValueError(f"begin_dt '{self.begin_dt}' is greater than end_dt '{self.end_dt}'")
448
+ return self
449
+
450
+ @pdt.field_serializer("begin_dt", mode="plain")
451
+ def serialize_begin_dt(self, v: datetime.datetime) -> str:
452
+ return json_datetime_encoder(v)
453
+
454
+ @pdt.field_serializer("end_dt", mode="plain")
455
+ def serialize_end_dt(self, v: datetime.datetime) -> str:
456
+ return json_datetime_encoder(v)
457
+
458
+ return ModelMixin
459
+
460
+
461
+ def make_tag_record_model_mixin() -> type[pdt.BaseModel]:
462
+ class ModelMixin(pdt.BaseModel):
463
+ target_sqn: int = Field(
464
+ sa_column=sa.Column(sa_sqlite.INTEGER, nullable=False),
465
+ description="Sequence number of the tag record's target",
466
+ )
467
+ begin_dt: datetime.datetime = Field(
468
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
469
+ description="Begin datetime of the tag record",
470
+ )
471
+ end_dt: datetime.datetime = Field(
472
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
473
+ description="End datetime of the tag record",
474
+ )
475
+ tagset_namespace: str | None = Field(
476
+ sa_column=sa.Column(sa_sqlite.VARCHAR(64), nullable=True),
477
+ default=None,
478
+ description="Namespace of the tagset that the tag belongs to",
479
+ )
480
+ tagset_version: str | None = Field(
481
+ sa_column=sa.Column(sa_sqlite.VARCHAR(32), nullable=True),
482
+ default=None,
483
+ description="Version of the tagset that the tag belongs to",
484
+ )
485
+ tag: str = Field(
486
+ sa_column=sa.Column(sa_sqlite.VARCHAR(256), nullable=False),
487
+ description="Tag name",
488
+ )
489
+ props: JsonType | None = Field(
490
+ sa_column=sa.Column(sa_sqlite.JSON, nullable=True),
491
+ default=None,
492
+ description="Additional properties of the tag record in JSON format",
493
+ )
494
+ flags: int = Field(
495
+ sa_column=sa.Column(sa_sqlite.INTEGER),
496
+ default=0,
497
+ description="Integer bitmask storing status or metadata flags for this tag record",
498
+ )
457
499
 
458
- class TagRecord(BaseModel):
459
- target_sqn: int = Field(
460
- sa_column=sa.Column(sa_sqlite.INTEGER, nullable=False),
461
- description="Sequence number of the tag record's target",
462
- )
463
- begin_dt: datetime.datetime = Field(
464
- sa_column=sa.Column(sa_sqlite.TIMESTAMP, nullable=False),
465
- description="Begin datetime of the tag record",
466
- )
467
- end_dt: datetime.datetime = Field(
468
- sa_column=sa.Column(sa_sqlite.TIMESTAMP, nullable=False),
469
- description="End datetime of the tag record",
470
- )
471
- tagset_namespace: str | None = Field(
472
- sa_column=sa.Column(sa_sqlite.VARCHAR(64), nullable=True),
473
- default=None,
474
- description="Namespace of the tagset that the tag belongs to",
475
- )
476
- tagset_version: str | None = Field(
477
- sa_column=sa.Column(sa_sqlite.VARCHAR(32), nullable=True),
478
- default=None,
479
- description="Version of the tagset that the tag belongs to",
480
- )
481
- tag: str = Field(
482
- sa_column=sa.Column(sa_sqlite.VARCHAR(256), nullable=False),
483
- description="Tag name",
484
- )
485
- props: JsonType | None = Field(
486
- sa_column=sa.Column(sa_sqlite.JSON, nullable=True),
487
- default=None,
488
- description="Additional properties of the tag record in JSON format",
489
- )
490
- flags: int = Field(
491
- sa_column=sa.Column(sa_sqlite.INTEGER),
492
- default=0,
493
- description="Integer bitmask storing status or metadata flags for this tag record",
494
- )
500
+ @pdt.field_validator("begin_dt", mode="after")
501
+ @classmethod
502
+ def validate_begin_dt(cls, v: datetime.datetime) -> datetime.datetime:
503
+ validate_dt_timezone(v)
504
+ return v
505
+
506
+ @pdt.field_validator("end_dt", mode="after")
507
+ @classmethod
508
+ def validate_end_dt(cls, v: datetime.datetime) -> datetime.datetime:
509
+ validate_dt_timezone(v)
510
+ return v
511
+
512
+ @pdt.field_validator("tagset_namespace", mode="after")
513
+ @classmethod
514
+ def validate_tagset_namespace(cls, v: str | None) -> str | None:
515
+ if v is not None:
516
+ validate_snake_case(v)
517
+ return v
518
+
519
+ @pdt.field_validator("tagset_version", mode="after")
520
+ @classmethod
521
+ def validate_tagset_version(cls, v: str | None) -> str | None:
522
+ if v is not None:
523
+ validate_semver(v)
524
+ return v
525
+
526
+ @pdt.model_validator(mode="after")
527
+ def validate_begin_dt_end_dt(self) -> Self:
528
+ if self.begin_dt > self.end_dt:
529
+ raise ValueError(f"begin_dt '{self.begin_dt}' is greater than end_dt '{self.end_dt}'")
530
+ return self
531
+
532
+ @pdt.field_validator("tag", mode="after")
533
+ @classmethod
534
+ def validate_tag(cls, v: str) -> str:
535
+ validate_colon_tag(v)
536
+ return v
537
+
538
+ @pdt.field_serializer("begin_dt", mode="plain")
539
+ def serialize_begin_dt(self, v: datetime.datetime) -> str:
540
+ return json_datetime_encoder(v)
541
+
542
+ @pdt.field_serializer("end_dt", mode="plain")
543
+ def serialize_end_dt(self, v: datetime.datetime) -> str:
544
+ return json_datetime_encoder(v)
545
+
546
+ return ModelMixin
495
547
 
496
- @pdt.field_validator("begin_dt", mode="after")
497
- @classmethod
498
- def validate_begin_dt(cls, v: datetime.datetime) -> datetime.datetime:
499
- validate_dt_timezone(v, allow_naive=True)
500
- return v
501
-
502
- @pdt.field_validator("end_dt", mode="after")
503
- @classmethod
504
- def validate_end_dt(cls, v: datetime.datetime) -> datetime.datetime:
505
- validate_dt_timezone(v, allow_naive=True)
506
- return v
507
-
508
- @pdt.field_validator("tagset_namespace", mode="after")
509
- @classmethod
510
- def validate_tagset_namespace(cls, v: str | None) -> str | None:
511
- if v is not None:
512
- validate_snake_case(v)
513
- return v
514
548
 
515
- @pdt.field_validator("tagset_version", mode="after")
516
- @classmethod
517
- def validate_tagset_version(cls, v: str | None) -> str | None:
518
- if v is not None:
519
- validate_semver(v)
520
- return v
549
+ BaseModel = make_base_model()
521
550
 
522
- @pdt.model_validator(mode="after")
523
- def validate_begin_dt_end_dt(self) -> Self:
524
- if self.begin_dt > self.end_dt:
525
- raise ValueError(f"begin_dt '{self.begin_dt}' is greater than end_dt '{self.end_dt}'")
526
- return self
527
551
 
528
- @pdt.field_validator("tag", mode="after")
529
- @classmethod
530
- def validate_tag(cls, v: str) -> str:
531
- validate_colon_tag(v)
532
- return v
552
+ class TagTarget(BaseModel, make_tag_target_model_mixin()):
553
+ pass
533
554
 
534
- @pdt.field_serializer("begin_dt", mode="plain")
535
- def serialize_begin_dt(self, v: datetime.datetime) -> str:
536
- return json_datetime_encoder(v)
537
555
 
538
- @pdt.field_serializer("end_dt", mode="plain")
539
- def serialize_end_dt(self, v: datetime.datetime) -> str:
540
- return json_datetime_encoder(v)
556
+ class TagRecord(BaseModel, make_tag_record_model_mixin()):
557
+ pass
541
558
 
542
559
 
543
560
  class TagTargetTable(TagTarget, make_sequence_model_mixin("sqlite"), table=True):
544
- __tablename__ = "tag_target_info"
561
+ __tablename__ = "tag_target"
545
562
 
546
563
 
547
564
  class TagRecordTable(TagRecord, make_sequence_model_mixin("sqlite"), table=True):
@@ -549,7 +566,7 @@ class TagRecordTable(TagRecord, make_sequence_model_mixin("sqlite"), table=True)
549
566
 
550
567
 
551
568
  if typing.TYPE_CHECKING:
552
- class TagTargetTable(SQLModel, SequenceModelMixinProtocol):
569
+ class TagTarget(BaseModel):
553
570
  identifier: sa_orm.Mapped[str] = ...
554
571
  tagger_name: sa_orm.Mapped[str] = ...
555
572
  tagger_version: sa_orm.Mapped[str] = ...
@@ -558,7 +575,11 @@ if typing.TYPE_CHECKING:
558
575
  end_dt: sa_orm.Mapped[datetime.datetime] = ...
559
576
 
560
577
 
561
- class TagRecordTable(SQLModel, SequenceModelMixinProtocol):
578
+ class TagTargetTable(TagTarget, SequenceModelMixinProtocol):
579
+ pass
580
+
581
+
582
+ class TagRecord(BaseModel):
562
583
  target_sqn: sa_orm.Mapped[int] = ...
563
584
  begin_dt: sa_orm.Mapped[datetime.datetime] = ...
564
585
  end_dt: sa_orm.Mapped[datetime.datetime] = ...
@@ -569,9 +590,13 @@ if typing.TYPE_CHECKING:
569
590
  flags: sa_orm.Mapped[int] = ...
570
591
 
571
592
 
593
+ class TagRecordTable(TagRecord, SequenceModelMixinProtocol):
594
+ pass
595
+
596
+
572
597
  @singleton
573
598
  def tag_cache_file_path() -> pathlib.Path:
574
- return pathlib.Path.home() / ".local" / "plus" / "datahub" / "tag_cache" / f"{randomizer().random_alphanumeric(7)}.db"
599
+ return pathlib.Path.home() / ".local" / "plexus" / "tag_cache" / f"{randomizer().random_alphanumeric(7)}.db"
575
600
 
576
601
 
577
602
  class TagCache(object):
@@ -595,9 +620,13 @@ class TagCache(object):
595
620
 
596
621
  @contextlib.contextmanager
597
622
  def make_session(self) -> Generator[sa_orm.Session, None, None]:
598
- with self.thread_lock:
599
- with self.conn_maker.make_session() as session:
623
+ with self.thread_lock, self.conn_maker.make_session(expire_on_commit=False) as session:
624
+ try:
600
625
  yield session
626
+ session.commit()
627
+ except Exception:
628
+ session.rollback()
629
+ raise
601
630
 
602
631
  def get_target(self, target: int | str) -> TagTargetTable | None:
603
632
  with self.make_session() as session:
@@ -1019,7 +1048,6 @@ class TargetedTagCache(object):
1019
1048
  session.add(db_tag_record)
1020
1049
  session.commit()
1021
1050
 
1022
- session.refresh(db_tag_record)
1023
1051
  return chainable(self, db_tag_record)
1024
1052
 
1025
1053
  def add_tag(
@@ -1104,7 +1132,6 @@ class TargetedTagCache(object):
1104
1132
 
1105
1133
  session.commit()
1106
1134
 
1107
- session.refresh(db_tag_record)
1108
1135
  return chainable(self, db_tag_record)
1109
1136
 
1110
1137
  def remove_tags(
@@ -1158,7 +1185,7 @@ class TargetedTagCache(object):
1158
1185
 
1159
1186
 
1160
1187
  @memorized
1161
- def tag_cache(*, identifier: str | None = None, file_path: str | None = None) -> TagCache:
1188
+ def tag_cache(*, identifier: str | None = None, file_path: str | os.PathLike[str] | None = None) -> TagCache:
1162
1189
  """
1163
1190
  Get a ``TagCache`` instance associated with the given identifier. If the identifier is ``None``, return a
1164
1191
  ``TagCache`` instance associated with a default file path. Otherwise, validate the identifier as a snake case
@@ -116,6 +116,7 @@ def patched_postgresql_session_maker(
116
116
  base_model: type[SQLModel],
117
117
  app: FastAPI,
118
118
  session_maker: Callable[[], Generator[sa_orm.Session]],
119
+ **kwargs,
119
120
  ) -> Callable[[pytest.FixtureRequest], Generator[Callable[[], Iterator[sa_orm.Session]]]]:
120
121
  """
121
122
  Create a pytest fixture that provides a patched session maker for PostgreSQL tests.
@@ -141,7 +142,7 @@ def patched_postgresql_session_maker(
141
142
  )
142
143
 
143
144
  def make_session_maker() -> Iterator[sa_orm.Session]:
144
- with cm.make_session(autocommit=False, autoflush=False) as db:
145
+ with cm.make_session(**kwargs) as db:
145
146
  try:
146
147
  # language=postgresql
147
148
  db.execute(sa.sql.text("SET TIMEZONE TO 'UTC'"))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python-common
3
- Version: 1.0.65
3
+ Version: 1.0.67
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.12
6
6
  Classifier: Programming Language :: Python :: 3.13
@@ -15,15 +15,15 @@ plexus/common/utils/config.py,sha256=uCzSYR9W-vNNZiRJ3FdiExuUazlDXY7xJtJaY11T_bA
15
15
  plexus/common/utils/datautils.py,sha256=mgnr-dcHpw-Pk3qBud0lC3JX_pv-iKzI8llsPW9Q12g,9275
16
16
  plexus/common/utils/dockerutils.py,sha256=WPxQuabRWyyM8wpSSYhb_HZaOw5yZ2TbU2dEQ2xRIlQ,5787
17
17
  plexus/common/utils/gisutils.py,sha256=UR3uVoD1nAy0SWJ1AYWCUy94Lo8zNb4nv_JdpcANBDE,11462
18
- plexus/common/utils/jsonutils.py,sha256=hD3cFkBll0AIH3u5FniJSVAsyZpUosgARX4ayIJmY6s,3238
19
- plexus/common/utils/ormutils.py,sha256=IenV_DdgGPS7Xb3QuV0GuIYmYw0GbU4dAN2a2XDaoxs,59327
18
+ plexus/common/utils/jsonutils.py,sha256=-_uKlQMLMgmVO9pB99S45Y_Vufx5dFSq43DIwGz1a54,3328
19
+ plexus/common/utils/ormutils.py,sha256=CHrp6l5shRL2qa8GzRLqeVbtC-eZZkfWCOpBy32JDok,60433
20
20
  plexus/common/utils/pathutils.py,sha256=hGJqSLj08tuOeZ7WeC5d4BtjnPI732BuntVQBQsqOaI,9581
21
21
  plexus/common/utils/s3utils.py,sha256=zlO4kGs-c2gUeOfPfiKIE5liQZsbYxqAZYCwA8kL0Lo,36017
22
22
  plexus/common/utils/sqlutils.py,sha256=D6kTBjhO5YlNRt3uFlPt6z3uH61m9ajEzPYmsI6NoFc,231
23
23
  plexus/common/utils/strutils.py,sha256=O9Inv4ffUTf6Xjc5ftoZwbIua1NeG7itCT9S3zjZxBc,16436
24
- plexus/common/utils/tagutils.py,sha256=dpj_bnpWQVcMF5ISBJHVVrw4BzMwLtQysARr_zJrpDA,51048
25
- plexus/common/utils/testutils.py,sha256=GyrKOKfrl1Go8Q7tCZLybxYvVqyox1AtEFWzWoecNwg,6163
26
- plexus_python_common-1.0.65.dist-info/METADATA,sha256=0Wtmhl6ClvNMcQdzz9mASKiGKufWSnTXALnL8Vyrwc8,1481
27
- plexus_python_common-1.0.65.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
28
- plexus_python_common-1.0.65.dist-info/top_level.txt,sha256=ug_g7CVwaMQuas5UzAXbHUrQvKGCn8ezc6ZNvvRlJOE,7
29
- plexus_python_common-1.0.65.dist-info/RECORD,,
24
+ plexus/common/utils/tagutils.py,sha256=n4yd4KIq8Ub4sGN8wYFom1Ea4IJ19s9UEW-rTl30NDs,52104
25
+ plexus/common/utils/testutils.py,sha256=N8ijLu7X-hlQlHzvv0TtSsQpIF4T1hbr-AjkILoV2Ac,6152
26
+ plexus_python_common-1.0.67.dist-info/METADATA,sha256=VYNQqHRyN3ivhmKeAU4tVa1xtVuUlfpQT8U706C3Ps0,1481
27
+ plexus_python_common-1.0.67.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
28
+ plexus_python_common-1.0.67.dist-info/top_level.txt,sha256=ug_g7CVwaMQuas5UzAXbHUrQvKGCn8ezc6ZNvvRlJOE,7
29
+ plexus_python_common-1.0.67.dist-info/RECORD,,