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.
- plexus/common/utils/jsonutils.py +6 -2
- plexus/common/utils/ormutils.py +65 -31
- plexus/common/utils/tagutils.py +110 -25
- plexus/common/utils/testutils.py +2 -1
- {plexus_python_common-1.0.64.dist-info → plexus_python_common-1.0.66.dist-info}/METADATA +1 -1
- {plexus_python_common-1.0.64.dist-info → plexus_python_common-1.0.66.dist-info}/RECORD +8 -8
- {plexus_python_common-1.0.64.dist-info → plexus_python_common-1.0.66.dist-info}/WHEEL +0 -0
- {plexus_python_common-1.0.64.dist-info → plexus_python_common-1.0.66.dist-info}/top_level.txt +0 -0
plexus/common/utils/jsonutils.py
CHANGED
|
@@ -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):
|
plexus/common/utils/ormutils.py
CHANGED
|
@@ -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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
plexus/common/utils/tagutils.py
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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" / "
|
|
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
|
-
|
|
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,
|
|
607
|
+
def get_target(self, target: int | str) -> TagTargetTable | None:
|
|
579
608
|
with self.make_session() as session:
|
|
580
|
-
|
|
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,
|
|
668
|
-
target_info = self.get_target(
|
|
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
|
|
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
|
-
|
|
1023
|
+
db_tag_record = clone_sequence_model_instance(TagRecordTable, tag_record)
|
|
1024
|
+
session.add(db_tag_record)
|
|
992
1025
|
session.commit()
|
|
993
1026
|
|
|
994
|
-
|
|
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)
|
|
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)
|
plexus/common/utils/testutils.py
CHANGED
|
@@ -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(
|
|
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'"))
|
|
@@ -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
|
|
19
|
-
plexus/common/utils/ormutils.py,sha256=
|
|
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=
|
|
25
|
-
plexus/common/utils/testutils.py,sha256=
|
|
26
|
-
plexus_python_common-1.0.
|
|
27
|
-
plexus_python_common-1.0.
|
|
28
|
-
plexus_python_common-1.0.
|
|
29
|
-
plexus_python_common-1.0.
|
|
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,,
|
|
File without changes
|
{plexus_python_common-1.0.64.dist-info → plexus_python_common-1.0.66.dist-info}/top_level.txt
RENAMED
|
File without changes
|