plexus-python-common 1.0.64__py3-none-any.whl → 1.0.66__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 | SQLModel
472
+ ChangingModelMixin = ChangingModelMixinProtocol | SQLModel
473
+ SnapshotModelMixin = SnapshotModelMixinProtocol | SQLModel
474
+ RevisionModelMixin = RevisionModelMixinProtocol | SQLModel
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``.
@@ -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
@@ -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
@@ -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
 
@@ -18,7 +18,8 @@ import sqlalchemy.dialects.sqlite as sa_sqlite
18
18
  import sqlalchemy.orm as sa_orm
19
19
  from iker.common.utils.dbutils import ConnectionMaker
20
20
  from iker.common.utils.dtutils import dt_from_ts_us, dt_to_ts_us
21
- from iker.common.utils.funcutils import memorized, singleton
21
+ from iker.common.utils.funcutils import Chainable
22
+ from iker.common.utils.funcutils import chainable, memorized, singleton
22
23
  from iker.common.utils.iterutils import batched, head_or_none
23
24
  from iker.common.utils.iterutils import dicttree
24
25
  from iker.common.utils.iterutils import dicttree_add, dicttree_remove
@@ -31,6 +32,8 @@ from sqlmodel import Field, SQLModel
31
32
  from plexus.common.resources.tags import predefined_tagset_specs
32
33
  from plexus.common.utils.datautils import validate_colon_tag, validate_snake_case, validate_vehicle_name
33
34
  from plexus.common.utils.datautils import validate_dt_timezone, validate_semver, validate_slash_tag
35
+ from plexus.common.utils.jsonutils import json_datetime_encoder
36
+ from plexus.common.utils.ormutils import SQLiteDateTime
34
37
  from plexus.common.utils.ormutils import SequenceModelMixinProtocol
35
38
  from plexus.common.utils.ormutils import clone_sequence_model_instance, make_base_model, make_sequence_model_mixin
36
39
  from plexus.common.utils.sqlutils import escape_sql_like
@@ -394,11 +397,11 @@ class TagTarget(BaseModel):
394
397
  description="Vehicle name associated with the tag record",
395
398
  )
396
399
  begin_dt: datetime.datetime = Field(
397
- sa_column=sa.Column(sa_sqlite.TIMESTAMP, nullable=False),
400
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
398
401
  description="Begin datetime of the target range associated with the tag record",
399
402
  )
400
403
  end_dt: datetime.datetime = Field(
401
- sa_column=sa.Column(sa_sqlite.TIMESTAMP, nullable=False),
404
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
402
405
  description="End datetime of the target range associated with the tag record",
403
406
  )
404
407
 
@@ -429,13 +432,13 @@ class TagTarget(BaseModel):
429
432
  @pdt.field_validator("begin_dt", mode="after")
430
433
  @classmethod
431
434
  def validate_begin_dt(cls, v: datetime.datetime) -> datetime.datetime:
432
- validate_dt_timezone(v, allow_naive=True)
435
+ validate_dt_timezone(v)
433
436
  return v
434
437
 
435
438
  @pdt.field_validator("end_dt", mode="after")
436
439
  @classmethod
437
440
  def validate_end_dt(cls, v: datetime.datetime) -> datetime.datetime:
438
- validate_dt_timezone(v, allow_naive=True)
441
+ validate_dt_timezone(v)
439
442
  return v
440
443
 
441
444
  @pdt.model_validator(mode="after")
@@ -444,6 +447,14 @@ class TagTarget(BaseModel):
444
447
  raise ValueError(f"begin_dt '{self.begin_dt}' is greater than end_dt '{self.end_dt}'")
445
448
  return self
446
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
+
447
458
 
448
459
  class TagRecord(BaseModel):
449
460
  target_sqn: int = Field(
@@ -451,11 +462,11 @@ class TagRecord(BaseModel):
451
462
  description="Sequence number of the tag record's target",
452
463
  )
453
464
  begin_dt: datetime.datetime = Field(
454
- sa_column=sa.Column(sa_sqlite.TIMESTAMP, nullable=False),
465
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
455
466
  description="Begin datetime of the tag record",
456
467
  )
457
468
  end_dt: datetime.datetime = Field(
458
- sa_column=sa.Column(sa_sqlite.TIMESTAMP, nullable=False),
469
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
459
470
  description="End datetime of the tag record",
460
471
  )
