plexus-python-common 1.0.49__tar.gz → 1.0.50__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/PKG-INFO +1 -1
  2. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/tagutils.py +306 -24
  3. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus_python_common.egg-info/PKG-INFO +1 -1
  4. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/tagutils_test.py +34 -3
  5. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/.editorconfig +0 -0
  6. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/.github/workflows/pr.yml +0 -0
  7. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/.github/workflows/push.yml +0 -0
  8. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/.gitignore +0 -0
  9. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/MANIFEST.in +0 -0
  10. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/README.md +0 -0
  11. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/VERSION +0 -0
  12. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/pyproject.toml +0 -0
  13. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/jsonutils/dummy.0.jsonl +0 -0
  14. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/jsonutils/dummy.1.jsonl +0 -0
  15. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/jsonutils/dummy.2.jsonl +0 -0
  16. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/0-dummy +0 -0
  17. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/1-dummy +0 -0
  18. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/2-dummy +0 -0
  19. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.0.0.jsonl +0 -0
  20. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.0.0.vol-0.jsonl +0 -0
  21. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.0.jsonl +0 -0
  22. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.1.1.jsonl +0 -0
  23. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.1.1.vol-1.jsonl +0 -0
  24. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.1.jsonl +0 -0
  25. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.2.2.jsonl +0 -0
  26. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.2.2.vol-2.jsonl +0 -0
  27. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.2.jsonl +0 -0
  28. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.csv.part0 +0 -0
  29. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.csv.part1 +0 -0
  30. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.csv.part2 +0 -0
  31. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/pathutils/dummy.txt +0 -0
  32. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.baz/file.bar.baz +0 -0
  33. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.baz/file.foo.bar +0 -0
  34. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.baz/file.foo.baz +0 -0
  35. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.foo/dir.foo.bar/dir.foo.bar.baz/file.foo.bar.baz +0 -0
  36. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.bar.baz +0 -0
  37. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.foo.bar +0 -0
  38. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.foo.baz +0 -0
  39. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.foo/file.bar +0 -0
  40. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.foo/file.baz +0 -0
  41. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils/dir.foo/file.foo +0 -0
  42. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils_archive/archive.compressed.zip +0 -0
  43. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/resources/unittest/s3utils_archive/archive.uncompressed.zip +0 -0
  44. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/setup.cfg +0 -0
  45. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/setup.py +0 -0
  46. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/__init__.py +0 -0
  47. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/carto/OSMFile.py +0 -0
  48. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/carto/OSMNode.py +0 -0
  49. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/carto/OSMTags.py +0 -0
  50. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/carto/OSMWay.py +0 -0
  51. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/carto/__init__.py +0 -0
  52. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/pose.py +0 -0
  53. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/proj.py +0 -0
  54. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/resources/__init__.py +0 -0
  55. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/resources/tags/__init__.py +0 -0
  56. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/resources/tags/universal.tagset.yaml +0 -0
  57. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/__init__.py +0 -0
  58. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/apiutils.py +0 -0
  59. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/bagutils.py +0 -0
  60. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/config.py +0 -0
  61. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/datautils.py +0 -0
  62. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/dockerutils.py +0 -0
  63. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/jsonutils.py +0 -0
  64. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/ormutils.py +0 -0
  65. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/pathutils.py +0 -0
  66. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/s3utils.py +0 -0
  67. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/sqlutils.py +0 -0
  68. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/strutils.py +0 -0
  69. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus/common/utils/testutils.py +0 -0
  70. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus_python_common.egg-info/SOURCES.txt +0 -0
  71. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus_python_common.egg-info/dependency_links.txt +0 -0
  72. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus_python_common.egg-info/not-zip-safe +0 -0
  73. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus_python_common.egg-info/requires.txt +0 -0
  74. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/src/plexus_python_common.egg-info/top_level.txt +0 -0
  75. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/__init__.py +0 -0
  76. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/__init__.py +0 -0
  77. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/carto/__init__.py +0 -0
  78. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/carto/osm_file_test.py +0 -0
  79. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/carto/osm_tags_test.py +0 -0
  80. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/pose_test.py +0 -0
  81. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/proj_test.py +0 -0
  82. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/__init__.py +0 -0
  83. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/bagutils_test.py +0 -0
  84. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/datautils_test.py +0 -0
  85. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/dockerutils_test.py +0 -0
  86. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/jsonutils_test.py +0 -0
  87. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/ormutils_test.py +0 -0
  88. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/pathutils_test.py +0 -0
  89. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/s3utils_test.py +0 -0
  90. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/strutils_test.py +0 -0
  91. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/plexus_tests/common/utils/testutils_test.py +0 -0
  92. {plexus_python_common-1.0.49 → plexus_python_common-1.0.50}/test/testenv.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python-common
