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.
- plexus/pulse/common/__init__.py +6 -0
- plexus/pulse/common/carto/OSMFile.py +259 -0
- plexus/pulse/common/carto/OSMNode.py +25 -0
- plexus/pulse/common/carto/OSMTags.py +101 -0
- plexus/pulse/common/carto/OSMWay.py +24 -0
- plexus/pulse/common/carto/__init__.py +11 -0
- plexus/pulse/common/resources/__init__.py +0 -0
- plexus/pulse/common/resources/tags/__init__.py +35 -0
- plexus/pulse/common/resources/tags/unittest-1.0.0.tagset.yaml +98 -0
- plexus/pulse/common/resources/tags/universal-1.0.0.tagset.yaml +1390 -0
- plexus/pulse/common/utils/__init__.py +0 -0
- plexus/pulse/common/utils/apiutils.py +31 -0
- plexus/pulse/common/utils/bagutils.py +331 -0
- plexus/pulse/common/utils/config.py +63 -0
- plexus/pulse/common/utils/datautils.py +233 -0
- plexus/pulse/common/utils/dockerutils.py +181 -0
- plexus/pulse/common/utils/gisutils.py +406 -0
- plexus/pulse/common/utils/jsonutils.py +96 -0
- plexus/pulse/common/utils/ormutils.py +1638 -0
- plexus/pulse/common/utils/pathutils.py +234 -0
- plexus/pulse/common/utils/s3utils.py +939 -0
- plexus/pulse/common/utils/sqlutils.py +9 -0
- plexus/pulse/common/utils/strutils.py +401 -0
- plexus/pulse/common/utils/tagutils.py +1476 -0
- plexus/pulse/common/utils/testutils.py +172 -0
- plexus_python_pulse_common-1.1.75.dist-info/METADATA +42 -0
- plexus_python_pulse_common-1.1.75.dist-info/RECORD +29 -0
- plexus_python_pulse_common-1.1.75.dist-info/WHEEL +5 -0
- plexus_python_pulse_common-1.1.75.dist-info/top_level.txt +1 -0
|
@@ -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
|