461
472
  tagset_namespace: str | None = Field(
@@ -477,17 +488,22 @@ class TagRecord(BaseModel):
477
488
  default=None,
478
489
  description="Additional properties of the tag record in JSON format",
479
490
  )
491
+ flags: int = Field(
492
+ sa_column=sa.Column(sa_sqlite.INTEGER),
493
+ default=0,
494
+ description="Integer bitmask storing status or metadata flags for this tag record",
495
+ )
480
496
 
481
497
  @pdt.field_validator("begin_dt", mode="after")
482
498
  @classmethod
483
499
  def validate_begin_dt(cls, v: datetime.datetime) -> datetime.datetime:
484
- validate_dt_timezone(v, allow_naive=True)
500
+ validate_dt_timezone(v)
485
501
  return v
486
502
 
487
503
  @pdt.field_validator("end_dt", mode="after")
488
504
  @classmethod
489
505
  def validate_end_dt(cls, v: datetime.datetime) -> datetime.datetime:
490
- validate_dt_timezone(v, allow_naive=True)
506
+ validate_dt_timezone(v)
491
507
  return v
492
508
 
493
509
  @pdt.field_validator("tagset_namespace", mode="after")
@@ -516,6 +532,14 @@ class TagRecord(BaseModel):
516
532
  validate_colon_tag(v)
517
533
  return v
518
534
 
535
+ @pdt.field_serializer("begin_dt", mode="plain")
536
+ def serialize_begin_dt(self, v: datetime.datetime) -> str:
537
+ return json_datetime_encoder(v)
538
+
539
+ @pdt.field_serializer("end_dt", mode="plain")
540
+ def serialize_end_dt(self, v: datetime.datetime) -> str:
541
+ return json_datetime_encoder(v)
542
+
519
543
 
520
544
  class TagTargetTable(TagTarget, make_sequence_model_mixin("sqlite"), table=True):
521
545
  __tablename__ = "tag_target_info"
@@ -543,11 +567,12 @@ if typing.TYPE_CHECKING:
543
567
  tagset_version: sa_orm.Mapped[str | None] = ...
544
568
  tag: sa_orm.Mapped[str] = ...
545
569
  props: sa_orm.Mapped[JsonType | None] = ...
570
+ flags: sa_orm.Mapped[int] = ...
546
571
 
547
572
 
548
573
  @singleton
549
574
  def tag_cache_file_path() -> pathlib.Path:
550
- return pathlib.Path.home() / ".local" / "plus" / "datahub" / "tag_cache" / f"{randomizer().random_alphanumeric(7)}.db"
575
+ return pathlib.Path.home() / ".local" / "plexus" / "tag_cache" / f"{randomizer().random_alphanumeric(7)}.db"
551
576
 
552
577
 
553
578
  class TagCache(object):
@@ -571,13 +596,20 @@ class TagCache(object):
571
596
 
572
597
  @contextlib.contextmanager
573
598
  def make_session(self) -> Generator[sa_orm.Session, None, None]:
574
- with self.thread_lock:
575
- with self.conn_maker.make_session() as session:
599
+ with self.thread_lock, self.conn_maker.make_session(expire_on_commit=False) as session:
600
+ try:
576
601
  yield session
602
+ session.commit()
603
+ except Exception:
604
+ session.rollback()
605
+ raise
577
606
 
578
- def get_target(self, identifier: str) -> TagTargetTable | None:
607
+ def get_target(self, target: int | str) -> TagTargetTable | None:
579
608
  with self.make_session() as session:
580
- return session.query(TagTargetTable).filter(TagTargetTable.identifier == identifier).one_or_none()
609
+ if isinstance(target, int):
610
+ return session.get(TagTargetTable, target)
611
+ else:
612
+ return session.query(TagTargetTable).filter(TagTargetTable.identifier == target).one_or_none()
581
613
 
582
614
  def query_targets(
583
615
  self,
@@ -664,10 +696,10 @@ class TagCache(object):
664
696
  )
665
697
  session.commit()
666
698
 
667
- def with_target(self, name: str) -> "TargetedTagCache":
668
- target_info = self.get_target(name)
699
+ def with_target(self, target: int | str) -> "TargetedTagCache":
700
+ target_info = self.get_target(target)
669
701
  if target_info is None:
670
- raise ValueError(f"target with name '{name}' not found in cache")
702
+ raise ValueError(f"target '{target}' not found in cache")
671
703
  return TargetedTagCache(cache=self, target_info=target_info)
