plexus-python-common 1.0.43__tar.gz → 1.0.45__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/PKG-INFO +1 -1
  2. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/datautils.py +2 -218
  3. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/ormutils.py +120 -29
  4. plexus_python_common-1.0.45/src/plexus/common/utils/tagutils.py +416 -0
  5. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus_python_common.egg-info/PKG-INFO +1 -1
  6. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus_python_common.egg-info/SOURCES.txt +2 -0
  7. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/utils/datautils_test.py +0 -18
  8. plexus_python_common-1.0.45/test/plexus_tests/common/utils/tagutils_test.py +95 -0
  9. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/.editorconfig +0 -0
  10. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/.github/workflows/pr.yml +0 -0
  11. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/.github/workflows/push.yml +0 -0
  12. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/.gitignore +0 -0
  13. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/MANIFEST.in +0 -0
  14. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/README.md +0 -0
  15. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/VERSION +0 -0
  16. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/pyproject.toml +0 -0
  17. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/jsonutils/dummy.0.jsonl +0 -0
  18. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/jsonutils/dummy.1.jsonl +0 -0
  19. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/jsonutils/dummy.2.jsonl +0 -0
  20. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.baz/file.bar.baz +0 -0
  21. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.baz/file.foo.bar +0 -0
  22. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.baz/file.foo.baz +0 -0
  23. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.foo/dir.foo.bar/dir.foo.bar.baz/file.foo.bar.baz +0 -0
  24. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.bar.baz +0 -0
  25. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.foo.bar +0 -0
  26. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.foo.baz +0 -0
  27. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.foo/file.bar +0 -0
  28. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.foo/file.baz +0 -0
  29. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils/dir.foo/file.foo +0 -0
  30. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils_archive/archive.compressed.zip +0 -0
  31. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/s3utils_archive/archive.uncompressed.zip +0 -0
  32. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/0-dummy +0 -0
  33. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/1-dummy +0 -0
  34. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/2-dummy +0 -0
  35. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.0.0.jsonl +0 -0
  36. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.0.0.vol-0.jsonl +0 -0
  37. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.0.jsonl +0 -0
  38. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.1.1.jsonl +0 -0
  39. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.1.1.vol-1.jsonl +0 -0
  40. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.1.jsonl +0 -0
  41. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.2.2.jsonl +0 -0
  42. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.2.2.vol-2.jsonl +0 -0
  43. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.2.jsonl +0 -0
  44. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.csv.part0 +0 -0
  45. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.csv.part1 +0 -0
  46. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.csv.part2 +0 -0
  47. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/resources/unittest/shutils/dummy.txt +0 -0
  48. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/setup.cfg +0 -0
  49. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/setup.py +0 -0
  50. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/__init__.py +0 -0
  51. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/carto/OSMFile.py +0 -0
  52. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/carto/OSMNode.py +0 -0
  53. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/carto/OSMTags.py +0 -0
  54. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/carto/OSMWay.py +0 -0
  55. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/carto/__init__.py +0 -0
  56. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/pose.py +0 -0
  57. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/proj.py +0 -0
  58. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/resources/__init__.py +0 -0
  59. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/resources/tags/__init__.py +0 -0
  60. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/resources/tags/universal.tagset.yaml +0 -0
  61. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/__init__.py +0 -0
  62. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/apiutils.py +0 -0
  63. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/bagutils.py +0 -0
  64. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/config.py +0 -0
  65. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/dockerutils.py +0 -0
  66. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/jsonutils.py +0 -0
  67. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/s3utils.py +0 -0
  68. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/shutils.py +0 -0
  69. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/sqlutils.py +0 -0
  70. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/strutils.py +0 -0
  71. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus/common/utils/testutils.py +0 -0
  72. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus_python_common.egg-info/dependency_links.txt +0 -0
  73. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus_python_common.egg-info/not-zip-safe +0 -0
  74. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus_python_common.egg-info/requires.txt +0 -0
  75. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/src/plexus_python_common.egg-info/top_level.txt +0 -0
  76. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_test.py +0 -0
  77. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/__init__.py +0 -0
  78. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/carto/osm_file_test.py +0 -0
  79. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/carto/osm_tags_test.py +0 -0
  80. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/pose_test.py +0 -0
  81. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/proj_test.py +0 -0
  82. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/utils/bagutils_test.py +0 -0
  83. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/utils/dockerutils_test.py +0 -0
  84. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/utils/jsonutils_test.py +0 -0
  85. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/utils/ormutils_test.py +0 -0
  86. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/utils/s3utils_test.py +0 -0
  87. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/utils/shutils_test.py +0 -0
  88. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/utils/strutils_test.py +0 -0
  89. {plexus_python_common-1.0.43 → plexus_python_common-1.0.45}/test/plexus_tests/common/utils/testutils_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python-common