3
- Version: 1.0.49
3
+ Version: 1.0.50
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.12
6
6
  Classifier: Programming Language :: Python :: 3.13
@@ -13,7 +13,7 @@ import sqlalchemy as sa
13
13
  import sqlalchemy.dialects.sqlite as sa_sqlite
14
14
  import sqlalchemy.orm as sa_orm
15
15
  from iker.common.utils.dbutils import ConnectionMaker
16
- from iker.common.utils.funcutils import singleton
16
+ from iker.common.utils.funcutils import memorized, singleton
17
17
  from iker.common.utils.iterutils import dicttree
18
18
  from iker.common.utils.iterutils import dicttree_add, dicttree_remove
19
19
  from iker.common.utils.iterutils import dicttree_children, dicttree_lineage, dicttree_subtree
@@ -24,15 +24,17 @@ from iker.common.utils.strutils import is_blank
24
24
  from sqlmodel import Field, SQLModel
25
25
 
26
26
  from plexus.common.resources.tags import predefined_tagset_specs
27
+ from plexus.common.utils.datautils import validate_bag_name, validate_dt_timezone
27
28
  from plexus.common.utils.datautils import validate_colon_tag, validate_snake_case, validate_vehicle_name
28
- from plexus.common.utils.datautils import validate_dt_timezone
29
29
  from plexus.common.utils.ormutils import SequenceModelMixinProtocol
30
30
  from plexus.common.utils.ormutils import clone_sequence_model_instance, make_base_model, make_sequence_model_mixin
31
31
  from plexus.common.utils.sqlutils import escape_sql_like
32
32
 
33
33
  __all__ = [
34
34
  "TagRecord",
35
+ "BagTagRecord",
35
36
  "TagRecordTable",
37
+ "BagTagRecordTable",
36
38
  "RichDesc",
37
39
  "Tag",
38
40
  "Tagset",
@@ -42,6 +44,7 @@ __all__ = [
42
44
  "render_tagset_markdown_readme",
43
45
  "tag_cache_file_path",
44
46
  "TagCache",
47
+ "tag_cache",
45
48
  ]
46
49
 
47
50
  BaseModel = make_base_model()
@@ -61,7 +64,7 @@ class TagRecord(BaseModel):
61
64
  description="End datetime of the tag record",
62
65
  )
63
66
  tag: str = Field(
64
- sa_column=sa.Column(sa_sqlite.TEXT, nullable=False),
67
+ sa_column=sa.Column(sa_sqlite.VARCHAR(256), nullable=False),
65
68
  description="Tag name",
66
69
  )