672
704
 
673
705
  def iter_tags(
@@ -961,7 +993,7 @@ class TargetedTagCache(object):
961
993
  props: JsonType | None = None,
962
994
  tagset_namespace: str | None = None,
963
995
  tagset_version: str | None = None,
964
- ) -> Self:
996
+ ) -> Chainable[Self, TagRecordTable]:
965
997
  """
966
998
  Add a tag record to the cache for the specified time range. If ``begin_dt`` or ``end_dt`` is None, it will
967
999
  default to the target's ``begin_dt`` or ``end_dt`` respectively.
@@ -988,10 +1020,11 @@ class TargetedTagCache(object):
988
1020
  tag=tag.name if isinstance(tag, Tag) else tag,
989
1021
  props=props,
990
1022
  )
991
- session.add(clone_sequence_model_instance(TagRecordTable, tag_record))
1023
+ db_tag_record = clone_sequence_model_instance(TagRecordTable, tag_record)
1024
+ session.add(db_tag_record)
992
1025
  session.commit()
993
1026
 
994
- return self
1027
+ return chainable(self, db_tag_record)
995
1028
 
996
1029
  def add_tag(
997
1030
  self,
@@ -999,7 +1032,7 @@ class TargetedTagCache(object):
999
1032
  props: JsonType | None = None,
1000
1033
  tagset_namespace: str | None = None,
1001
1034
  tagset_version: str | None = None,
1002
- ) -> Self:
1035
+ ) -> Chainable[Self, TagRecordTable]:
1003
1036
  """
1004
1037
  Add a tag record to the cache for the entire target range.
1005
1038
 
@@ -1023,6 +1056,60 @@ class TargetedTagCache(object):
1023
1056
  props=props,
1024
1057
  )
1025
1058
 