3
- Version: 1.0.43
3
+ Version: 1.0.45
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.12
6
6
  Classifier: Programming Language :: Python :: 3.13
@@ -1,22 +1,13 @@
1
- import dataclasses
2
1
  import datetime
3
- import textwrap
4
- from collections.abc import Callable, Generator, Mapping, Sequence
2
+ from collections.abc import Callable, Generator
5
3
  from typing import Any
6
4
 
7
- import jinja2
8
5
  import pyparsing as pp
9
6
  import ujson as json
10
7
  from iker.common.utils.funcutils import singleton
11
- from iker.common.utils.iterutils import dicttree
12
- from iker.common.utils.iterutils import dicttree_add, dicttree_remove
13
- from iker.common.utils.iterutils import dicttree_children, dicttree_lineage, dicttree_subtree
14
- from iker.common.utils.iterutils import head_or_none
15
- from iker.common.utils.jsonutils import JsonObject, JsonType
8
+ from iker.common.utils.jsonutils import JsonType
16
9
  from iker.common.utils.randutils import randomizer
17
- from iker.common.utils.strutils import is_blank
18
10
 
19
- from plexus.common.resources.tags import predefined_tagset_specs
20
11
  from plexus.common.utils.strutils import BagName, UserName, VehicleName
21
12
  from plexus.common.utils.strutils import colon_tag_parser, slash_tag_parser
22
13
  from plexus.common.utils.strutils import dot_case_parser, kebab_case_parser, snake_case_parser
@@ -53,12 +44,6 @@ __all__ = [
53
44
  "known_user_names",
54
45
  "known_vehicle_names",
55
46
  "random_bag_names_sequence",
56
- "RichDesc",
57
- "Tag",
58
- "Tagset",
59
- "populate_tagset",
60
- "predefined_tagsets",
61
- "render_tagset_markdown_readme",
62
47
  ]
63
48
 
64
49
 