67
70
  props: JsonType | None = Field(
@@ -91,7 +94,73 @@ class TagRecord(BaseModel):
91
94
  @pdt.model_validator(mode="after")
92
95
  def validate_begin_dt_end_dt(self) -> Self:
93
96
  if self.begin_dt > self.end_dt:
94
- raise ValueError(f"begin dt '{self.begin_dt}' is greater than end dt '{self.end_dt}'")
97
+ raise ValueError(f"begin_dt '{self.begin_dt}' is greater than end_dt '{self.end_dt}'")
98
+ return self
99
+
100
+ @pdt.field_validator("tag", mode="after")
101
+ @classmethod
102
+ def validate_tag(cls, v: str) -> str:
103
+ validate_colon_tag(v)
104
+ return v
105
+
106
+
107
+ class BagTagRecord(BaseModel):
108
+ vehicle_name: str = Field(
109
+ sa_column=sa.Column(sa_sqlite.TEXT, nullable=False),
110
+ description="Vehicle name associated with the tag record",
111
+ )
112
+ bag_name: str = Field(
113
+ sa_column=sa.Column(sa_sqlite.VARCHAR(256), nullable=False),
114
+ description="Name of the bag associated with the tag record",
115
+ )
116
+ begin_offset: int = Field(
117
+ sa_column=sa.Column(sa_sqlite.INTEGER, nullable=False),
118
+ description="Begin offset (in microseconds) of the tag record within the bag",
119
+ )
120
+ end_offset: int = Field(
121
+ sa_column=sa.Column(sa_sqlite.INTEGER, nullable=False),
122
+ description="End offset (in microseconds) of the tag record within the bag",
123
+ )
124
+ tag: str = Field(
125
+ sa_column=sa.Column(sa_sqlite.VARCHAR(256), nullable=False),
126
+ description="Tag name",
127
+ )
128
+ props: JsonType | None = Field(
129
+ sa_column=sa.Column(sa_sqlite.TEXT, nullable=True),
130
+ default=None,
131
+ description="Additional properties of the tag record in JSON format",
132
+ )
133
+
134
+ @pdt.field_validator("vehicle_name", mode="after")
135
+ @classmethod
136
+ def validate_vehicle_name(cls, v: str) -> str:
137
+ validate_vehicle_name(v)
138
+ return v
139
+
140
+ @pdt.field_validator("bag_name", mode="after")
141
+ @classmethod
142
+ def validate_bag_name(cls, v: str) -> str:
143
+ validate_bag_name(v)
144
+ return v
145
+
146
+ @pdt.field_validator("begin_offset", mode="after")
147
+ @classmethod
148
+ def validate_begin_offset(cls, v: int) -> int:
149
+ if v < 0:
150
+ raise ValueError(f"begin_offset '{v}' is negative")
151
+ return v
152
+
153
+ @pdt.field_validator("end_offset", mode="after")
154
+ @classmethod
155
+ def validate_end_offset(cls, v: int) -> int:
156
+ if v < 0:
157
+ raise ValueError(f"end_offset '{v}' is negative")
158
+ return v
159
+
160
+ @pdt.model_validator(mode="after")
161
+ def validate_begin_offset_end_offset(self) -> Self:
162
+ if self.begin_offset > self.end_offset:
163
+ raise ValueError(f"begin offset '{self.begin_offset}' is greater than end offset '{self.end_offset}'")
95
164
  return self
96
165
 
97
166
  @pdt.field_validator("tag", mode="after")
@@ -105,6 +174,10 @@ class TagRecordTable(TagRecord, make_sequence_model_mixin("sqlite"), table=True)
105
174
  __tablename__ = "tag_record"
106
175
 
107
176
 
177
+ class BagTagRecordTable(BagTagRecord, make_sequence_model_mixin("sqlite"), table=True):
178
+ __tablename__ = "bag_tag_record"
179
+
180
+
108
181
  if typing.TYPE_CHECKING:
109
182
  class TagRecordTable(SQLModel, SequenceModelMixinProtocol):
110
183
  vehicle_name: sa_orm.Mapped[str] = ...
@@ -400,7 +473,21 @@ class TagCache(object):
400
473
  tag_pattern: str | None = None,
401
474
  *,
402
475
  tagsets: Sequence[Tagset] | None = None,
403
- ) -> Generator[TagRecordTable]:
476
+ tagset_inverted: bool = False,
477
+ ) -> Generator[TagRecordTable, None, None]:
478
+ """
479
+ Query tag records in the cache with optional filters.
480
+
481
+ :param vehicle_name: Filter by vehicle name (exact match)
482
+ :param begin_time: Filter by begin time (inclusive)
483
+ :param end_time: Filter by end time (inclusive)
484
+ :param tag_pattern: Filter by tag name pattern (SQL LIKE syntax, e.g. "dummy_tag:%" to match all tags starting
485
+ with "dummy_tag:")
486
+ :param tagsets: Filter by tagsets (match tags that are in any of the specified tagsets)
487
+ :param tagset_inverted: Whether to invert the tagset filter (match tags that are NOT in any of the specified
488
+ tagsets)
489
+ :return: Generator of ``TagRecordTable`` instances that match the filters
490
+ """
404
491
  with self.conn_maker.make_session() as session:
405
492
  query = session.query(TagRecordTable)
406
493
  if vehicle_name:
@@ -411,16 +498,62 @@ class TagCache(object):
411
498
  query = query.filter(TagRecordTable.begin_dt <= end_time)
412
499
  if tag_pattern:
413
500
  query = query.filter(TagRecordTable.tag.like(f"{escape_sql_like(tag_pattern)}%", escape="\\"))
501
+ if tagsets:
502
+ if tagset_inverted:
503
+ query = query.filter(
504
+ TagRecordTable.tag.notin_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
505
+ else:
506
+ query = query.filter(
507
+ TagRecordTable.tag.in_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
414
508
 
415
- if not tagsets:
416
- yield from query.all()
417
- else:
418
- yield from (db_tag_record for db_tag_record in query.all()
419
- if any(db_tag_record.tag in tagset for tagset in tagsets))
509
+ yield from query.all()
510
+
511
+ def query_bag_tag(
512
+ self,
513
+ vehicle_name: str | None = None,
514
+ bag_name: str | None = None,
515
+ begin_offset: int | None = None,
516
+ end_offset: int | None = None,
517
+ tag_pattern: str | None = None,
518
+ *,
519
+ tagsets: Sequence[Tagset] | None = None,
520
+ tagset_inverted: bool = False,
521
+ ) -> Generator[BagTagRecordTable, None, None]:
522
+ """
523
+ Query bag tag records in the cache with optional filters.
524
+
525
+ :param vehicle_name: Filter by vehicle name (exact match)
526
+ :param bag_name: Filter by bag name (exact match)
527
+ :param begin_offset: Filter by begin offset (inclusive)
528
+ :param end_offset: Filter by end offset (inclusive)
529
+ :param tag_pattern: Filter by tag name pattern (SQL LIKE syntax, e.g. "dummy_tag:%" to match all tags starting
530
+ with "dummy_tag:")
531
+ :param tagsets: Filter by tagsets (match tags that are in any of the specified tagsets)
532
+ :param tagset_inverted: Whether to invert the tagset filter (match tags that are NOT in any of the specified
533
+ tagsets)
534
+ :return: Generator of ``BagTagRecordTable`` instances that match the filters
535
+ """
536
+ with self.conn_maker.make_session() as session:
537
+ query = session.query(BagTagRecordTable)
538
+ if vehicle_name:
539
+ query = query.filter(BagTagRecordTable.vehicle_name == vehicle_name)
540
+ if bag_name:
541
+ query = query.filter(BagTagRecordTable.bag_name == bag_name)
542
+ if begin_offset:
543
+ query = query.filter(BagTagRecordTable.end_offset >= begin_offset)
544
+ if end_offset:
545
+ query = query.filter(BagTagRecordTable.begin_offset <= end_offset)
546
+ if tag_pattern:
547
+ query = query.filter(BagTagRecordTable.tag.like(f"{escape_sql_like(tag_pattern)}%", escape="\\"))
548
+ if tagsets:
549
+ if tagset_inverted:
550
+ query = query.filter(
551
+ BagTagRecordTable.tag.notin_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
552
+ else:
553
+ query = query.filter(
554
+ BagTagRecordTable.tag.in_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
420
555
 
421
- def undefined(self, tagsets: Sequence[Tagset]) -> Generator[TagRecordTable, None, None]:
422
- yield from (db_tag_record for db_tag_record in self.query()
423
- if all(db_tag_record.tag not in tagset for tagset in tagsets))
556
+ yield from query.all()
424
557
 
425
558
  def add(
426
559
  self,
@@ -430,6 +563,16 @@ class TagCache(object):
430
563
  tag: str | Tag,
431
564
  props: JsonType | None = None,
432
565
  ) -> Self:
566
+ """
567
+ Add a tag record to the cache.
568
+
569
+ :param vehicle_name: Vehicle name associated with the tag record
570
+ :param begin_time: Begin datetime of the tag record
571
+ :param end_time: End datetime of the tag record
572
+ :param tag: Tag name or Tag instance to be added (if Tag instance is provided, its name will be used)
573
+ :param props: Additional properties of the tag record in JSON format (optional)
574
+ :return: Self instance for chaining
575
+ """
433
576
  with self.conn_maker.make_session() as session:
434
577
  tag_record = TagRecord(
435
578
  vehicle_name=vehicle_name,
@@ -443,17 +586,132 @@ class TagCache(object):
443
586
 
444
587
  return self
445
588
 
446
- def remove(self, vehicle_name: str, begin_time: datetime.datetime, end_time: datetime.datetime) -> Self:
589
+ def add_bag_tag(
590
+ self,
591
+ vehicle_name: str,
592
+ bag_name: str,
593
+ begin_offset: int,
594
+ end_offset: int,
595
+ tag: str | Tag,
596
+ props: JsonType | None = None,
597
+ ) -> Self:
598
+ """
599
+ Add a bag tag record to the cache.
600
+
601
+ :param vehicle_name: Vehicle name associated with the tag record
602
+ :param bag_name: Name of the bag associated with the tag record
603
+ :param begin_offset: Begin offset (in microseconds) of the tag record within the bag (inclusive)
604
+ :param end_offset: End offset (in microseconds) of the tag record within the bag (inclusive)
605
+ :param tag: Tag name or Tag instance to be added (if Tag instance is provided, its name will be used)
606
+ :param props: Additional properties of the tag record in JSON format (optional)
607
+ :return: Self instance for chaining
608
+ """
447
609
  with self.conn_maker.make_session() as session:
448
- session.execute(
449
- sa
450
- .delete(TagRecordTable)
451
- .where(
452
- TagRecordTable.vehicle_name == vehicle_name,
453
- TagRecordTable.end_dt >= begin_time,
454
- TagRecordTable.begin_dt <= end_time,
455
- )
610
+ tag_record = BagTagRecord(
611
+ vehicle_name=vehicle_name,
612
+ bag_name=bag_name,
613
+ begin_offset=begin_offset,
614
+ end_offset=end_offset,
615
+ tag=tag.name if isinstance(tag, Tag) else tag,
616
+ props=props,
456
617
  )
618
+ session.add(clone_sequence_model_instance(BagTagRecordTable, tag_record))
619
+ session.commit()
620
+
621
+ return self
622
+
623
+ def remove(
624
+ self,
625
+ vehicle_name: str | None = None,
626
+ begin_time: datetime.datetime | None = None,
627
+ end_time: datetime.datetime | None = None,
628
+ tag_pattern: str | None = None,
629
+ *,
630
+ tagsets: Sequence[Tagset] | None = None,
631
+ tagset_inverted: bool = False,
632
+ ) -> Self:
633
+ """
634
+ Remove tag records from the cache that match the specified filters.
635
+
636
+ :param vehicle_name: Filter by vehicle name (exact match)
637
+ :param begin_time: Filter by begin time (inclusive)
638
+ :param end_time: Filter by end time (inclusive)
639
+ :param tag_pattern: Filter by tag name pattern (SQL LIKE syntax, e.g. "dummy_tag:%" to match all tags starting
640
+ with "dummy_tag:")
641
+ :param tagsets: Filter by tagsets (match tags that are in any of the specified tagsets)
642
+ :param tagset_inverted: Whether to invert the tagset filter (match tags that are NOT in any of the specified
643
+ tagsets)
644
+ :return: Self instance for chaining
645
+ """
646
+ with self.conn_maker.make_session() as session:
647
+ query = session.query(TagRecordTable)
648
+ if vehicle_name:
649
+ query = query.filter(TagRecordTable.vehicle_name == vehicle_name)
650
+ if begin_time:
651
+ query = query.filter(TagRecordTable.end_dt >= begin_time)
652
+ if end_time:
653
+ query = query.filter(TagRecordTable.begin_dt <= end_time)
654
+ if tag_pattern:
655
+ query = query.filter(TagRecordTable.tag.like(f"{escape_sql_like(tag_pattern)}%", escape="\\"))
656
+ if tagsets:
657
+ if tagset_inverted:
658
+ query = query.filter(
659
+ TagRecordTable.tag.notin_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
660
+ else:
661
+ query = query.filter(
662
+ TagRecordTable.tag.in_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
663
+
664
+ query.delete()
665
+ session.commit()
666
+
667
+ return self
668
+
669
+ def remove_bag_tag(
670
+ self,
671
+ vehicle_name: str | None = None,
672
+ bag_name: str | None = None,
673
+ begin_offset: int | None = None,
674
+ end_offset: int | None = None,
675
+ tag_pattern: str | None = None,
676
+ *,
677
+ tagsets: Sequence[Tagset] | None = None,
678
+ tagset_inverted: bool = False,
679
+ ) -> Self:
680
+ """
681
+ Remove bag tag records from the cache that match the specified filters.
682
+
683
+ :param vehicle_name: Filter by vehicle name (exact match)
684
+ :param bag_name: Filter by bag name (exact match)
685
+ :param begin_offset: Filter by begin offset (inclusive)
686
+ :param end_offset: Filter by end offset (inclusive)
687
+ :param tag_pattern: Filter by tag name pattern (SQL LIKE syntax, e.g. "dummy_tag:%" to match all tags starting
688
+ with "dummy_tag:")
689
+ :param tagsets: Filter by tagsets (match tags that are in any of the specified tagsets)
690
+ :param tagset_inverted: Whether to invert the tagset filter (match tags that are NOT in any of the specified
691
+ tagsets)
692
+ :return: Self instance for chaining
693
+ """
694
+ with self.conn_maker.make_session() as session:
695
+ query = session.query(BagTagRecordTable)
696
+ if vehicle_name:
697
+ query = query.filter(BagTagRecordTable.vehicle_name == vehicle_name)
698
+ if bag_name:
699
+ query = query.filter(BagTagRecordTable.bag_name == bag_name)
700
+ if begin_offset:
701
+ query = query.filter(BagTagRecordTable.end_offset >= begin_offset)
702
+ if end_offset:
703
+ query = query.filter(BagTagRecordTable.begin_offset <= end_offset)
704
+ if tag_pattern:
705
+ query = query.filter(BagTagRecordTable.tag.like(f"{escape_sql_like(tag_pattern)}%", escape="\\"))
706
+ if tagsets:
707
+ if tagset_inverted:
708
+ query = query.filter(
709
+ BagTagRecordTable.tag.notin_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
710
+ else:
711
+ query = query.filter(
712
+ BagTagRecordTable.tag.in_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
713
+
714
+ query.delete()
457
715
  session.commit()
458
716
 
459
717
  return self
@@ -461,6 +719,7 @@ class TagCache(object):
461
719
  def clear(self):
462
720
  with self.conn_maker.make_session() as session:
463
721
  session.execute(sa.delete(TagRecordTable))
722
+ session.execute(sa.delete(BagTagRecordTable))
464
723
  session.commit()
465
724
 
466
725
  def append_to(self, target_file_path: str, *, overwrite: bool = False):
@@ -479,6 +738,29 @@ class TagCache(object):
479
738
  def clone_to(source: "TagCache", target: "TagCache"):
480
739
  target.clear()
481
740
  with source.conn_maker.make_session() as source_session, target.conn_maker.make_session() as target_session:
482
- target_session.add_all([clone_sequence_model_instance(TagRecordTable, db_tag_record, clear_meta_fields=True)
483
- for db_tag_record in source_session.query(TagRecordTable).all()])
741
+ target_session.add_all(
742
+ [clone_sequence_model_instance(TagRecordTable, db_tag_record, clear_meta_fields=True)
743
+ for db_tag_record in source_session.query(TagRecordTable).all()],
744
+ )
745
+ target_session.add_all(
746
+ [clone_sequence_model_instance(BagTagRecordTable, db_tag_record, clear_meta_fields=True)
747
+ for db_tag_record in source_session.query(BagTagRecordTable).all()],
748
+ )
484
749
  target_session.commit()
750
+
751
+
752
+ @memorized
753
+ def tag_cache(identifier: str | None = None) -> TagCache:
754
+ """
755
+ Get a ``TagCache`` instance associated with the given identifier. If the identifier is ``None``, return a
756
+ ``TagCache`` instance associated with a default file path. Otherwise, validate the identifier as a snake case
757
+ string and return a ``TagCache`` instance associated with a file path derived from the identifier.
758
+
759
+ :param identifier: An optional string identifier for the tag cache. If provided, it must be in snake case format
760
+ and will be used to derive the file path for the tag cache. If not provided, a default file path will be used.
761
+ :return: A ``TagCache`` instance associated with the specified or default file path.
762
+ """
763
+ if identifier is None:
764
+ return TagCache(file_path=tag_cache_file_path())
765
+ validate_snake_case(identifier)
766
+ return TagCache(file_path=tag_cache_file_path().parent / f"{identifier}.db")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python-common
3
- Version: 1.0.49
3
+ Version: 1.0.50
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.12
6
6
  Classifier: Programming Language :: Python :: 3.13
@@ -55,10 +55,20 @@ class TagUtilsTest(unittest.TestCase):
55
55
  dt_parse_iso("2021-01-01T00:00:00.000000+00:00") + datetime.timedelta(seconds=i + 1),
56
56
  tags[i % len(tags)],
57
57
  )
58
+ for i in range(1000):
59
+ tag_cache.add_bag_tag(
60
+ "dummy_vehicle",
61
+ f"20200101T000000-dummy_vehicle-{i // 100}.bag",
62
+ i % 100,
63
+ i % 100 + 1,
64
+ tags[i % len(tags)],
65
+ )
58
66
 
59
67
  self.assertEqual(len(list(tag_cache.query())), 1000)
60
68
  self.assertEqual(len(list(tag_cache.query("dummy_vehicle"))), 1000)
61
69
  self.assertEqual(len(list(tag_cache.query(tagsets=[tagset]))), 500)
70
+ self.assertEqual(len(list(tag_cache.query(tagsets=[tagset], tagset_inverted=True))), 500)
71
+ self.assertEqual(len(list(tag_cache.query(tagsets=[tagset], tagset_inverted=False))), 500)
62
72
  self.assertEqual(
63
73
  len(list(tag_cache.query("dummy_vehicle",
64
74
  dt_parse_iso("2020-01-01T00:00:00.000000+00:00"),
@@ -90,15 +100,36 @@ class TagUtilsTest(unittest.TestCase):
90
100
  self.assertEqual(len(list(tag_cache.query("dummy_vehicle", tag_pattern="dummy:bar"))), 250)
91
101
  self.assertEqual(len(list(tag_cache.query("dummy_vehicle", tag_pattern="dummy"))), 1000)
92
102
  self.assertEqual(len(list(tag_cache.query("another_dummy_vehicle"))), 0)
93
-
94
- self.assertEqual(len(list(tag_cache.undefined([tagset]))), 500)
103
+ self.assertEqual(len(list(tag_cache.query_bag_tag())), 1000)
104
+ self.assertEqual(len(list(tag_cache.query_bag_tag(tagsets=[tagset]))), 500)
105
+ self.assertEqual(len(list(tag_cache.query_bag_tag(tagsets=[tagset], tagset_inverted=True))), 500)
106
+ self.assertEqual(len(list(tag_cache.query_bag_tag(tagsets=[tagset], tagset_inverted=False))), 500)
107
+ self.assertEqual(len(list(tag_cache.query_bag_tag("dummy_vehicle"))), 1000)
108
+ self.assertEqual(len(list(tag_cache.query_bag_tag("dummy_vehicle",
109
+ "20200101T000000-dummy_vehicle-0.bag"))),
110
+ 100)
111
+ self.assertEqual(len(list(tag_cache.query_bag_tag("dummy_vehicle",
112
+ "20200101T000000-dummy_vehicle-0.bag",
113
+ 0,
114
+ 50))),
115
+ 51)
95
116
 
96
117
  tag_cache.remove("dummy_vehicle",
97
118
  dt_parse_iso("2020-01-01T00:00:00.000000+00:00"),
98
119
  dt_parse_iso("2020-01-01T00:01:00.000000+00:00"))
120
+ tag_cache.remove_bag_tag("dummy_vehicle",
121
+ "20200101T000000-dummy_vehicle-0.bag")
99
122
 
100
123
  self.assertEqual(len(list(tag_cache.query("dummy_vehicle"))), 939)
124
+ self.assertEqual(len(list(tag_cache.query_bag_tag("dummy_vehicle"))), 900)
125
+
126
+ tag_cache.remove(tagsets=[tagset], tagset_inverted=True)
127
+ tag_cache.remove_bag_tag(tagsets=[tagset], tagset_inverted=True)
128
+
129
+ self.assertEqual(len(list(tag_cache.query())), 469)
130
+ self.assertEqual(len(list(tag_cache.query_bag_tag())), 450)
101
131
 
102
132
  tag_cache.clear()
103
133
 
104
- self.assertEqual(len(list(tag_cache.query("dummy_vehicle"))), 0)
134
+ self.assertEqual(len(list(tag_cache.query())), 0)
135
+ self.assertEqual(len(list(tag_cache.query_bag_tag())), 0)