1059
+ def update_tag(
1060
+ self,
1061
+ sqn: int,
1062
+ *,
1063
+ begin_dt: datetime.datetime | None = None,
1064
+ end_dt: datetime.datetime | None = None,
1065
+ tag: str | Tag | BoundTag | None = None,
1066
+ props: JsonType | None = None,
1067
+ tagset_namespace: str | None = None,
1068
+ tagset_version: str | None = None,
1069
+ flags: int | None = None,
1070
+ ) -> Chainable[Self, TagRecordTable]:
1071
+ """
1072
+ Update a tag record in the cache by its sequence number.
1073
+
1074
+ :param sqn: Sequence number of the tag record to be updated
1075
+ :param begin_dt: New begin datetime of the tag record (optional)
1076
+ :param end_dt: New end datetime of the tag record (optional)
1077
+ :param tag: New tag name or ``Tag``/``BoundTag`` instance to be updated (optional). If ``Tag``/``BoundTag``
1078
+ instance is provided, its name will be used.
1079
+ :param props: New additional properties of the tag record in JSON format (optional)
1080
+ :param tagset_namespace: New namespace of the tagset that the tag belongs to (optional). If the ``tag``
1081
+ parameter is a ``BoundTag`` instance, this parameter will be ignored and the namespace
1082
+ from the instance will be used.
1083
+ :param tagset_version: New version of the tagset that the tag belongs to (optional). If the ``tag`` parameter
1084
+ is a ``BoundTag`` instance, this parameter will be ignored and the version from the
1085
+ instance will be used.
1086
+ :param flags: New integer bitmask storing status or metadata flags for this tag record (optional)
1087
+ :return: Self instance for chaining
1088
+ """
1089
+ with self.make_session() as session:
1090
+ db_tag_record = session.query(TagRecordTable).filter(TagRecordTable.sqn == sqn).one_or_none()
1091
+ if not db_tag_record:
1092
+ raise ValueError(f"tag record with sqn '{sqn}' not found in cache")
1093
+
1094
+ if begin_dt is not None:
1095
+ db_tag_record.begin_dt = begin_dt
1096
+ if end_dt is not None:
1097
+ db_tag_record.end_dt = end_dt
1098
+ if tagset_namespace is not None:
1099
+ db_tag_record.tagset_namespace = tag.namespace if isinstance(tag, BoundTag) else tagset_namespace
1100
+ if tagset_version is not None:
1101
+ db_tag_record.tagset_version = tag.version if isinstance(tag, BoundTag) else tagset_version
1102
+ if tag is not None:
1103
+ db_tag_record.tag = tag.name if isinstance(tag, Tag) else tag
1104
+ if props is not None:
1105
+ db_tag_record.props = props
1106
+ if flags is not None:
1107
+ db_tag_record.flags = flags
1108
+
1109
+ session.commit()
1110
+
1111
+ return chainable(self, db_tag_record)
1112
+
1026
1113
  def remove_tags(
1027
1114
  self,
1028
1115
  begin_dt: datetime.datetime | None = None,
@@ -1074,7 +1161,7 @@ class TargetedTagCache(object):
1074
1161
 
1075
1162
 
1076
1163
  @memorized
1077
- def tag_cache(*, identifier: str | None = None, file_path: str | None = None) -> TagCache:
1164
+ def tag_cache(*, identifier: str | None = None, file_path: str | os.PathLike[str] | None = None) -> TagCache:
1078
1165
  """
1079
1166
  Get a ``TagCache`` instance associated with the given identifier. If the identifier is ``None``, return a
1080
1167
  ``TagCache`` instance associated with a default file path. Otherwise, validate the identifier as a snake case
@@ -1099,14 +1186,12 @@ def tag_cache(*, identifier: str | None = None, file_path: str | None = None) ->
1099
1186
  return TagCache(file_path=tag_cache_file_path())
1100
1187
 
1101
1188
 
1102
-
1103
1189
  @singleton
1104
1190
  def standard_clip_duration_us() -> int:
1105
1191
  """Duration of clips to split samples into, in microseconds, which is fixed to 20 seconds for now."""
1106
1192
  return 20 * 1_000_000 # 20 seconds
1107
1193
 
1108
1194
 
1109
-
1110
1195
  def populate_clip_ranges(
1111
1196
  data_begin_dt: datetime.datetime,
1112
1197
  data_end_dt: datetime.datetime,
@@ -1137,7 +1222,7 @@ def populate_clip_ranges(
1137
1222
  return
1138
1223
 
1139
1224
  begin_clip_slot = math.floor(dt_to_ts_us(data_begin_dt) / clip_duration_us)
1140
- end_clip_slot = math.ceil(dt_to_ts_us(data_end_dt) / clip_duration_us)
1225
+ end_clip_slot = math.ceil(dt_to_ts_us(data_end_dt) / clip_duration_us)
1141
1226
 
1142
1227
  for clip_slot in range(begin_clip_slot, end_clip_slot):
1143
1228
  clip_begin_dt = dt_from_ts_us(clip_slot * clip_duration_us)
@@ -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.64
3
+ Version: 1.0.66
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=_kkQLTScYeoAneEdbGvkcsXh1TDja__AUznVkOpRNn0,60393
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=7qBLW848fr64Jq8ogUUuUY15mT1KnNZbtEbb3tKosXQ,46930
25
- plexus/common/utils/testutils.py,sha256=GyrKOKfrl1Go8Q7tCZLybxYvVqyox1AtEFWzWoecNwg,6163
26
- plexus_python_common-1.0.64.dist-info/METADATA,sha256=J8wWo3Q3ffOL79INfOk4lyJAcDolpsf3FXz5lIZ95eY,1481
27
- plexus_python_common-1.0.64.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
28
- plexus_python_common-1.0.64.dist-info/top_level.txt,sha256=ug_g7CVwaMQuas5UzAXbHUrQvKGCn8ezc6ZNvvRlJOE,7
29
- plexus_python_common-1.0.64.dist-info/RECORD,,
24
+ plexus/common/utils/tagutils.py,sha256=MzsuxBH62lAPuvQL1wx7kKvQHsA5NXYMEPRhSYwb4gA,51077
25
+ plexus/common/utils/testutils.py,sha256=N8ijLu7X-hlQlHzvv0TtSsQpIF4T1hbr-AjkILoV2Ac,6152
26
+ plexus_python_common-1.0.66.dist-info/METADATA,sha256=FrVKPSwnSIbWkpwtHO-PABwKcvEK6Sey8UtP8sRKleg,1481
27
+ plexus_python_common-1.0.66.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
28
+ plexus_python_common-1.0.66.dist-info/top_level.txt,sha256=ug_g7CVwaMQuas5UzAXbHUrQvKGCn8ezc6ZNvvRlJOE,7
29
+ plexus_python_common-1.0.66.dist-info/RECORD,,