@@ -244,204 +229,3 @@ def random_bag_names_sequence(
244
229
 
245
230
  for record_sn in range(bags_count):
246
231
  yield BagName(vehicle_name=vehicle_name, record_dt=record_dt, record_sn=record_sn)
247
-
248
-
249
- @dataclasses.dataclass(frozen=True, eq=True, order=True)
250
- class RichDesc(object):
251
- type: str
252
- text: str
253
-
254
-
255
- @dataclasses.dataclass(frozen=True, eq=True, order=True)
256
- class Tag(object):
257
- name: str
258
- desc: str | RichDesc | None
259
-
260
- @property
261
- def tag_parts(self) -> list[str]:
262
- return self.name.rsplit(":")
263
-
264
- @property
265
- def parent_tag_name(self) -> str | None:
266
- return head_or_none(self.name.rsplit(":", 1))
267
-
268
-
269
- class Tagset(Sequence[Tag], Mapping[str, Tag]):
270
- def __init__(self, namespace: str, desc: str | RichDesc) -> None:
271
- super().__init__()
272
- self.namespace = namespace
273
- self.desc = desc
274
- self.tags: list[Tag] = []
275
- self.tags_dict: dict[str, Tag] = {}
276
- self.tags_tree: dicttree[str, Tag] = {}
277
-
278
- def __contains__(self, item: str | Tag) -> bool:
279
- tag_name = item.name if isinstance(item, Tag) else item
280
- return tag_name in self.tags_dict
281
-
282
- def __len__(self) -> int:
283
- return len(self.tags)
284
-
285
- def __getitem__(self, index: int) -> Tag:
286
- return self.tags[index]
287
-
288
- def keys(self):
289
- return self.tags_dict.keys()
290
-
291
- def values(self):
292
- return self.tags_dict.values()
293
-
294
- def items(self):
295
- return self.tags_dict.items()
296
-
297
- def get(self, item: str | Tag) -> Tag | None:
298
- tag_name = item.name if isinstance(item, Tag) else item
299
- return self.tags_dict.get(tag_name)
300
-
301
- @property
302
- def tag_names(self) -> list[str]:
303
- return list(self.tags_dict.keys())
304
-
305
- def add(self, tag: Tag) -> None:
306
- if tag.name in self.tags_dict:
307
- raise ValueError(f"duplicate tag name '{tag.name}'")
308
-
309
- self.tags.append(tag)
310
- self.tags_dict[tag.name] = tag
311
-
312
- dicttree_add(self.tags_tree, tag.tag_parts, tag, create_prefix=True)
313
-
314
- def remove(self, tag_name: str) -> None:
315
- tag = self.get(tag_name)
316
- if tag is None:
317
- raise ValueError(f"tag '{tag_name}' not found")
318
-
319
- self.tags.remove(tag)
320
- del self.tags_dict[tag_name]
321
-
322
- dicttree_remove(self.tags_tree, tag.tag_parts, recursive=True)
323
-
324
- def child_tags(self, parent: str | Tag) -> list[Tag]:
325
- if parent is None:
326
- return []
327
- if isinstance(parent, str):
328
- return self.child_tags(self.get(parent))
329
-
330
- subtree = dicttree_subtree(self.tags_tree, parent.tag_parts)
331
- return list(dicttree_children(subtree)) if subtree else []
332
-
333
- def parent_tags(self, child: str | Tag) -> list[Tag]:
334
- if child is None:
335
- return []
336
- if isinstance(child, str):
337
- return self.parent_tags(self.get(child))
338
-
339
- return list(dicttree_lineage(self.tags_tree, child.tag_parts[:-1]))
340
-
341
-
342
- def populate_tagset(tagset_spec: JsonObject) -> Tagset:
343
- """
344
- Collect tags from tagset spec JSON object, validate the format along the way.
345
-
346
- :param tagset_spec: JSON object of tagset spec
347
- :return: Populated Tagset instance
348
- """
349
-
350
- def validate_and_collect(name: str, tag_def: JsonObject) -> Generator[Tag, None, None]:
351
- if not isinstance(tag_def, dict):
352
- raise ValueError(f"tag '{name}' definition is not a dict")
353
-
354
- if not is_blank(name):
355
- desc = tag_def.get("$desc")
356
- if isinstance(desc, dict):
357
- desc = RichDesc(**desc)
358
-
359
- yield Tag(name=name, desc=desc)
360
-
361
- for child_name, child_tag_def in tag_def.items():
362
- if child_name == "$desc":
363
- continue
364
-
365
- if not isinstance(child_name, str):
366
- raise ValueError(f"child '{child_name}' of tag '{name}' is not a string")
367
- try:
368
- validate_snake_case(child_name)
369
- except ValueError as e:
370
- raise ValueError(f"child '{child_name}' of tag '{name}' is not in snake case") from e
371
-
372
- child_name = name + ":" + child_name if name else child_name
373
-
374
- yield from validate_and_collect(child_name, child_tag_def)
375
-
376
- namespace = tagset_spec.get("$namespace")
377
- if namespace is None:
378
- raise ValueError("missing '$namespace' in tagset spec")
379
- try:
380
- validate_snake_case(namespace)
381
- except ValueError as e:
382
- raise ValueError(f"tagset namespace '{namespace}' is not in snake case") from e
383
-
384
- desc = tagset_spec.get("$desc")
385
- if desc is None:
386
- raise ValueError("missing '$desc' in tagset spec")
387
- if isinstance(desc, dict):
388
- desc = RichDesc(**desc)
389
-
390
- tags = tagset_spec.get("$tags")
391
- if tags is None:
392
- raise ValueError("missing '$tags' in tagset spec")
393
-
394
- tagset = Tagset(namespace=namespace, desc=desc)
395
-
396
- for tag in validate_and_collect("", tags):
397
- tagset.add(tag)
398
-
399
- return tagset
400
-
401
-
402
- @singleton
403
- def predefined_tagsets() -> dict[str, Tagset]:
404
- tagsets: dict[str, Tagset] = {}
405
- for _, tagset_spec in predefined_tagset_specs():
406
- tagset = populate_tagset(tagset_spec)
407
- tagsets[tagset.namespace] = tagset
408
- return tagsets
409
-
410
-
411
- def render_tagset_markdown_readme(tagset: Tagset) -> str:
412
- def render_desc(desc: str | RichDesc | None) -> str:
413
- if desc is None:
414
- return ""
415
- if isinstance(desc, str):
416
- return desc
417
- if isinstance(desc, RichDesc):
418
- return desc.text
419
- raise ValueError(f"unsupported desc type '{type(desc)}'")
420
-
421
- template_str = textwrap.dedent(
422
- """
423
- # Tagset {{ tagset.namespace }}
424
-
425
- {{ tagset.desc | render_desc }}
426
-
427
- ## Contents
428
-
429
- {% for tag in tagset.tags %}
430
- - {{ tag.name }}
431
- {% endfor %}
432
-
433
- ## Tags
434
-
435
- {% for tag in tagset.tags %}
436
- ### {{ tag.name }}
437
-
438
- {{ tag.desc | render_desc }}
439
-
440
- {% endfor %}
441
- """
442
- )
443
-
444
- env = jinja2.Environment(trim_blocks=True, lstrip_blocks=True)
445
- env.filters["render_desc"] = render_desc
446
-
447
- return env.from_string(template_str).render(tagset=tagset)
@@ -4,8 +4,10 @@ from typing import Protocol, Self
4
4
  import pydantic as pdt
5
5
  import sqlalchemy as sa
6
6
  import sqlalchemy.dialects.postgresql as sa_pg
7
+ import sqlalchemy.dialects.sqlite as sa_sqlite
7
8
  import sqlalchemy.exc as sa_exc
8
9
  import sqlalchemy.orm as sa_orm
10
+ from iker.common.utils.dbutils import dialects
9
11
  from sqlmodel import Field, SQLModel
10
12
 
11
13
  from plexus.common.utils.datautils import validate_dt_timezone
@@ -13,6 +15,7 @@ from plexus.common.utils.jsonutils import json_datetime_encoder
13
15
 
14
16
  __all__ = [
15
17
  "compare_postgresql_types",
18
+ "compare_sqlite_types",
16
19
  "model_name_of",
17
20
  "validate_model_extended",
18
21
  "collect_model_tables",
@@ -110,6 +113,30 @@ def compare_postgresql_types(type_a, type_b) -> bool:
110
113
  }
111
114
 
112
115
 
116
+ def compare_sqlite_types(type_a, type_b) -> bool:
117
+ """
118
+ Compares two SQLite-specific column types to determine if they are equivalent.
119
+ This includes types from sqlalchemy.dialects.sqlite like JSON, etc.
120
+ """
121
+ if not isinstance(type_a, type(type_b)):
122
+ return False
123
+ if isinstance(type_a, (sa_sqlite.VARCHAR, sa_sqlite.CHAR, sa_sqlite.TEXT)):
124
+ return type_a.length == type_b.length
125
+ if isinstance(type_a, (sa_sqlite.TIMESTAMP, sa_sqlite.TIME)):
126
+ return type_a.timezone == type_b.timezone
127
+ if isinstance(type_a, (sa_sqlite.NUMERIC, sa_sqlite.DECIMAL)):
128
+ return type_a.precision == type_b.precision and type_a.scale == type_b.scale
129
+ return type(type_a) in {
130
+ sa_sqlite.BOOLEAN,
131
+ sa_sqlite.INTEGER,
132
+ sa_sqlite.SMALLINT,
133
+ sa_sqlite.FLOAT,
134
+ sa_sqlite.REAL,
135
+ sa_sqlite.DATE,
136
+ sa_sqlite.JSON,
137
+ }
138
+
139
+
113
140
  def model_name_of(model: type[SQLModel], fallback_classname: bool = True) -> str | None:
114
141
  table_name = getattr(model, "__tablename__")
115
142
  if not table_name:
@@ -183,12 +210,12 @@ def make_base_model() -> type[SQLModel]:
183
210
 
184
211
 
185
212
  class SequenceModelMixinProtocol(Protocol):
186
- sqn: int | None
213
+ sqn: sa_orm.Mapped[int | None]
187
214
 
188
215
 
189
216
  class ChangingModelMixinProtocol(SequenceModelMixinProtocol):
190
- created_at: datetime.datetime | None
191
- updated_at: datetime.datetime | None
217
+ created_at: sa_orm.Mapped[datetime.datetime | None]
218
+ updated_at: sa_orm.Mapped[datetime.datetime | None]
192
219
 
193
220
  @classmethod
194
221
  def make_index_created_at(cls, index_name: str) -> sa.Index:
@@ -201,9 +228,9 @@ class ChangingModelMixinProtocol(SequenceModelMixinProtocol):
201
228
 
202
229
 
203
230
  class SnapshotModelMixinProtocol(SequenceModelMixinProtocol):
204
- created_at: datetime.datetime | None
205
- expired_at: datetime.datetime | None
206
- record_sqn: int | None
231
+ created_at: sa_orm.Mapped[datetime.datetime | None]
232
+ expired_at: sa_orm.Mapped[datetime.datetime | None]
233
+ record_sqn: sa_orm.Mapped[int | None]
207
234
 
208
235
  @classmethod
209
236
  def make_index_created_at_expired_at(cls, index_name: str) -> sa.Index:
@@ -249,11 +276,11 @@ class SnapshotModelMixinProtocol(SequenceModelMixinProtocol):
249
276
 
250
277
 
251
278
  class RevisionModelMixinProtocol(SequenceModelMixinProtocol):
252
- created_at: datetime.datetime | None
253
- updated_at: datetime.datetime | None
254
- expired_at: datetime.datetime | None
255
- record_sqn: int | None
256
- revision: int | None
279
+ created_at: sa_orm.Mapped[datetime.datetime | None]
280
+ updated_at: sa_orm.Mapped[datetime.datetime | None]
281
+ expired_at: sa_orm.Mapped[datetime.datetime | None]
282
+ record_sqn: sa_orm.Mapped[int | None]
283
+ revision: sa_orm.Mapped[int | None]
257
284
 
258
285
  @classmethod
259
286
  def make_index_created_at_updated_at_expired_at(cls, index_name: str) -> sa.Index:
@@ -317,15 +344,67 @@ SnapshotModelMixin = SnapshotModelMixinProtocol | SQLModel
317
344
  RevisionModelMixin = RevisionModelMixinProtocol | SQLModel
318
345
 
319
346
 
320
- def make_sequence_model_mixin() -> type[SequenceModelMixin]:
347
+ def model_sqn_type(dialect: str | None = None) -> sa.types.TypeEngine[int]:
348
+ """
349
+ Returns the appropriate SQLAlchemy column type for a sequence number (sqn) based on the specified database dialect.
350
+
351
+ :param dialect: The database dialect to determine the column type for. If None, defaults to PostgreSQL.
352
+ :return: The SQLAlchemy column type to use for the sqn field.
353
+ """
354
+ if dialect is None:
355
+ dialect = dialects.postgresql
356
+ if dialect == dialects.postgresql:
357
+ return sa_pg.BIGINT()
358
+ if dialect == dialects.sqlite:
359
+ return sa_sqlite.INTEGER()
360
+ raise ValueError(f"unsupported database dialect '{dialect}'")
361
+
362
+
363
+ def model_datetime_tz_type(dialect: str | None = None) -> sa.types.TypeEngine[datetime.datetime]:
364
+ """
365
+ Returns the appropriate SQLAlchemy column type for a timezone-aware datetime field based on the specified database
366
+ dialect.
367
+
368
+ :param dialect: The database dialect to determine the column type for. If None, defaults to PostgreSQL.
369
+ :return: The SQLAlchemy column type to use for timezone-aware datetime fields.
370
+ """
371
+ if dialect is None:
372
+ dialect = dialects.postgresql
373
+ if dialect == dialects.postgresql:
374
+ return sa_pg.TIMESTAMP(timezone=True)
375
+ if dialect == dialects.sqlite:
376
+ return sa_sqlite.TIMESTAMP(timezone=True)
377
+ raise ValueError(f"unsupported database dialect '{dialect}'")
378
+
379
+
380
+ def model_revision_type(dialect: str | None = None) -> sa.types.TypeEngine[int]:
381
+ """
382
+ Returns the appropriate SQLAlchemy column type for a revision number field based on the specified database dialect.
383
+
384
+ :param dialect: The database dialect to determine the column type for. If None, defaults to PostgreSQL.
385
+ :return: The SQLAlchemy column type to use for revision number fields.
386
+ """
387
+ if dialect is None:
388
+ dialect = dialects.postgresql
389
+ if dialect == dialects.postgresql:
390
+ return sa_pg.INTEGER()
391
+ if dialect == dialects.sqlite:
392
+ return sa_sqlite.INTEGER()
393
+ raise ValueError(f"unsupported database dialect '{dialect}'")
394
+
395
+
396
+ def make_sequence_model_mixin(dialect: str | None = None) -> type[SequenceModelMixin]:
321
397
  """
322
398
  Creates a mixin class for SQLModel models that adds a unique identifier field `sqn`.
323
399
  Use this mixin to add an auto-incremented primary key to your models.
400
+
401
+ :param dialect: The database dialect to determine the column type for the `sqn` field.
402
+ :return: A mixin class that can be used with SQLModel models to add the `sqn` field.
324
403
  """
325
404
 
326
405
  class ModelMixin(SQLModel):
327
406
  sqn: int | None = Field(
328
- sa_column=sa.Column(sa_pg.BIGINT, primary_key=True, autoincrement=True),
407
+ sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
329
408
  default=None,
330
409
  description="Unique auto-incremented primary key for the record",
331
410
  )
@@ -333,25 +412,29 @@ def make_sequence_model_mixin() -> type[SequenceModelMixin]:
333
412
  return ModelMixin
334
413
 
335
414
 
336
- def make_changing_model_mixin() -> type[ChangingModelMixin]:
415
+ def make_changing_model_mixin(dialect: str | None = None) -> type[ChangingModelMixin]:
337
416
  """
338
417
  Creates a mixin class for SQLModel models that adds common fields and validation logic for updatable records.
339
418
  This mixin includes ``sqn``, ``created_at``, and ``updated_at`` fields, along with validation for timestamps.
419
+
420
+ :param dialect: The database dialect to determine the column types for the fields.
421
+ :return: A mixin class that can be used with SQLModel models to add the common fields and validation logic for
422
+ updatable records.
340
423
  """
341
424
 
342
425
  class ModelMixin(SQLModel):
343
426
  sqn: int | None = Field(
344
- sa_column=sa.Column(sa_pg.BIGINT, primary_key=True, autoincrement=True),
427
+ sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
345
428
  default=None,
346
429
  description="Unique auto-incremented primary key for the record",
347
430
  )
348
431
  created_at: datetime.datetime | None = Field(
349
- sa_column=sa.Column(sa_pg.TIMESTAMP(timezone=True)),
432
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
350
433
  default=None,
351
434
  description="Timestamp (with timezone) when the record was created",
352
435
  )
353
436
  updated_at: datetime.datetime | None = Field(
354
- sa_column=sa.Column(sa_pg.TIMESTAMP(timezone=True)),
437
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
355
438
  default=None,
356
439
  description="Timestamp (with timezone) when the record was last updated",
357
440
  )
@@ -383,7 +466,7 @@ def make_changing_model_mixin() -> type[ChangingModelMixin]:
383
466
  return ModelMixin
384
467
 
385
468
 
386
- def make_snapshot_model_mixin() -> type[SnapshotModelMixin]:
469
+ def make_snapshot_model_mixin(dialect: str | None = None) -> type[SnapshotModelMixin]:
387
470
  """
388
471
  Provides a mixin class for SQLModel models that adds common fields and validation logic for record snapshots.
389
472
  A snapshot model tracks the full change history of an entity: when any field changes, the current record (with a
@@ -395,26 +478,30 @@ def make_snapshot_model_mixin() -> type[SnapshotModelMixin]:
395
478
  - ``expired_at``: Time (with timezone) when this snapshot of the record was superseded or became inactive;
396
479
  ``None`` if still active.
397
480
  - ``record_sqn``: Foreign key to the record this snapshot belongs to; used to link snapshots together.
481
+
482
+ :param dialect: The database dialect to determine the column types for the fields.
483
+ :return: A mixin class that can be used with SQLModel models to add the common fields and validation logic for
484
+ record snapshots.
398
485
  """
399
486
 
400
487
  class ModelMixin(SQLModel):
401
488
  sqn: int | None = Field(
402
- sa_column=sa.Column(sa_pg.BIGINT, primary_key=True, autoincrement=True),
489
+ sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
403
490
  default=None,
404
491
  description="Unique auto-incremented primary key for each record snapshot",
405
492
  )
406
493
  created_at: datetime.datetime | None = Field(
407
- sa_column=sa.Column(sa_pg.TIMESTAMP(timezone=True)),
494
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
408
495
  default=None,
409
496
  description="Timestamp (with timezone) when this record snapshot became active",
410
497
  )
411
498
  expired_at: datetime.datetime | None = Field(
412
- sa_column=sa.Column(sa_pg.TIMESTAMP(timezone=True)),
499
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
413
500
  default=None,
414
501
  description="Timestamp (with timezone) when this record snapshot became inactive; None if still active",
415
502
  )
416
503
  record_sqn: int | None = Field(
417
- sa_column=sa.Column(sa_pg.BIGINT, nullable=True),
504
+ sa_column=sa.Column(model_sqn_type(dialect), nullable=True),
418
505
  default=None,
419
506
  description="Foreign key to the record this snapshot belongs to",
420
507
  )
@@ -472,7 +559,7 @@ def make_snapshot_model_mixin() -> type[SnapshotModelMixin]:
472
559
  return ModelMixin
473
560
 
474
561
 
475
- def make_revision_model_mixin() -> type[RevisionModelMixin]:
562
+ def make_revision_model_mixin(dialect: str | None = None) -> type[RevisionModelMixin]:
476
563
  """
477
564
  Provides a mixin class for SQLModel models that adds common fields and validation logic for record revisions.
478
565
  A revision model tracks the full change history of an entity: when any field changes, the current record (with a
@@ -486,36 +573,40 @@ def make_revision_model_mixin() -> type[RevisionModelMixin]:
486
573
  ``None`` if still active.
487
574
  - ``record_sqn``: Auto-incremented key of the record this revision belongs to; used to link revisions together.
488
575
  - ``revision``: Revision number for the record, used to track changes over time.
576
+
577
+ :param dialect: The database dialect to determine the column types for the fields.
578
+ :return: A mixin class that can be used with SQLModel models to add the common fields and validation logic for
579
+ record revisions.
489
580
  """
490
581
 
491
582
  class ModelMixin(SQLModel):
492
583
  sqn: int | None = Field(
493
- sa_column=sa.Column(sa_pg.BIGINT, primary_key=True, autoincrement=True),
584
+ sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
494
585
  default=None,
495
586
  description="Unique auto-incremented primary key for each record revision",
496
587
  )
497
588
  created_at: datetime.datetime | None = Field(
498
- sa_column=sa.Column(sa_pg.TIMESTAMP(timezone=True)),
589
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
499
590
  default=None,
500
591
  description="Timestamp (with timezone) when this record is first created (preserved across revisions)",
501
592
  )
502
593
  updated_at: datetime.datetime | None = Field(
503
- sa_column=sa.Column(sa_pg.TIMESTAMP(timezone=True)),
594
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
504
595
  default=None,
505
596
  description="Timestamp (with timezone) when this record is updated and this record revision became active",
506
597
  )
507
598
  expired_at: datetime.datetime | None = Field(
508
- sa_column=sa.Column(sa_pg.TIMESTAMP(timezone=True)),
599
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
509
600
  default=None,
510
601
  description="Timestamp (with timezone) when this record revision became inactive; None if still active",
511
602
  )
512
603
  record_sqn: int | None = Field(
513
- sa_column=sa.Column(sa_pg.BIGINT, nullable=True),
604
+ sa_column=sa.Column(model_sqn_type(dialect), nullable=True),
514
605
  default=None,
515
606
  description="Auto-incremented key of the record this revision belongs to",
516
607
  )
517
608
  revision: int | None = Field(
518
- sa_column=sa.Column(sa_pg.INTEGER, nullable=True),
609
+ sa_column=sa.Column(model_revision_type(dialect), nullable=True),
519
610
  default=None,
520
611
  description="Revision number for the record",
521
612
  )