plexus-python-pulse-common 1.1.75__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.
@@ -0,0 +1,1476 @@
1
+ import contextlib
2
+ import dataclasses
3
+ import datetime
4
+ import math
5
+ import os
6
+ import pathlib
7
+ import textwrap
8
+ import threading
9
+ import typing
10
+ from collections.abc import Generator, Mapping, Sequence
11
+ from typing import Self
12
+
13
+ import jinja2
14
+ import jsonschema
15
+ import pydantic as pdt
16
+ import sqlalchemy as sa
17
+ import sqlalchemy.dialects.sqlite as sa_sqlite
18
+ import sqlalchemy.orm as sa_orm
19
+ from plexus.common.utils.dbutils import ConnectionMaker
20
+ from plexus.common.utils.dtutils import dt_from_ts_us, dt_to_ts_us
21
+ from plexus.common.utils.funcutils import Chainable
22
+ from plexus.common.utils.funcutils import chainable, memorized, singleton
23
+ from plexus.common.utils.iterutils import batched, head_or_none
24
+ from plexus.common.utils.iterutils import dicttree
25
+ from plexus.common.utils.iterutils import dicttree_add, dicttree_remove
26
+ from plexus.common.utils.iterutils import dicttree_children, dicttree_lineage, dicttree_subtree
27
+ from plexus.common.utils.jsonutils import JsonObject, JsonType
28
+ from plexus.common.utils.randutils import randomizer
29
+ from plexus.common.utils.strutils import is_blank
30
+ from sqlmodel import Field
31
+
32
+ from plexus.pulse.common.resources.tags import predefined_tagset_specs
33
+ from plexus.pulse.common.utils.datautils import validate_colon_tag, validate_snake_case, validate_vehicle_name
34
+ from plexus.pulse.common.utils.datautils import validate_dt_timezone, validate_semver, validate_slash_tag
35
+ from plexus.pulse.common.utils.jsonutils import json_datetime_encoder
36
+ from plexus.pulse.common.utils.ormutils import SQLiteDateTime, SequenceModelMixinProtocol
37
+ from plexus.pulse.common.utils.ormutils import clone_sequence_model_instance, make_base_model, make_sequence_model_mixin
38
+ from plexus.pulse.common.utils.sqlutils import escape_sql_like
39
+
40
+ __all__ = [
41
+ "versioned_identifier",
42
+ "split_versioned_identifier",
43
+ "RichDesc",
44
+ "Tag",
45
+ "BoundTag",
46
+ "Tagset",
47
+ "MutableTagset",
48
+ "populate_tagset",
49
+ "predefined_tagsets",
50
+ "render_tagset_markdown_readme",
51
+ "make_tag_target_model_mixin",
52
+ "make_tag_record_model_mixin",
53
+ "TagTarget",
54
+ "TagRecord",
55
+ "TagTargetTable",
56
+ "TagRecordTable",
57
+ "tag_cache_file_path",
58
+ "TagCache",
59
+ "tag_cache",
60
+ "standard_clip_duration_us",
61
+ "populate_clip_ranges",
62
+ ]
63
+
64
+
65
+ def versioned_identifier(identifier: str | None, version: str | None) -> str | None:
66
+ if identifier is None or version is None:
67
+ return identifier
68
+ return f"{identifier}@{version}"
69
+
70
+
71
+ def split_versioned_identifier(s: str | None) -> tuple[str | None, str | None]:
72
+ if s is None:
73
+ return None, None
74
+ if "@" not in s:
75
+ return s, None
76
+ identifier, version = s.rsplit("@", 1)
77
+ return identifier, version
78
+
79
+
80
+ @dataclasses.dataclass(frozen=True, eq=True, order=True)
81
+ class RichDesc(object):
82
+ type: str
83
+ text: str
84
+
85
+
86
+ @dataclasses.dataclass(frozen=True, eq=True, order=True)
87
+ class Tag(object):
88
+ name: str
89
+ desc: str | RichDesc | None
90
+ schema: JsonObject | None = None
91
+
92
+ @property
93
+ def tag_parts(self) -> list[str]:
94
+ return self.name.rsplit(":")
95
+
96
+ @property
97
+ def parent_tag_name(self) -> str | None:
98
+ name = head_or_none(self.name.rsplit(":", 1))
99
+ return None if is_blank(name) or name == self.name else name
100
+
101
+ def unbind(self) -> Self:
102
+ return self
103
+
104
+ def validate(self, features: JsonType | None, *, raise_on_error: bool = False) -> bool:
105
+ if self.schema is None or features is None:
106
+ return True
107
+ try:
108
+ jsonschema.Draft7Validator(self.schema).validate(features)
109
+ return True
110
+ except (jsonschema.ValidationError, jsonschema.SchemaError):
111
+ if raise_on_error:
112
+ raise
113
+ return False
114
+
115
+
116
+ @dataclasses.dataclass(frozen=True, eq=True, order=True)
117
+ class BoundTag(Tag):
118
+ namespace: str | None = None
119
+ version: str | None = None
120
+
121
+ def unbind(self) -> Tag:
122
+ return Tag(name=self.name, desc=self.desc, schema=self.schema)
123
+
124
+
125
+ class Tagset(Sequence[Tag], Mapping[str, Tag]):
126
+ def __init__(self, namespace: str, version: str, desc: str | RichDesc) -> None:
127
+ super().__init__()
128
+ self.namespace = namespace
129
+ self.version = version
130
+ self.desc = desc
131
+ self.tags: list[Tag] = []
132
+ self.tags_dict: dict[str, Tag] = {}
133
+ self.tags_tree: dicttree[str, Tag] = {}
134
+
135
+ def __contains__(self, item: str | Tag | BoundTag | None) -> bool:
136
+ try:
137
+ return self.get(item) is not None
138
+ except ValueError:
139
+ return False
140
+
141
+ def __len__(self) -> int:
142
+ return len(self.tags)
143
+
144
+ def __getitem__(self, index: int) -> Tag:
145
+ return self.tags[index]
146
+
147
+ def keys(self):
148
+ return self.tags_dict.keys()
149
+
150
+ def values(self):
151
+ return self.tags_dict.values()
152
+
153
+ def items(self):
154
+ return self.tags_dict.items()
155
+
156
+ def get(self, item: str | Tag | BoundTag) -> Tag | None:
157
+ if item is None:
158
+ return None
159
+ if isinstance(item, BoundTag):
160
+ if item.namespace != self.namespace or item.version != self.version:
161
+ raise ValueError(f"tag bind '{item}' does not belong to this tagset")
162
+ tag_name = item.name if isinstance(item, Tag) else item
163
+ return self.tags_dict.get(tag_name)
164
+
165
+ def bind(self, tag: Tag) -> BoundTag:
166
+ return BoundTag(tag.name, tag.desc, tag.schema, self.namespace, self.version)
167
+
168
+ def get_bound(self, item: str | Tag | BoundTag) -> BoundTag | None:
169
+ tag = self.get(item)
170
+ return None if tag is None else self.bind(tag)
171
+
172
+ def clone(self) -> Self:
173
+ return clone_tagset(self)
174
+
175
+ def mutable(self):
176
+ return clone_mutable_tagset(self)
177
+
178
+ def frozen(self):
179
+ return self.clone()
180
+
181
+ @property
182
+ def tag_names(self) -> list[str]:
183
+ return list(self.tags_dict.keys())
184
+
185
+ def child_tags(self, parent: str | Tag | BoundTag) -> list[Tag]:
186
+ parent = self.get(parent)
187
+ if parent is None:
188
+ return []
189
+ subtree = dicttree_subtree(self.tags_tree, parent.tag_parts)
190
+ return list(dicttree_children(subtree)) if subtree else []
191
+
192
+ def parent_tags(self, child: str | Tag | BoundTag) -> list[Tag]:
193
+ child = self.get(child)
194
+ if child is None:
195
+ return []
196
+ return list(dicttree_lineage(self.tags_tree, child.tag_parts[:-1]))
197
+
198
+ def validate(self, tag_name: str, features: JsonType | None, *, raise_on_error: bool = False) -> bool:
199
+ tag = self.get(tag_name)
200
+ return False if tag is None else tag.validate(features, raise_on_error=raise_on_error)
201
+
202
+
203
+ class MutableTagset(Tagset):
204
+
205
+ def clone(self) -> Self:
206
+ return clone_mutable_tagset(self)
207
+
208
+ def mutable(self):
209
+ return self.clone()
210
+
211
+ def frozen(self):
212
+ return clone_tagset(self)
213
+
214
+ def add(self, tag: Tag | BoundTag) -> Self:
215
+ if tag.name in self.tags_dict:
216
+ raise ValueError(f"duplicate tag name '{tag.name}'")
217
+ if isinstance(tag, BoundTag):
218
+ if tag.namespace != self.namespace or tag.version != self.version:
219
+ raise ValueError(f"tag bind '{tag}' does not belong to this tagset")
220
+
221
+ tag = tag.unbind()
222
+
223
+ self.tags.append(tag)
224
+ self.tags_dict[tag.name] = tag
225
+
226
+ dicttree_add(self.tags_tree, tag.tag_parts, tag, create_prefix=True)
227
+
228
+ return self
229
+
230
+ def remove(self, tag_name: str) -> Self:
231
+ tag = self.get(tag_name)
232
+ if tag is None:
233
+ raise ValueError(f"tag '{tag_name}' not found")
234
+
235
+ self.tags.remove(tag)
236
+ del self.tags_dict[tag_name]
237
+
238
+ dicttree_remove(self.tags_tree, tag.tag_parts, recursive=True)
239
+
240
+ return self
241
+
242
+
243
+ def clone_tagset(tagset: Tagset) -> Tagset:
244
+ tagset = clone_mutable_tagset(tagset)
245
+ new_tagset = Tagset(namespace=tagset.namespace, version=tagset.version, desc=tagset.desc)
246
+ new_tagset.tags = tagset.tags
247
+ new_tagset.tags_dict = tagset.tags_dict
248
+ new_tagset.tags_tree = tagset.tags_tree
249
+ return new_tagset
250
+
251
+
252
+ def clone_mutable_tagset(tagset: Tagset) -> MutableTagset:
253
+ new_tagset = MutableTagset(namespace=tagset.namespace, version=tagset.version, desc=tagset.desc)
254
+ for tag in tagset.tags:
255
+ new_tagset.add(tag)
256
+ return new_tagset
257
+
258
+
259
+ def populate_tagset(tagset_spec: JsonObject) -> Tagset:
260
+ """
261
+ Collect tags from tagset spec JSON object, validate the format along the way.
262
+
263
+ :param tagset_spec: JSON object of tagset spec
264
+ :return: Populated Tagset instance
265
+ """
266
+
267
+ def validate_and_collect(name: str, tag_def: JsonObject) -> Generator[Tag, None, None]:
268
+ if not isinstance(tag_def, dict):
269
+ raise ValueError(f"tag '{name}' definition is not a dict")
270
+
271
+ if not is_blank(name):
272
+ desc = tag_def.get("$desc")
273
+ if isinstance(desc, dict):
274
+ desc = RichDesc(**desc)
275
+
276
+ schema = tag_def.get("$schema")
277
+ if schema is not None:
278
+ try:
279
+ jsonschema.Draft7Validator.check_schema(schema)
280
+ except jsonschema.SchemaError as e:
281
+ raise ValueError(f"invalid JSON schema for tag '{name}'") from e
282
+
283
+ yield Tag(name=name, desc=desc, schema=schema)
284
+
285
+ for child_name, child_tag_def in tag_def.items():
286
+ if child_name in ("$desc", "$schema"):
287
+ continue
288
+
289
+ if not isinstance(child_name, str):
290
+ raise ValueError(f"child '{child_name}' of tag '{name}' is not a string")
291
+ try:
292
+ validate_snake_case(child_name)
293
+ except ValueError as e:
294
+ raise ValueError(f"child '{child_name}' of tag '{name}' is not in snake case") from e
295
+
296
+ child_name = name + ":" + child_name if name else child_name
297
+
298
+ yield from validate_and_collect(child_name, child_tag_def)
299
+
300
+ namespace = tagset_spec.get("$namespace")
301
+ if namespace is None:
302
+ raise ValueError("missing '$namespace' in tagset spec")
303
+ try:
304
+ validate_snake_case(namespace)
305
+ except ValueError as e:
306
+ raise ValueError(f"tagset namespace '{namespace}' is not in snake case") from e
307
+
308
+ version = tagset_spec.get("$version")
309
+ if version is None:
310
+ raise ValueError("missing '$version' in tagset spec")
311
+ try:
312
+ validate_semver(version)
313
+ except ValueError as e:
314
+ raise ValueError(f"tagset version '{version}' is not a valid semantic version") from e
315
+
316
+ desc = tagset_spec.get("$desc")
317
+ if desc is None:
318
+ raise ValueError("missing '$desc' in tagset spec")
319
+ if isinstance(desc, dict):
320
+ desc = RichDesc(**desc)
321
+
322
+ tags = tagset_spec.get("$tags")
323
+ if tags is None:
324
+ raise ValueError("missing '$tags' in tagset spec")
325
+
326
+ tagset = MutableTagset(namespace=namespace, version=version, desc=desc)
327
+
328
+ for tag in validate_and_collect("", tags):
329
+ tagset.add(tag)
330
+
331
+ return tagset.frozen()
332
+
333
+
334
+ @singleton
335
+ def predefined_tagsets() -> dict[str, Tagset]:
336
+ from packaging.version import Version
337
+
338
+ tagsets: dict[str, Tagset] = {}
339
+ latest_targets: dict[str, tuple[Version, Tagset]] = {}
340
+
341
+ for _, tagset_spec in predefined_tagset_specs():
342
+ tagset = populate_tagset(tagset_spec)
343
+ version = Version(tagset.version)
344
+ if tagset.namespace in latest_targets:
345
+ latest_version, _ = latest_targets[tagset.namespace]
346
+ if version > latest_version:
347
+ latest_targets[tagset.namespace] = (version, tagset)
348
+ else:
349
+ latest_targets[tagset.namespace] = (version, tagset)
350
+
351
+ tagsets[versioned_identifier(tagset.namespace, tagset.version)] = tagset
352
+
353
+ for _, tagset in latest_targets.values():
354
+ tagsets[tagset.namespace] = tagset
355
+
356
+ return tagsets
357
+
358
+
359
+ def render_tagset_markdown_readme(tagset: Tagset) -> str:
360
+ def render_desc(desc: str | RichDesc | None) -> str:
361
+ if desc is None:
362
+ return ""
363
+ if isinstance(desc, str):
364
+ return desc
365
+ if isinstance(desc, RichDesc):
366
+ return desc.text
367
+ raise ValueError(f"unsupported desc type '{type(desc)}'")
368
+
369
+ template_str = textwrap.dedent(
370
+ """
371
+ # Tagset {{ tagset.namespace }} (Version {{ tagset.version }})
372
+
373
+ {{ tagset.desc | render_desc }}
374
+
375
+ ## Contents
376
+
377
+ {% for tag in tagset.tags %}
378
+ - {{ tag.name }}
379
+ {% endfor %}
380
+
381
+ ## Tags
382
+
383
+ {% for tag in tagset.tags %}
384
+ ### {{ tag.name }}
385
+
386
+ {{ tag.desc | render_desc }}
387
+
388
+ {% endfor %}
389
+ """
390
+ )
391
+
392
+ env = jinja2.Environment(trim_blocks=True, lstrip_blocks=True)
393
+ env.filters["render_desc"] = render_desc
394
+
395
+ return env.from_string(template_str).render(tagset=tagset)
396
+
397
+
398
+ def make_tag_target_model_mixin() -> type[pdt.BaseModel]:
399
+ class ModelMixin(pdt.BaseModel):
400
+ identifier: str = Field(
401
+ sa_column=sa.Column(sa_sqlite.VARCHAR(256), nullable=False, unique=True),
402
+ description="Identifier of the tag target",
403
+ )
404
+ tagger_name: str = Field(
405
+ sa_column=sa.Column(sa_sqlite.VARCHAR(128), nullable=False),
406
+ description="Name of the tagger that generates the tag records for the target",
407
+ )
408
+ tagger_version: str = Field(
409
+ sa_column=sa.Column(sa_sqlite.VARCHAR(32), nullable=False),
410
+ description="Version of the tagger that generates the tag records for the target",
411
+ )
412
+ vehicle_name: str = Field(
413
+ sa_column=sa.Column(sa_sqlite.VARCHAR(128), nullable=False),
414
+ description="Name of the tagger's applicability vehicle",
415
+ )
416
+ begin_dt: datetime.datetime = Field(
417
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
418
+ description="Beginning of the tagger's applicability time range",
419
+ )
420
+ end_dt: datetime.datetime = Field(
421
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
422
+ description="End of the tagger's applicability time range",
423
+ )
424
+ props: JsonType | None = Field(
425
+ sa_column=sa.Column(sa_sqlite.JSON, nullable=True),
426
+ default=None,
427
+ description="Additional properties of the tag target in JSON format",
428
+ )
429
+
430
+ @pdt.field_validator("identifier", mode="after")
431
+ @classmethod
432
+ def validate_identifier(cls, v: str) -> str:
433
+ validate_slash_tag(v)
434
+ return v
435
+
436
+ @pdt.field_validator("tagger_name", mode="after")
437
+ @classmethod
438
+ def validate_tagger_name(cls, v: str) -> str:
439
+ validate_slash_tag(v)
440
+ return v
441
+
442
+ @pdt.field_validator("tagger_version", mode="after")
443
+ @classmethod
444
+ def validate_tagger_version(cls, v: str) -> str:
445
+ validate_semver(v)
446
+ return v
447
+
448
+ @pdt.field_validator("vehicle_name", mode="after")
449
+ @classmethod
450
+ def validate_vehicle_name(cls, v: str) -> str:
451
+ validate_vehicle_name(v)
452
+ return v
453
+
454
+ @pdt.field_validator("begin_dt", mode="after")
455
+ @classmethod
456
+ def validate_begin_dt(cls, v: datetime.datetime) -> datetime.datetime:
457
+ validate_dt_timezone(v)
458
+ return v
459
+
460
+ @pdt.field_validator("end_dt", mode="after")
461
+ @classmethod
462
+ def validate_end_dt(cls, v: datetime.datetime) -> datetime.datetime:
463
+ validate_dt_timezone(v)
464
+ return v
465
+
466
+ @pdt.model_validator(mode="after")
467
+ def validate_begin_dt_end_dt(self) -> Self:
468
+ if self.begin_dt > self.end_dt:
469
+ raise ValueError(f"begin_dt '{self.begin_dt}' is greater than end_dt '{self.end_dt}'")
470
+ return self
471
+
472
+ @pdt.field_serializer("begin_dt", mode="plain")
473
+ def serialize_begin_dt(self, v: datetime.datetime) -> str:
474
+ return json_datetime_encoder(v)
475
+
476
+ @pdt.field_serializer("end_dt", mode="plain")
477
+ def serialize_end_dt(self, v: datetime.datetime) -> str:
478
+ return json_datetime_encoder(v)
479
+
480
+ return ModelMixin
481
+
482
+
483
+ def make_tag_record_model_mixin() -> type[pdt.BaseModel]:
484
+ class ModelMixin(pdt.BaseModel):
485
+ target_sqn: int = Field(
486
+ sa_column=sa.Column(sa_sqlite.INTEGER, nullable=False),
487
+ description="Sequence number of the tag record's target",
488
+ )
489
+ begin_dt: datetime.datetime = Field(
490
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
491
+ description="Begin of the tag record time range",
492
+ )
493
+ end_dt: datetime.datetime = Field(
494
+ sa_column=sa.Column(SQLiteDateTime, nullable=False),
495
+ description="End of the tag record time range",
496
+ )
497
+ tagset_namespace: str | None = Field(
498
+ sa_column=sa.Column(sa_sqlite.VARCHAR(64), nullable=True),
499
+ default=None,
500
+ description="Namespace of the tagset that the tag in this record belongs to",
501
+ )
502
+ tagset_version: str | None = Field(
503
+ sa_column=sa.Column(sa_sqlite.VARCHAR(32), nullable=True),
504
+ default=None,
505
+ description="Version of the tagset that the tag in this record belongs to",
506
+ )
507
+ tag: str = Field(
508
+ sa_column=sa.Column(sa_sqlite.VARCHAR(256), nullable=False),
509
+ description="Tag name",
510
+ )
511
+ features: JsonType | None = Field(
512
+ sa_column=sa.Column(sa_sqlite.JSON, nullable=True),
513
+ default=None,
514
+ description="Tag features in JSON format, which can be used for storing preprocessed information of the tag record to facilitate querying and filtering",
515
+ )
516
+ props: JsonType | None = Field(
517
+ sa_column=sa.Column(sa_sqlite.JSON, nullable=True),
518
+ default=None,
519
+ description="Additional properties of the tag record in JSON format",
520
+ )
521
+ flags: int = Field(
522
+ sa_column=sa.Column(sa_sqlite.INTEGER),
523
+ default=0,
524
+ description="Integer bitmask storing status or metadata flags for this tag record",
525
+ )
526
+
527
+ @pdt.field_validator("begin_dt", mode="after")
528
+ @classmethod
529
+ def validate_begin_dt(cls, v: datetime.datetime) -> datetime.datetime:
530
+ validate_dt_timezone(v)
531
+ return v
532
+
533
+ @pdt.field_validator("end_dt", mode="after")
534
+ @classmethod
535
+ def validate_end_dt(cls, v: datetime.datetime) -> datetime.datetime:
536
+ validate_dt_timezone(v)
537
+ return v
538
+
539
+ @pdt.field_validator("tagset_namespace", mode="after")
540
+ @classmethod
541
+ def validate_tagset_namespace(cls, v: str | None) -> str | None:
542
+ if v is not None:
543
+ validate_snake_case(v)
544
+ return v
545
+
546
+ @pdt.field_validator("tagset_version", mode="after")
547
+ @classmethod
548
+ def validate_tagset_version(cls, v: str | None) -> str | None:
549
+ if v is not None:
550
+ validate_semver(v)
551
+ return v
552
+
553
+ @pdt.model_validator(mode="after")
554
+ def validate_begin_dt_end_dt(self) -> Self:
555
+ if self.begin_dt > self.end_dt:
556
+ raise ValueError(f"begin_dt '{self.begin_dt}' is greater than end_dt '{self.end_dt}'")
557
+ return self
558
+
559
+ @pdt.field_validator("tag", mode="after")
560
+ @classmethod
561
+ def validate_tag(cls, v: str) -> str:
562
+ validate_colon_tag(v)
563
+ return v
564
+
565
+ @pdt.field_serializer("begin_dt", mode="plain")
566
+ def serialize_begin_dt(self, v: datetime.datetime) -> str:
567
+ return json_datetime_encoder(v)
568
+
569
+ @pdt.field_serializer("end_dt", mode="plain")
570
+ def serialize_end_dt(self, v: datetime.datetime) -> str:
571
+ return json_datetime_encoder(v)
572
+
573
+ return ModelMixin
574
+
575
+
576
+ BaseModel = make_base_model()
577
+
578
+
579
+ class TagTarget(BaseModel, make_tag_target_model_mixin()):
580
+ pass
581
+
582
+
583
+ class TagRecord(BaseModel, make_tag_record_model_mixin()):
584
+ pass
585
+
586
+
587
+ class TagTargetTable(TagTarget, make_sequence_model_mixin("sqlite"), table=True):
588
+ __tablename__ = "tag_target"
589
+
590
+
591
+ class TagRecordTable(TagRecord, make_sequence_model_mixin("sqlite"), table=True):
592
+ __tablename__ = "tag_record"
593
+
594
+
595
+ if typing.TYPE_CHECKING:
596
+ class TagTarget(BaseModel):
597
+ identifier: sa_orm.Mapped[str] = ...
598
+ tagger_name: sa_orm.Mapped[str] = ...
599
+ tagger_version: sa_orm.Mapped[str] = ...
600
+ vehicle_name: sa_orm.Mapped[str] = ...
601
+ begin_dt: sa_orm.Mapped[datetime.datetime] = ...
602
+ end_dt: sa_orm.Mapped[datetime.datetime] = ...
603
+ props: sa_orm.Mapped[JsonType | None] = ...
604
+
605
+
606
+ class TagTargetTable(TagTarget, SequenceModelMixinProtocol):
607
+ pass
608
+
609
+
610
+ class TagRecord(BaseModel):
611
+ target_sqn: sa_orm.Mapped[int] = ...
612
+ begin_dt: sa_orm.Mapped[datetime.datetime] = ...
613
+ end_dt: sa_orm.Mapped[datetime.datetime] = ...
614
+ tagset_namespace: sa_orm.Mapped[str | None] = ...
615
+ tagset_version: sa_orm.Mapped[str | None] = ...
616
+ tag: sa_orm.Mapped[str] = ...
617
+ features: sa_orm.Mapped[JsonType | None] = ...
618
+ props: sa_orm.Mapped[JsonType | None] = ...
619
+ flags: sa_orm.Mapped[int] = ...
620
+
621
+
622
+ class TagRecordTable(TagRecord, SequenceModelMixinProtocol):
623
+ pass
624
+
625
+
626
+ @singleton
627
+ def tag_cache_file_path() -> pathlib.Path:
628
+ return pathlib.Path.home() / ".local" / "plexus" / "tag_cache" / f"{randomizer().random_alphanumeric(7)}.db"
629
+
630
+
631
+ class TagCache(object):
632
+ def __init__(
633
+ self,
634
+ *,
635
+ file_path: str | os.PathLike[str] | None = None,
636
+ fail_if_exists: bool = False,
637
+ ):
638
+ self.file_path = pathlib.Path(file_path or tag_cache_file_path())
639
+ if fail_if_exists and self.file_path.exists():
640
+ raise FileExistsError(f"tag cache file '{str(self.file_path)}' already exists")
641
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
642
+
643
+ self.conn_maker = ConnectionMaker.from_url(f"sqlite:///{str(self.file_path.absolute())}",
644
+ engine_opts=dict(connect_args={"check_same_thread": False}))
645
+
646
+ self.thread_lock = threading.Lock()
647
+
648
+ BaseModel.metadata.create_all(self.conn_maker.engine)
649
+
650
+ @contextlib.contextmanager
651
+ def make_session(self) -> Generator[sa_orm.Session, None, None]:
652
+ with self.thread_lock, self.conn_maker.make_session(expire_on_commit=False) as session:
653
+ try:
654
+ yield session
655
+ session.commit()
656
+ except Exception:
657
+ session.rollback()
658
+ raise
659
+
660
+ def get_target(self, target: int | str) -> TagTargetTable | None:
661
+ with self.make_session() as session:
662
+ if isinstance(target, int):
663
+ return session.get(TagTargetTable, target)
664
+ else:
665
+ return session.query(TagTargetTable).filter(TagTargetTable.identifier == target).one_or_none()
666
+
667
+ def query_targets(
668
+ self,
669
+ identifier: str | None = None,
670
+ tagger_name: str | None = None,
671
+ tagger_version: str | None = None,
672
+ vehicle_name: str | None = None,
673
+ begin_dt: datetime.datetime | None = None,
674
+ end_dt: datetime.datetime | None = None,
675
+ ) -> list[TagTargetTable]:
676
+ with self.make_session() as session:
677
+ query_stmt = session.query(TagTargetTable)
678
+ if identifier:
679
+ query_stmt = query_stmt.filter(TagTargetTable.identifier == identifier)
680
+ if tagger_name:
681
+ query_stmt = query_stmt.filter(TagTargetTable.tagger_name == tagger_name)
682
+ if tagger_version:
683
+ query_stmt = query_stmt.filter(TagTargetTable.tagger_version == tagger_version)
684
+ if vehicle_name:
685
+ query_stmt = query_stmt.filter(TagTargetTable.vehicle_name == vehicle_name)
686
+ if begin_dt:
687
+ query_stmt = query_stmt.filter(TagTargetTable.end_dt >= begin_dt)
688
+ if end_dt:
689
+ query_stmt = query_stmt.filter(TagTargetTable.begin_dt <= end_dt)
690
+
691
+ return query_stmt.all()
692
+
693
+ def add_target(
694
+ self,
695
+ identifier: str,
696
+ tagger_name: str,
697
+ tagger_version: str,
698
+ vehicle_name: str,
699
+ begin_dt: datetime.datetime,
700
+ end_dt: datetime.datetime,
701
+ props: JsonType | None = None,
702
+ ) -> Chainable[Self, TagTargetTable]:
703
+ """
704
+ Create a new tag target and add it to the cache.
705
+
706
+ :param identifier: Identifier of the tag target, which should be unique across the cache. It is recommended
707
+ to use a slash tag (e.g. "dummy_name/dummy_name") to ensure uniqueness and facilitate
708
+ organization, but other formats are also allowed as long as they are valid slash tags.
709
+ :param tagger_name: Name of the tagger creating the tag target.
710
+ :param tagger_version: Version of the tagger creating the tag target.
711
+ :param vehicle_name: Name of the vehicle associated with the tag target.
712
+ :param begin_dt: Start datetime for the tag target's validity period.
713
+ :param end_dt: End datetime for the tag target's validity period.
714
+ :param props: Additional properties for the tag target.
715
+ :return: Self instance for chaining along with the added ``TagTargetTable`` instance
716
+ """
717
+ with self.make_session() as session:
718
+ tag_target = TagTarget(
719
+ identifier=identifier,
720
+ tagger_name=tagger_name,
721
+ tagger_version=tagger_version,
722
+ vehicle_name=vehicle_name,
723
+ begin_dt=begin_dt,
724
+ end_dt=end_dt,
725
+ props=props,
726
+ )
727
+ db_tag_target = clone_sequence_model_instance(TagTargetTable, tag_target)
728
+ session.add(db_tag_target)
729
+ session.commit()
730
+
731
+ return chainable(self, db_tag_target)
732
+
733
+ def remove_targets(
734
+ self,
735
+ identifier: str | None = None,
736
+ tagger_name: str | None = None,
737
+ tagger_version: str | None = None,
738
+ vehicle_name: str | None = None,
739
+ begin_dt: datetime.datetime | None = None,
740
+ end_dt: datetime.datetime | None = None,
741
+ *,
742
+ cascade_records: bool = True,
743
+ ):
744
+ """
745
+ Remove tag targets from the cache that match the specified filters.
746
+
747
+ :param identifier: Filter by target identifier (exact match)
748
+ :param tagger_name: Filter by tagger name (exact match)
749
+ :param tagger_version: Filter by tagger version (exact match)
750
+ :param vehicle_name: Filter by vehicle name (exact match)
751
+ :param begin_dt: Filter by begin time (inclusive)
752
+ :param end_dt: Filter by end time (inclusive)
753
+ :param cascade_records: Whether to also remove tag records that are associated with the removed targets.
754
+ If False, the tag records will be kept but become orphaned (i.e. their ``target_sqn``
755
+ will not have a corresponding target in the cache).
756
+ :return: Self for chaining
757
+ """
758
+ with self.make_session() as session:
759
+ query_stmt = session.query(TagTargetTable)
760
+ if identifier:
761
+ query_stmt = query_stmt.filter(TagTargetTable.identifier == identifier)
762
+ if tagger_name:
763
+ query_stmt = query_stmt.filter(TagTargetTable.tagger_name == tagger_name)
764
+ if tagger_version:
765
+ query_stmt = query_stmt.filter(TagTargetTable.tagger_version == tagger_version)
766
+ if vehicle_name:
767
+ query_stmt = query_stmt.filter(TagTargetTable.vehicle_name == vehicle_name)
768
+ if begin_dt:
769
+ query_stmt = query_stmt.filter(TagTargetTable.end_dt >= begin_dt)
770
+ if end_dt:
771
+ query_stmt = query_stmt.filter(TagTargetTable.begin_dt <= end_dt)
772
+
773
+ query_stmt.delete()
774
+ session.commit()
775
+
776
+ if cascade_records:
777
+ (
778
+ session
779
+ .query(TagRecordTable)
780
+ .filter(TagRecordTable.target_sqn.notin_(session.query(TagTargetTable.sqn)))
781
+ .delete()
782
+ )
783
+ session.commit()
784
+
785
+ return self
786
+
787
+ def with_target(self, target: int | str) -> "TargetedTagCache":
788
+ db_tag_target = self.get_target(target)
789
+ if db_tag_target is None:
790
+ raise ValueError(f"tag target '{target}' not found in cache")
791
+ return TargetedTagCache(cache=self, tag_target=db_tag_target)
792
+
793
+ def count_records(self) -> int:
794
+ with self.make_session() as session:
795
+ return session.query(sa.func.count(TagRecordTable.sqn)).scalar()
796
+
797
+ def iter_records(
798
+ self,
799
+ begin_dt: datetime.datetime | None = None,
800
+ end_dt: datetime.datetime | None = None,
801
+ tagset_namespace: str | None = None,
802
+ tagset_version: str | None = None,
803
+ tag_prefix: str | None = None,
804
+ *,
805
+ tagsets: Sequence[Tagset] | None = None,
806
+ tagset_inverted: bool = False,
807
+ skip: int | None = None,
808
+ limit: int | None = None,
809
+ batch_size: int = 1000,
810
+ ) -> Generator[TagRecordTable, None, None]:
811
+ """
812
+ Query tag records in the cache with optional filters.
813
+
814
+ :param begin_dt: Filter by begin time (inclusive)
815
+ :param end_dt: Filter by end time (inclusive)
816
+ :param tagset_namespace: Filter by tagset namespace (exact match)
817
+ :param tagset_version: Filter by tagset version (exact match)
818
+ :param tag_prefix: Filter by tag name prefix, e.g. "dummy_tag:" to match all tags starting with "dummy_tag:"
819
+ :param tagsets: Filter by tagsets (match tags that are in any of the specified tagsets)
820
+ :param tagset_inverted: Whether to invert the tagset filter (match tags that are NOT in any of the specified
821
+ tagsets)
822
+ :param skip: Number of records to skip (for pagination)
823
+ :param limit: Maximum number of records to return (for pagination)
824
+ :param batch_size: Number of records to fetch per batch from the database (for memory efficiency)
825
+ :return: Generator of ``TagRecordTable`` instances that match the filters
826
+ """
827
+ with self.make_session() as session:
828
+ query_stmt = session.query(TagRecordTable)
829
+ if begin_dt:
830
+ query_stmt = query_stmt.filter(TagRecordTable.end_dt >= begin_dt)
831
+ if end_dt:
832
+ query_stmt = query_stmt.filter(TagRecordTable.begin_dt <= end_dt)
833
+ if tagset_namespace:
834
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_namespace == tagset_namespace)
835
+ if tagset_version:
836
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_version == tagset_version)
837
+ if tag_prefix:
838
+ query_stmt = query_stmt.filter(TagRecordTable.tag.like(f"{escape_sql_like(tag_prefix)}%", escape="\\"))
839
+ if tagsets:
840
+ if tagset_inverted:
841
+ query_stmt = query_stmt.filter(
842
+ TagRecordTable.tag.notin_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
843
+ else:
844
+ query_stmt = query_stmt.filter(
845
+ TagRecordTable.tag.in_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
846
+ if skip is not None or limit is not None:
847
+ query_stmt = query_stmt.order_by(TagRecordTable.sqn.desc())
848
+ if skip is not None:
849
+ query_stmt = query_stmt.offset(skip)
850
+ if limit is not None:
851
+ query_stmt = query_stmt.limit(limit)
852
+
853
+ for result in query_stmt.yield_per(batch_size):
854
+ yield result
855
+
856
+ def iter_record_and_targets(
857
+ self,
858
+ begin_dt: datetime.datetime | None = None,
859
+ end_dt: datetime.datetime | None = None,
860
+ tagset_namespace: str | None = None,
861
+ tagset_version: str | None = None,
862
+ tag_prefix: str | None = None,
863
+ *,
864
+ target_identifier: str | None = None,
865
+ target_tagger_name: str | None = None,
866
+ target_tagger_version: str | None = None,
867
+ target_vehicle_name: str | None = None,
868
+ target_begin_dt: datetime.datetime | None = None,
869
+ target_end_dt: datetime.datetime | None = None,
870
+ tagsets: Sequence[Tagset] | None = None,
871
+ tagset_inverted: bool = False,
872
+ skip: int | None = None,
873
+ limit: int | None = None,
874
+ batch_size: int = 1000,
875
+ ) -> Generator[tuple[TagRecordTable, TagTargetTable], None, None]:
876
+ """
877
+ Query tag records along with their target in the cache with optional filters.
878
+
879
+ :param begin_dt: Filter by begin time (inclusive)
880
+ :param end_dt: Filter by end time (inclusive)
881
+ :param tagset_namespace: Filter by tagset namespace (exact match)
882
+ :param tagset_version: Filter by tagset version (exact match)
883
+ :param tag_prefix: Filter by tag name prefix, e.g. "dummy_tag:" to match all tags starting with "dummy_tag:"
884
+ :param target_identifier: Filter by target identifier (exact match)
885
+ :param target_tagger_name: Filter by target tagger name (exact match)
886
+ :param target_tagger_version: Filter by target tagger version (exact match)
887
+ :param target_vehicle_name: Filter by target vehicle name (exact match)
888
+ :param target_begin_dt: Filter by target begin time (inclusive)
889
+ :param target_end_dt: Filter by target end time (inclusive)
890
+ :param tagsets: Filter by tagsets (match tags that are in any of the specified tagsets)
891
+ :param tagset_inverted: Whether to invert the tagset filter (match tags that are NOT in any of the specified
892
+ tagsets)
893
+ :param skip: Number of records to skip (for pagination)
894
+ :param limit: Maximum number of records to return (for pagination)
895
+ :param batch_size: Number of records to fetch per batch from the database (for memory efficiency)
896
+ :return: Generator of ``TagRecordTable`` instances that match the filters
897
+ """
898
+ with self.make_session() as session:
899
+ query_stmt = (
900
+ session
901
+ .query(TagRecordTable, TagTargetTable)
902
+ .join(TagTargetTable, TagRecordTable.target_sqn == TagTargetTable.sqn)
903
+ )
904
+ if begin_dt:
905
+ query_stmt = query_stmt.filter(TagRecordTable.end_dt >= begin_dt)
906
+ if end_dt:
907
+ query_stmt = query_stmt.filter(TagRecordTable.begin_dt <= end_dt)
908
+ if tagset_namespace:
909
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_namespace == tagset_namespace)
910
+ if tagset_version:
911
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_version == tagset_version)
912
+ if tag_prefix:
913
+ query_stmt = query_stmt.filter(TagRecordTable.tag.like(f"{escape_sql_like(tag_prefix)}%", escape="\\"))
914
+ if target_identifier:
915
+ query_stmt = query_stmt.filter(TagTargetTable.identifier == target_identifier)
916
+ if target_tagger_name:
917
+ query_stmt = query_stmt.filter(TagTargetTable.tagger_name == target_tagger_name)
918
+ if target_tagger_version:
919
+ query_stmt = query_stmt.filter(TagTargetTable.tagger_version == target_tagger_version)
920
+ if target_vehicle_name:
921
+ query_stmt = query_stmt.filter(TagTargetTable.vehicle_name == target_vehicle_name)
922
+ if target_begin_dt:
923
+ query_stmt = query_stmt.filter(TagTargetTable.end_dt >= target_begin_dt)
924
+ if target_end_dt:
925
+ query_stmt = query_stmt.filter(TagTargetTable.begin_dt <= target_end_dt)
926
+ if tagsets:
927
+ if tagset_inverted:
928
+ query_stmt = query_stmt.filter(
929
+ TagRecordTable.tag.notin_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
930
+ else:
931
+ query_stmt = query_stmt.filter(
932
+ TagRecordTable.tag.in_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
933
+ if skip is not None or limit is not None:
934
+ query_stmt = query_stmt.order_by(TagRecordTable.sqn.desc())
935
+ if skip is not None:
936
+ query_stmt = query_stmt.offset(skip)
937
+ if limit is not None:
938
+ query_stmt = query_stmt.limit(limit)
939
+
940
+ for result in query_stmt.yield_per(batch_size):
941
+ yield result
942
+
943
+ def remove_records(
944
+ self,
945
+ begin_dt: datetime.datetime | None = None,
946
+ end_dt: datetime.datetime | None = None,
947
+ tagset_namespace: str | None = None,
948
+ tagset_version: str | None = None,
949
+ tag_prefix: str | None = None,
950
+ *,
951
+ tagsets: Sequence[Tagset] | None = None,
952
+ tagset_inverted: bool = False,
953
+ ) -> Self:
954
+ """
955
+ Remove tag records from the cache that match the specified filters.
956
+
957
+ :param begin_dt: Filter by begin time (inclusive)
958
+ :param end_dt: Filter by end time (inclusive)
959
+ :param tagset_namespace: Filter by tagset namespace (exact match)
960
+ :param tagset_version: Filter by tagset version (exact match)
961
+ :param tag_prefix: Filter by tag name prefix, e.g. "dummy_tag:" to match all tags starting with "dummy_tag:"
962
+ :param tagsets: Filter by tagsets (match tags that are in any of the specified tagsets)
963
+ :param tagset_inverted: Whether to invert the tagset filter (match tags that are NOT in any of the specified
964
+ tagsets)
965
+ :return: Self for chaining
966
+ """
967
+ with self.make_session() as session:
968
+ query_stmt = session.query(TagRecordTable)
969
+ if begin_dt:
970
+ query_stmt = query_stmt.filter(TagRecordTable.end_dt >= begin_dt)
971
+ if end_dt:
972
+ query_stmt = query_stmt.filter(TagRecordTable.begin_dt <= end_dt)
973
+ if tagset_namespace:
974
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_namespace == tagset_namespace)
975
+ if tagset_version:
976
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_version == tagset_version)
977
+ if tag_prefix:
978
+ query_stmt = query_stmt.filter(TagRecordTable.tag.like(f"{escape_sql_like(tag_prefix)}%", escape="\\"))
979
+ if tagsets:
980
+ if tagset_inverted:
981
+ query_stmt = query_stmt.filter(
982
+ TagRecordTable.tag.notin_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
983
+ else:
984
+ query_stmt = query_stmt.filter(
985
+ TagRecordTable.tag.in_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
986
+
987
+ query_stmt.delete()
988
+ session.commit()
989
+
990
+ return self
991
+
992
+ def target_count_records(self, target_sqn: int) -> int:
993
+ with self.make_session() as session:
994
+ return (
995
+ session
996
+ .query(sa.func.count(TagRecordTable.sqn))
997
+ .filter(TagRecordTable.target_sqn == target_sqn)
998
+ .scalar()
999
+ )
1000
+
1001
+ def target_iter_records(
1002
+ self,
1003
+ target_sqn: int,
1004
+ begin_dt: datetime.datetime | None = None,
1005
+ end_dt: datetime.datetime | None = None,
1006
+ tagset_namespace: str | None = None,
1007
+ tagset_version: str | None = None,
1008
+ tag_prefix: str | None = None,
1009
+ *,
1010
+ tagsets: Sequence[Tagset] | None = None,
1011
+ tagset_inverted: bool = False,
1012
+ skip: int | None = None,
1013
+ limit: int | None = None,
1014
+ batch_size: int = 1000,
1015
+ ) -> Generator[TagRecordTable, None, None]:
1016
+ """
1017
+ Query tag records in the cache with optional filters.
1018
+
1019
+ :param target_sqn: Filter by target sequence number (exact match)
1020
+ :param begin_dt: Filter by begin time (inclusive)
1021
+ :param end_dt: Filter by end time (inclusive)
1022
+ :param tagset_namespace: Filter by tagset namespace (exact match)
1023
+ :param tagset_version: Filter by tagset version (exact match)
1024
+ :param tag_prefix: Filter by tag name prefix, e.g. "dummy_tag:" to match all tags starting with "dummy_tag:"
1025
+ :param tagsets: Filter by tagsets (match tags that are in any of the specified tagsets)
1026
+ :param tagset_inverted: Whether to invert the tagset filter (match tags that are NOT in any of the specified
1027
+ tagsets)
1028
+ :param skip: Number of records to skip (for pagination)
1029
+ :param limit: Maximum number of records to return (for pagination)
1030
+ :param batch_size: Number of records to fetch per batch from the database (for memory efficiency)
1031
+ :return: Generator of ``TagRecordTable`` instances that match the filters
1032
+ """
1033
+
1034
+ with self.make_session() as session:
1035
+ query_stmt = session.query(TagRecordTable).filter(TagRecordTable.target_sqn == target_sqn)
1036
+ if begin_dt:
1037
+ query_stmt = query_stmt.filter(TagRecordTable.end_dt >= begin_dt)
1038
+ if end_dt:
1039
+ query_stmt = query_stmt.filter(TagRecordTable.begin_dt <= end_dt)
1040
+ if tagset_namespace:
1041
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_namespace == tagset_namespace)
1042
+ if tagset_version:
1043
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_version == tagset_version)
1044
+ if tag_prefix:
1045
+ query_stmt = query_stmt.filter(TagRecordTable.tag.like(f"{escape_sql_like(tag_prefix)}%", escape="\\"))
1046
+ if tagsets:
1047
+ if tagset_inverted:
1048
+ query_stmt = query_stmt.filter(
1049
+ TagRecordTable.tag.notin_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
1050
+ else:
1051
+ query_stmt = query_stmt.filter(
1052
+ TagRecordTable.tag.in_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
1053
+ if skip is not None or limit is not None:
1054
+ query_stmt = query_stmt.order_by(TagRecordTable.sqn.desc())
1055
+ if skip is not None:
1056
+ query_stmt = query_stmt.offset(skip)
1057
+ if limit is not None:
1058
+ query_stmt = query_stmt.limit(limit)
1059
+
1060
+ for result in query_stmt.yield_per(batch_size):
1061
+ yield result
1062
+
1063
+ def target_add_record(
1064
+ self,
1065
+ target_sqn: int,
1066
+ begin_dt: datetime.datetime,
1067
+ end_dt: datetime.datetime,
1068
+ tag: str | Tag | BoundTag,
1069
+ features: JsonType | None = None,
1070
+ props: JsonType | None = None,
1071
+ tagset_namespace: str | None = None,
1072
+ tagset_version: str | None = None,
1073
+ ) -> Chainable[Self, TagRecordTable]:
1074
+ """
1075
+ Add a tag record to the cache for the specified time range.
1076
+
1077
+ :param target_sqn: Sequence number of the tag record's target
1078
+ :param begin_dt: Begin datetime of the tag record
1079
+ :param end_dt: End datetime of the tag record
1080
+ :param tag: Tag name or ``Tag``/``BoundTag`` instance to be added. If ``Tag``/``BoundTag`` instance is provided,
1081
+ its name will be used.
1082
+ :param features: Additional tag features of the tag record in JSON format (optional), which can be used for
1083
+ storing preprocessed information of the tag record to facilitate querying and filtering.
1084
+ :param props: Additional properties of the tag record in JSON format (optional)
1085
+ :param tagset_namespace: Namespace of the tagset that the tag belongs to. If the ``tag`` parameter is a
1086
+ ``BoundTag`` instance, this parameter will be ignored and the namespace from the
1087
+ instance will be used.
1088
+ :param tagset_version: Version of the tagset that the tag belongs to. If the ``tag`` parameter is a ``BoundTag``
1089
+ instance, this parameter will be ignored and the version from the instance will be used.
1090
+ :return: Self instance for chaining along with the added ``TagRecordTable`` instance
1091
+ """
1092
+ with self.make_session() as session:
1093
+ tag_record = TagRecord(
1094
+ target_sqn=target_sqn,
1095
+ begin_dt=begin_dt,
1096
+ end_dt=end_dt,
1097
+ tagset_namespace=tag.namespace if isinstance(tag, BoundTag) else tagset_namespace,
1098
+ tagset_version=tag.version if isinstance(tag, BoundTag) else tagset_version,
1099
+ tag=tag.name if isinstance(tag, Tag) else tag,
1100
+ features=features,
1101
+ props=props,
1102
+ )
1103
+ db_tag_record = clone_sequence_model_instance(TagRecordTable, tag_record)
1104
+ session.add(db_tag_record)
1105
+ session.commit()
1106
+
1107
+ return chainable(self, db_tag_record)
1108
+
1109
+ def target_update_record(
1110
+ self,
1111
+ target_sqn: int,
1112
+ sqn: int,
1113
+ *,
1114
+ begin_dt: datetime.datetime | None = None,
1115
+ end_dt: datetime.datetime | None = None,
1116
+ tag: str | Tag | BoundTag | None = None,
1117
+ features: JsonType | None = None,
1118
+ props: JsonType | None = None,
1119
+ tagset_namespace: str | None = None,
1120
+ tagset_version: str | None = None,
1121
+ flags: int | None = None,
1122
+ ) -> Chainable[Self, TagRecordTable]:
1123
+ """
1124
+ Update a tag record in the cache by its sequence number.
1125
+
1126
+ :param target_sqn: Sequence number of the tag record's target
1127
+ :param sqn: Sequence number of the tag record to be updated
1128
+ :param begin_dt: New begin datetime of the tag record (optional)
1129
+ :param end_dt: New end datetime of the tag record (optional)
1130
+ :param tag: New tag name or ``Tag``/``BoundTag`` instance to be updated (optional). If ``Tag``/``BoundTag``
1131
+ instance is provided, its name will be used.
1132
+ :param features: Additional tag features of the tag record in JSON format (optional), which can be used for
1133
+ storing preprocessed information of the tag record to facilitate querying and filtering.
1134
+ :param props: New additional properties of the tag record in JSON format (optional)
1135
+ :param tagset_namespace: New namespace of the tagset that the tag belongs to (optional). If the ``tag``
1136
+ parameter is a ``BoundTag`` instance, this parameter will be ignored and the namespace
1137
+ from the instance will be used.
1138
+ :param tagset_version: New version of the tagset that the tag belongs to (optional). If the ``tag`` parameter
1139
+ is a ``BoundTag`` instance, this parameter will be ignored and the version from the
1140
+ instance will be used.
1141
+ :param flags: New integer bitmask storing status or metadata flags for this tag record (optional)
1142
+ :return: Self instance for chaining along with the updated ``TagRecordTable`` instance
1143
+ """
1144
+ with self.make_session() as session:
1145
+ db_tag_record = (
1146
+ session
1147
+ .query(TagRecordTable)
1148
+ .filter(TagRecordTable.target_sqn == target_sqn, TagRecordTable.sqn == sqn)
1149
+ .one_or_none()
1150
+ )
1151
+ if not db_tag_record:
1152
+ raise ValueError(f"tag record with sqn '{sqn}' not found in cache")
1153
+
1154
+ if begin_dt is not None:
1155
+ db_tag_record.begin_dt = begin_dt
1156
+ if end_dt is not None:
1157
+ db_tag_record.end_dt = end_dt
1158
+ if tagset_namespace is not None:
1159
+ db_tag_record.tagset_namespace = tag.namespace if isinstance(tag, BoundTag) else tagset_namespace
1160
+ if tagset_version is not None:
1161
+ db_tag_record.tagset_version = tag.version if isinstance(tag, BoundTag) else tagset_version
1162
+ if tag is not None:
1163
+ db_tag_record.tag = tag.name if isinstance(tag, Tag) else tag
1164
+ if features is not None:
1165
+ db_tag_record.features = features
1166
+ if props is not None:
1167
+ db_tag_record.props = props
1168
+ if flags is not None:
1169
+ db_tag_record.flags = flags
1170
+
1171
+ session.commit()
1172
+
1173
+ return chainable(self, db_tag_record)
1174
+
1175
+ def target_remove_records(
1176
+ self,
1177
+ target_sqn: int,
1178
+ begin_dt: datetime.datetime | None = None,
1179
+ end_dt: datetime.datetime | None = None,
1180
+ tagset_namespace: str | None = None,
1181
+ tagset_version: str | None = None,
1182
+ tag_prefix: str | None = None,
1183
+ *,
1184
+ tagsets: Sequence[Tagset] | None = None,
1185
+ tagset_inverted: bool = False,
1186
+ ) -> Self:
1187
+ """
1188
+ Remove tag records from the cache that match the specified filters.
1189
+
1190
+ :param target_sqn: Filter by target sequence number (exact match)
1191
+ :param begin_dt: Filter by begin time (inclusive)
1192
+ :param end_dt: Filter by end time (inclusive)
1193
+ :param tagset_namespace: Filter by tagset namespace (exact match)
1194
+ :param tagset_version: Filter by tagset version (exact match)
1195
+ :param tag_prefix: Filter by tag name prefix, e.g. "dummy_tag:" to match all tags starting with "dummy_tag:"
1196
+ :param tagsets: Filter by tagsets (match tags that are in any of the specified tagsets)
1197
+ :param tagset_inverted: Whether to invert the tagset filter (match tags that are NOT in any of the specified
1198
+ tagsets)
1199
+ :return: Self instance for chaining
1200
+ """
1201
+ with self.make_session() as session:
1202
+ query_stmt = session.query(TagRecordTable).filter(TagRecordTable.target_sqn == target_sqn)
1203
+ if begin_dt:
1204
+ query_stmt = query_stmt.filter(TagRecordTable.end_dt >= begin_dt)
1205
+ if end_dt:
1206
+ query_stmt = query_stmt.filter(TagRecordTable.begin_dt <= end_dt)
1207
+ if tagset_namespace:
1208
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_namespace == tagset_namespace)
1209
+ if tagset_version:
1210
+ query_stmt = query_stmt.filter(TagRecordTable.tagset_version == tagset_version)
1211
+ if tag_prefix:
1212
+ query_stmt = query_stmt.filter(TagRecordTable.tag.like(f"{escape_sql_like(tag_prefix)}%", escape="\\"))
1213
+ if tagsets:
1214
+ if tagset_inverted:
1215
+ query_stmt = query_stmt.filter(
1216
+ TagRecordTable.tag.notin_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
1217
+ else:
1218
+ query_stmt = query_stmt.filter(
1219
+ TagRecordTable.tag.in_([tag_name for tagset in tagsets for tag_name in tagset.tag_names]))
1220
+
1221
+ query_stmt.delete()
1222
+ session.commit()
1223
+
1224
+ return self
1225
+
1226
+ def clear(self):
1227
+ with self.make_session() as session:
1228
+ session.execute(sa.delete(TagRecordTable))
1229
+ session.execute(sa.delete(TagTargetTable))
1230
+ session.commit()
1231
+
1232
+ def append_to(self, target_file_path: str, *, overwrite: bool = False):
1233
+ target_tag_cache = TagCache(file_path=target_file_path)
1234
+ if overwrite:
1235
+ target_tag_cache.clear()
1236
+ TagCache.copy_to(self, target_tag_cache)
1237
+
1238
+ def merge_from(self, source_file_path: str, *, overwrite: bool = False):
1239
+ source_tag_cache = TagCache(file_path=source_file_path)
1240
+ if overwrite:
1241
+ self.clear()
1242
+ TagCache.copy_to(source_tag_cache, self)
1243
+
1244
+ @staticmethod
1245
+ def copy_to(src: "TagCache", dst: "TagCache"):
1246
+ # If src and dst are the same instance or point to the same file path,
1247
+ # do nothing to avoid accidentally clearing the cache
1248
+ if src == dst or src.file_path == dst.file_path:
1249
+ return
1250
+
1251
+ with src.make_session() as src_session, dst.make_session() as dst_session:
1252
+ src_db_tag_targets = src_session.query(TagTargetTable).all()
1253
+ dst_db_tag_targets = [
1254
+ clone_sequence_model_instance(TagTargetTable, db_tag_target, clear_meta_fields=True)
1255
+ for db_tag_target in src_db_tag_targets
1256
+ ]
1257
+ dst_session.add_all(dst_db_tag_targets)
1258
+ dst_session.flush() # ensure new sqn values are assigned
1259
+
1260
+ sqn_map = {src_db_tag_target.sqn: dst_db_tag_target.sqn
1261
+ for src_db_tag_target, dst_db_tag_target in zip(src_db_tag_targets, dst_db_tag_targets)}
1262
+
1263
+ for db_tag_records in batched(src_session.query(TagRecordTable).yield_per(1000), 1000):
1264
+ cloned_db_tag_records = []
1265
+ for db_tag_record in db_tag_records:
1266
+ cloned_db_tag_record = clone_sequence_model_instance(TagRecordTable,
1267
+ db_tag_record,
1268
+ clear_meta_fields=True)
1269
+ try:
1270
+ cloned_db_tag_record.target_sqn = sqn_map[db_tag_record.target_sqn]
1271
+ except KeyError as e:
1272
+ raise ValueError(f"no cloned tag target for target_sqn '{db_tag_record.target_sqn}'") from e
1273
+ cloned_db_tag_records.append(cloned_db_tag_record)
1274
+ dst_session.add_all(cloned_db_tag_records)
1275
+ dst_session.commit()
1276
+
1277
+
1278
+ class TargetedTagCache(object):
1279
+ def __init__(self, cache: TagCache, tag_target: TagTargetTable):
1280
+ self.cache = cache
1281
+ self.tag_target = tag_target
1282
+
1283
+ def count_records(self) -> int:
1284
+ return self.cache.target_count_records(self.tag_target.sqn)
1285
+
1286
+ def iter_records(
1287
+ self,
1288
+ begin_dt: datetime.datetime | None = None,
1289
+ end_dt: datetime.datetime | None = None,
1290
+ tagset_namespace: str | None = None,
1291
+ tagset_version: str | None = None,
1292
+ tag_prefix: str | None = None,
1293
+ *,
1294
+ tagsets: Sequence[Tagset] | None = None,
1295
+ tagset_inverted: bool = False,
1296
+ skip: int | None = None,
1297
+ limit: int | None = None,
1298
+ batch_size: int = 1000,
1299
+ ) -> Generator[TagRecordTable, None, None]:
1300
+ return self.cache.target_iter_records(
1301
+ target_sqn=self.tag_target.sqn,
1302
+ begin_dt=begin_dt,
1303
+ end_dt=end_dt,
1304
+ tagset_namespace=tagset_namespace,
1305
+ tagset_version=tagset_version,
1306
+ tag_prefix=tag_prefix,
1307
+ tagsets=tagsets,
1308
+ tagset_inverted=tagset_inverted,
1309
+ skip=skip,
1310
+ limit=limit,
1311
+ batch_size=batch_size,
1312
+ )
1313
+
1314
+ def add_ranged_record(
1315
+ self,
1316
+ begin_dt: datetime.datetime | None,
1317
+ end_dt: datetime.datetime | None,
1318
+ tag: str | Tag | BoundTag,
1319
+ features: JsonType | None = None,
1320
+ props: JsonType | None = None,
1321
+ tagset_namespace: str | None = None,
1322
+ tagset_version: str | None = None,
1323
+ ) -> Chainable[Self, TagRecordTable]:
1324
+ result = self.cache.target_add_record(
1325
+ target_sqn=self.tag_target.sqn,
1326
+ begin_dt=begin_dt or self.tag_target.begin_dt,
1327
+ end_dt=end_dt or self.tag_target.end_dt,
1328
+ tag=tag,
1329
+ features=features,
1330
+ props=props,
1331
+ tagset_namespace=tagset_namespace,
1332
+ tagset_version=tagset_version,
1333
+ )
1334
+ return result.retarget(self)
1335
+
1336
+ def add_record(
1337
+ self,
1338
+ tag: str | Tag | BoundTag,
1339
+ features: JsonType | None = None,
1340
+ props: JsonType | None = None,
1341
+ tagset_namespace: str | None = None,
1342
+ tagset_version: str | None = None,
1343
+ ) -> Chainable[Self, TagRecordTable]:
1344
+ result = self.add_ranged_record(
1345
+ begin_dt=self.tag_target.begin_dt,
1346
+ end_dt=self.tag_target.end_dt,
1347
+ tag=tag,
1348
+ features=features,
1349
+ tagset_namespace=tagset_namespace,
1350
+ tagset_version=tagset_version,
1351
+ props=props,
1352
+ )
1353
+ return result.retarget(self)
1354
+
1355
+ def update_record(
1356
+ self,
1357
+ sqn: int,
1358
+ *,
1359
+ begin_dt: datetime.datetime | None = None,
1360
+ end_dt: datetime.datetime | None = None,
1361
+ tag: str | Tag | BoundTag | None = None,
1362
+ features: JsonType | None = None,
1363
+ props: JsonType | None = None,
1364
+ tagset_namespace: str | None = None,
1365
+ tagset_version: str | None = None,
1366
+ flags: int | None = None,
1367
+ ) -> Chainable[Self, TagRecordTable]:
1368
+ result = self.cache.target_update_record(
1369
+ self.tag_target.sqn,
1370
+ sqn,
1371
+ begin_dt=begin_dt,
1372
+ end_dt=end_dt,
1373
+ tag=tag,
1374
+ features=features,
1375
+ props=props,
1376
+ tagset_namespace=tagset_namespace,
1377
+ tagset_version=tagset_version,
1378
+ flags=flags,
1379
+ )
1380
+ return result.retarget(self)
1381
+
1382
+ def remove_records(
1383
+ self,
1384
+ begin_dt: datetime.datetime | None = None,
1385
+ end_dt: datetime.datetime | None = None,
1386
+ tagset_namespace: str | None = None,
1387
+ tagset_version: str | None = None,
1388
+ tag_prefix: str | None = None,
1389
+ *,
1390
+ tagsets: Sequence[Tagset] | None = None,
1391
+ tagset_inverted: bool = False,
1392
+ ) -> Self:
1393
+ self.cache.target_remove_records(
1394
+ self.tag_target.sqn,
1395
+ begin_dt=begin_dt,
1396
+ end_dt=end_dt,
1397
+ tagset_namespace=tagset_namespace,
1398
+ tagset_version=tagset_version,
1399
+ tag_prefix=tag_prefix,
1400
+ tagsets=tagsets,
1401
+ tagset_inverted=tagset_inverted,
1402
+ )
1403
+ return self
1404
+
1405
+
1406
+ @memorized
1407
+ def tag_cache(*, identifier: str | None = None, file_path: str | os.PathLike[str] | None = None) -> TagCache:
1408
+ """
1409
+ Get a ``TagCache`` instance associated with the given identifier. If the identifier is ``None``, return a
1410
+ ``TagCache`` instance associated with a default file path. Otherwise, validate the identifier as a snake case
1411
+ string and return a ``TagCache`` instance associated with a file path derived from the identifier.
1412
+
1413
+ :param identifier: An optional string identifier for the tag cache. If provided, it must be in snake case format
1414
+ and will be used to derive the file path for the tag cache. If not provided, a default file path
1415
+ will be used.
1416
+ :param file_path: An optional file path for the tag cache. If provided, it will be used directly. If not provided,
1417
+ the file path will be derived from the identifier if it is provided, or a default file path will
1418
+ be used if the identifier is not provided. Note that both ``identifier`` and ``file_path`` cannot
1419
+ be specified at the same time.
1420
+ :return: A ``TagCache`` instance associated with the specified or default file path.
1421
+ """
1422
+ if identifier is not None and file_path is not None:
1423
+ raise ValueError("cannot specify both 'identifier' and 'file_path'")
1424
+ if identifier is not None:
1425
+ validate_snake_case(identifier)
1426
+ return TagCache(file_path=tag_cache_file_path().parent / f"{identifier}.db")
1427
+ if file_path is not None:
1428
+ return TagCache(file_path=file_path)
1429
+ return TagCache(file_path=tag_cache_file_path())
1430
+
1431
+
1432
+ @singleton
1433
+ def standard_clip_duration_us() -> int:
1434
+ """Duration of clips to split samples into, in microseconds, which is fixed to 20 seconds for now."""
1435
+ return 20 * 1_000_000 # 20 seconds
1436
+
1437
+
1438
+ def populate_clip_ranges(
1439
+ data_begin_dt: datetime.datetime,
1440
+ data_end_dt: datetime.datetime,
1441
+ *,
1442
+ clip_duration_us: int = None
1443
+ ) -> Generator[tuple[datetime.datetime, datetime.datetime, datetime.datetime, datetime.datetime]]:
1444
+ """
1445
+ Generate clip begin datetime, clip data begin datetime, and clip data end datetime for each clip slot that overlaps
1446
+ with the given data begin and end datetimes.
1447
+ The clip begin datetime is the beginning of the clip slot, the clip data begin datetime is the maximum of the clip
1448
+ begin datetime and the data begin datetime, and the clip data end datetime is the minimum of the clip end datetime
1449
+ and the data end datetime.
1450
+
1451
+ :param data_begin_dt: The beginning datetime of the data.
1452
+ :param data_end_dt: The end datetime of the data.
1453
+ :param clip_duration_us: The duration of each clip in microseconds. If not provided,
1454
+ the standard clip duration will be used.
1455
+ :return: A generator of tuples containing the clip begin datetime, clip data begin datetime,
1456
+ and clip data end datetime
1457
+ """
1458
+ clip_duration_us = clip_duration_us or standard_clip_duration_us()
1459
+
1460
+ if data_begin_dt == data_end_dt:
1461
+ clip_slot = math.floor(dt_to_ts_us(data_begin_dt) / clip_duration_us)
1462
+ clip_begin_dt = dt_from_ts_us(clip_slot * clip_duration_us)
1463
+ clip_end_dt = dt_from_ts_us((clip_slot + 1) * clip_duration_us)
1464
+ yield clip_begin_dt, clip_end_dt, data_begin_dt, data_end_dt
1465
+ return
1466
+
1467
+ begin_clip_slot = math.floor(dt_to_ts_us(data_begin_dt) / clip_duration_us)
1468
+ end_clip_slot = math.ceil(dt_to_ts_us(data_end_dt) / clip_duration_us)
1469
+
1470
+ for clip_slot in range(begin_clip_slot, end_clip_slot):
1471
+ clip_begin_dt = dt_from_ts_us(clip_slot * clip_duration_us)
1472
+ clip_end_dt = dt_from_ts_us((clip_slot + 1) * clip_duration_us)
1473
+ clip_data_begin_dt = max(clip_begin_dt, data_begin_dt)
1474
+ clip_data_end_dt = min(clip_end_dt, data_end_dt)
1475
+
1476
+ yield clip_begin_dt, clip_end_dt, clip_data_begin_dt, clip_data_end_dt