plexus-python-pulse-common 1.1.83__tar.gz → 1.1.85__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 (96) hide show
  1. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/PKG-INFO +1 -1
  2. plexus_python_pulse_common-1.1.85/src/plexus/pulse/common/utils/apiutils.py +47 -0
  3. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/ormutils.py +72 -14
  4. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus_python_pulse_common.egg-info/PKG-INFO +1 -1
  5. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus_python_pulse_common.egg-info/SOURCES.txt +1 -0
  6. plexus_python_pulse_common-1.1.85/test/plexus_tests/pulse/common/utils/apiutils_test.py +71 -0
  7. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/ormutils_test.py +59 -10
  8. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/s3utils_test.py +4 -7
  9. plexus_python_pulse_common-1.1.83/src/plexus/pulse/common/utils/apiutils.py +0 -31
  10. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/.editorconfig +0 -0
  11. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/.github/workflows/pr.yml +0 -0
  12. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/.github/workflows/push.yml +0 -0
  13. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/.gitignore +0 -0
  14. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/MANIFEST.in +0 -0
  15. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/README.md +0 -0
  16. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/VERSION +0 -0
  17. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/pyproject.toml +0 -0
  18. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/jsonutils/dummy.0.jsonl +0 -0
  19. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/jsonutils/dummy.1.jsonl +0 -0
  20. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/jsonutils/dummy.2.jsonl +0 -0
  21. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/0-dummy +0 -0
  22. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/1-dummy +0 -0
  23. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/2-dummy +0 -0
  24. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.0.0.jsonl +0 -0
  25. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.0.0.vol-0.jsonl +0 -0
  26. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.0.jsonl +0 -0
  27. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.1.1.jsonl +0 -0
  28. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.1.1.vol-1.jsonl +0 -0
  29. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.1.jsonl +0 -0
  30. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.2.2.jsonl +0 -0
  31. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.2.2.vol-2.jsonl +0 -0
  32. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.2.jsonl +0 -0
  33. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.csv.part0 +0 -0
  34. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.csv.part1 +0 -0
  35. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.csv.part2 +0 -0
  36. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/pathutils/dummy.txt +0 -0
  37. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.baz/file.bar.baz +0 -0
  38. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.baz/file.foo.bar +0 -0
  39. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.baz/file.foo.baz +0 -0
  40. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.foo/dir.foo.bar/dir.foo.bar.baz/file.foo.bar.baz +0 -0
  41. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.bar.baz +0 -0
  42. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.foo.bar +0 -0
  43. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.foo.baz +0 -0
  44. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.foo/file.bar +0 -0
  45. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.foo/file.baz +0 -0
  46. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils/dir.foo/file.foo +0 -0
  47. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils_archive/archive.compressed.zip +0 -0
  48. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/resources/unittest/s3utils_archive/archive.uncompressed.zip +0 -0
  49. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/setup.cfg +0 -0
  50. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/setup.py +0 -0
  51. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/__init__.py +0 -0
  52. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/carto/OSMFile.py +0 -0
  53. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/carto/OSMNode.py +0 -0
  54. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/carto/OSMTags.py +0 -0
  55. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/carto/OSMWay.py +0 -0
  56. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/carto/__init__.py +0 -0
  57. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/resources/__init__.py +0 -0
  58. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/resources/tags/__init__.py +0 -0
  59. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/resources/tags/unittest-1.0.0.tagset.yaml +0 -0
  60. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/resources/tags/universal-1.0.0.tagset.yaml +0 -0
  61. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/__init__.py +0 -0
  62. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/bagutils.py +0 -0
  63. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/config.py +0 -0
  64. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/datautils.py +0 -0
  65. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/dockerutils.py +0 -0
  66. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/flowutils.py +0 -0
  67. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/gisutils.py +0 -0
  68. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/jsonutils.py +0 -0
  69. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/pathutils.py +0 -0
  70. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/s3utils.py +0 -0
  71. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/sqlutils.py +0 -0
  72. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/strutils.py +0 -0
  73. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/tagutils.py +0 -0
  74. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus/pulse/common/utils/testutils.py +0 -0
  75. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus_python_pulse_common.egg-info/dependency_links.txt +0 -0
  76. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus_python_pulse_common.egg-info/not-zip-safe +0 -0
  77. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus_python_pulse_common.egg-info/requires.txt +0 -0
  78. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/src/plexus_python_pulse_common.egg-info/top_level.txt +0 -0
  79. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/__init__.py +0 -0
  80. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/__init__.py +0 -0
  81. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/__init__.py +0 -0
  82. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/carto/__init__.py +0 -0
  83. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/carto/osm_file_test.py +0 -0
  84. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/carto/osm_tags_test.py +0 -0
  85. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/__init__.py +0 -0
  86. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/bagutils_test.py +0 -0
  87. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/datautils_test.py +0 -0
  88. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/dockerutils_test.py +0 -0
  89. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/flowutils_test.py +0 -0
  90. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/gisutils_test.py +0 -0
  91. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/jsonutils_test.py +0 -0
  92. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/pathutils_test.py +0 -0
  93. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/strutils_test.py +0 -0
  94. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/tagutils_test.py +0 -0
  95. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/plexus_tests/pulse/common/utils/testutils_test.py +0 -0
  96. {plexus_python_pulse_common-1.1.83 → plexus_python_pulse_common-1.1.85}/test/testenv.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python-pulse-common
3
- Version: 1.1.83
3
+ Version: 1.1.85
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.12
6
6
  Classifier: Programming Language :: Python :: 3.13
@@ -0,0 +1,47 @@
1
+ from contextlib import AbstractContextManager
2
+
3
+ import sqlalchemy.orm as sa_orm
4
+ from fastapi import HTTPException
5
+ from plexus.common.utils import logger
6
+
7
+ __all__ = [
8
+ "managed_db_session",
9
+ ]
10
+
11
+
12
+ class DBSessionExceptionManager(AbstractContextManager):
13
+ def __init__(self, db: sa_orm.Session, commit_on_exit: bool):
14
+ self.db = db
15
+ self.commit_on_exit = commit_on_exit
16
+
17
+ def __enter__(self):
18
+ return self
19
+
20
+ def __exit__(self, exc_type, exc_value, traceback):
21
+ if exc_type is None:
22
+ if self.commit_on_exit:
23
+ try:
24
+ self.db.commit()
25
+ except BaseException as commit_exc_value:
26
+ self.handle_exception(commit_exc_value)
27
+ raise
28
+ return False
29
+
30
+ self.handle_exception(exc_value)
31
+ return False
32
+
33
+ def handle_exception(self, exc_value: BaseException | None) -> None:
34
+ try:
35
+ self.db.rollback()
36
+ except Exception:
37
+ logger.exception("Failed to roll back database session")
38
+
39
+ if isinstance(exc_value, HTTPException) or not isinstance(exc_value, Exception):
40
+ return
41
+
42
+ logger.error("Database session failed", exc_info=(type(exc_value), exc_value, exc_value.__traceback__))
43
+ raise HTTPException(status_code=500, detail="Internal server error") from exc_value
44
+
45
+
46
+ def managed_db_session(db: sa_orm.Session, commit_on_exit: bool = True) -> DBSessionExceptionManager:
47
+ return DBSessionExceptionManager(db, commit_on_exit)
@@ -224,24 +224,72 @@ def collect_model_tables[ModelT: SQLModel](*models: ModelT) -> sa.MetaData:
224
224
  return metadata
225
225
 
226
226
 
227
- def model_copy_from[ModelT: SQLModel, ModelU: SQLModel](dst: ModelT, src: ModelU, **kwargs) -> ModelT:
227
+ def model_copy_from[ModelT: SQLModel, ModelU: SQLModel](
228
+ dst: ModelT,
229
+ src: ModelU,
230
+ *,
231
+ include: set[str] | frozenset[str] | None = None,
232
+ exclude: set[str] | frozenset[str] | None = None,
233
+ exclude_none: bool = False,
234
+ exclude_unset: bool = False,
235
+ exclude_defaults: bool = False,
236
+ ) -> ModelT:
237
+ """Copy native field values without invoking serializers.
238
+
239
+ ``include`` and ``exclude`` select top-level field names only; nested filter
240
+ mappings and serialization options are not supported. The exclusion flags
241
+ also apply when recursively copying nested models.
242
+ """
228
243
  if not isinstance(dst, SQLModel) or not isinstance(src, SQLModel):
229
244
  raise TypeError("both 'dst' and 'src' must be instances of SQLModel or its subclasses")
245
+ if include is not None and (
246
+ not isinstance(include, (set, frozenset)) or not all(isinstance(item, str) for item in include)):
247
+ raise TypeError(f"'include' must be a set of field names or None")
248
+ if exclude is not None and (
249
+ not isinstance(exclude, (set, frozenset)) or not all(isinstance(item, str) for item in exclude)):
250
+ raise TypeError(f"'exclude' must be a set of field names or None")
230
251
 
231
- for field, value in src.model_dump(**kwargs).items():
232
- if field not in dst.model_fields:
233
- continue
252
+ src_model = type(src)
253
+ dst_model = type(dst)
254
+
255
+ for field, src_field_info in src_model.model_fields.items():
234
256
  # Skip fields that are not present in the destination model
235
- if value is None and dst.model_fields[field].required:
236
- raise ValueError(f"field '{field}' is required but got None")
257
+ if field not in dst_model.model_fields:
258
+ continue
259
+ if include is not None and field not in include:
260
+ continue
261
+ if exclude is not None and field in exclude:
262
+ continue
263
+ if exclude_unset and field not in src.model_fields_set:
264
+ continue
265
+
266
+ value = getattr(src, field)
267
+ if exclude_none and value is None:
268
+ continue
269
+ if exclude_defaults and not src_field_info.is_required():
270
+ default = src_field_info.get_default(call_default_factory=True, validated_data=src.__dict__)
271
+ if value == default:
272
+ continue
273
+
274
+ field_info = dst_model.model_fields[field]
275
+ if value is None:
276
+ pdt.TypeAdapter(field_info.annotation).validate_python(None)
237
277
 
238
278
  # Only set the field if it exists in the destination model
239
279
  if hasattr(dst, field):
240
280
  # If the field is a SQLModel, recursively copy it
241
281
  if isinstance(value, SQLModel):
242
- value = model_copy_from(getattr(dst, field), value, **kwargs)
282
+ value = model_copy_from(getattr(dst, field),
283
+ value,
284
+ exclude_none=exclude_none,
285
+ exclude_unset=exclude_unset,
286
+ exclude_defaults=exclude_defaults)
243
287
  elif isinstance(value, list) and all(isinstance(item, SQLModel) for item in value):
244
- value = [model_copy_from(dst_item, src_item, **kwargs)
288
+ value = [model_copy_from(dst_item,
289
+ src_item,
290
+ exclude_none=exclude_none,
291
+ exclude_unset=exclude_unset,
292
+ exclude_defaults=exclude_defaults)
245
293
  for dst_item, src_item in zip(getattr(dst, field), value)]
246
294
 
247
295
  setattr(dst, field, value)
@@ -503,6 +551,16 @@ def model_revision_type(dialect: str | None = None) -> sa.types.TypeEngine[int]:
503
551
  raise ValueError(f"unsupported database dialect '{dialect}'")
504
552
 
505
553
 
554
+ def index_condition_kwarg(dialect: str | None = None, clause: sa.TextClause | None = None) -> dict[str, sa.TextClause]:
555
+ if dialect is None:
556
+ dialect = dialects.postgresql
557
+ if dialect == dialects.postgresql:
558
+ return dict(postgresql_where=clause)
559
+ if dialect == dialects.sqlite:
560
+ return dict(sqlite_where=clause)
561
+ raise ValueError(f"unsupported database dialect '{dialect}'")
562
+
563
+
506
564
  # At the present time, we cannot express intersection of Protocol and SQLModel directly.
507
565
  # Thus, we define union types here for the mixins.
508
566
  SequenceModelMixin = SequenceModelMixinProtocol | pdt.BaseModel
@@ -670,7 +728,7 @@ def make_snapshot_model_mixin(dialect: str | None = None) -> type[SnapshotModelM
670
728
  index_name,
671
729
  "record_sqn",
672
730
  unique=True,
673
- postgresql_where=sa.text('"expired_at" IS NULL'),
731
+ **index_condition_kwarg(dialect, sa.text('"expired_at" IS NULL')),
674
732
  )
675
733
 
676
734
  @classmethod
@@ -678,7 +736,7 @@ def make_snapshot_model_mixin(dialect: str | None = None) -> type[SnapshotModelM
678
736
  return sa.Index(
679
737
  index_name,
680
738
  *fields,
681
- postgresql_where=sa.text('"expired_at" IS NULL'),
739
+ **index_condition_kwarg(dialect, sa.text('"expired_at" IS NULL')),
682
740
  )
683
741
 
684
742
  @classmethod
@@ -687,7 +745,7 @@ def make_snapshot_model_mixin(dialect: str | None = None) -> type[SnapshotModelM
687
745
  index_name,
688
746
  *fields,
689
747
  unique=True,
690
- postgresql_where=sa.text('"expired_at" IS NULL'),
748
+ **index_condition_kwarg(dialect, sa.text('"expired_at" IS NULL')),
691
749
  )
692
750
 
693
751
  return ModelMixin
@@ -807,7 +865,7 @@ def make_revision_model_mixin(dialect: str | None = None) -> type[RevisionModelM
807
865
  index_name,
808
866
  "record_sqn",
809
867
  unique=True,
810
- postgresql_where=sa.text('"expired_at" IS NULL'),
868
+ **index_condition_kwarg(dialect, sa.text('"expired_at" IS NULL')),
811
869
  )
812
870
 
813
871
  @classmethod
@@ -815,7 +873,7 @@ def make_revision_model_mixin(dialect: str | None = None) -> type[RevisionModelM
815
873
  return sa.Index(
816
874
  index_name,
817
875
  *fields,
818
- postgresql_where=sa.text('"expired_at" IS NULL'),
876
+ **index_condition_kwarg(dialect, sa.text('"expired_at" IS NULL')),
819
877
  )
820
878
 
821
879
  @classmethod
@@ -824,7 +882,7 @@ def make_revision_model_mixin(dialect: str | None = None) -> type[RevisionModelM
824
882
  index_name,
825
883
  *fields,
826
884
  unique=True,
827
- postgresql_where=sa.text('"expired_at" IS NULL'),
885
+ **index_condition_kwarg(dialect, sa.text('"expired_at" IS NULL')),
828
886
  )
829
887
 
830
888
  return ModelMixin
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python-pulse-common
3
- Version: 1.1.83
3
+ Version: 1.1.85
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.12
6
6
  Classifier: Programming Language :: Python :: 3.13
@@ -78,6 +78,7 @@ test/plexus_tests/pulse/common/carto/__init__.py
78
78
  test/plexus_tests/pulse/common/carto/osm_file_test.py
79
79
  test/plexus_tests/pulse/common/carto/osm_tags_test.py
80
80
  test/plexus_tests/pulse/common/utils/__init__.py
81
+ test/plexus_tests/pulse/common/utils/apiutils_test.py
81
82
  test/plexus_tests/pulse/common/utils/bagutils_test.py
82
83
  test/plexus_tests/pulse/common/utils/datautils_test.py
83
84
  test/plexus_tests/pulse/common/utils/dockerutils_test.py
@@ -0,0 +1,71 @@
1
+ import unittest
2
+ from unittest.mock import Mock, patch
3
+
4
+ from fastapi import HTTPException
5
+
6
+ from plexus.pulse.common.utils.apiutils import managed_db_session
7
+
8
+
9
+ class ApiUtilsTest(unittest.TestCase):
10
+ def test_managed_db_session__success(self):
11
+ for commit_on_exit in (True, False):
12
+ with self.subTest(commit_on_exit=commit_on_exit):
13
+ db = Mock()
14
+ with managed_db_session(db, commit_on_exit):
15
+ pass
16
+ self.assertEqual(db.commit.call_count, int(commit_on_exit))
17
+ db.rollback.assert_not_called()
18
+
19
+ def test_managed_db_session__http_exception(self):
20
+ db = Mock()
21
+ error = HTTPException(404, "Missing", headers={"X-Reason": "missing"})
22
+ with self.assertRaises(HTTPException) as caught:
23
+ with managed_db_session(db):
24
+ raise error
25
+ self.assertIs(caught.exception, error)
26
+ db.rollback.assert_called_once_with()
27
+ db.commit.assert_not_called()
28
+
29
+ def test_managed_db_session__body_and_commit_errors_are_sanitized(self):
30
+ for during_commit in (False, True):
31
+ with self.subTest(during_commit=during_commit):
32
+ db = Mock()
33
+ error = RuntimeError("Sensitive SQL parameters")
34
+ if during_commit:
35
+ db.commit.side_effect = error
36
+ with patch("plexus.pulse.common.utils.apiutils.logger") as logger:
37
+ with self.assertRaises(HTTPException) as caught:
38
+ with managed_db_session(db):
39
+ if not during_commit:
40
+ raise error
41
+ logger.error.assert_called_once()
42
+ self.assertEqual(caught.exception.status_code, 500)
43
+ self.assertEqual(caught.exception.detail, "Internal server error")
44
+ self.assertIs(caught.exception.__cause__, error)
45
+ db.rollback.assert_called_once_with()
46
+
47
+ def test_managed_db_session__process_control_exceptions_are_preserved(self):
48
+ for error_type in (KeyboardInterrupt, SystemExit):
49
+ for during_commit in (False, True):
50
+ with self.subTest(error_type=error_type, during_commit=during_commit):
51
+ db = Mock()
52
+ error = error_type()
53
+ if during_commit:
54
+ db.commit.side_effect = error
55
+ with self.assertRaises(error_type) as caught:
56
+ with managed_db_session(db):
57
+ if not during_commit:
58
+ raise error
59
+ self.assertIs(caught.exception, error)
60
+ db.rollback.assert_called_once_with()
61
+
62
+ def test_managed_db_session__rollback_failure_preserves_original_http_exception(self):
63
+ db = Mock()
64
+ db.rollback.side_effect = RuntimeError("Rollback failed")
65
+ error = HTTPException(409, "Conflict")
66
+ with patch("plexus.pulse.common.utils.apiutils.logger") as logger:
67
+ with self.assertRaises(HTTPException) as caught:
68
+ with managed_db_session(db):
69
+ raise error
70
+ logger.exception.assert_called_once()
71
+ self.assertIs(caught.exception, error)
@@ -1,6 +1,8 @@
1
1
  import datetime
2
+ import unittest
2
3
  import uuid as py_uuid
3
4
 
5
+ import pydantic as pdt
4
6
  import pytest
5
7
  import pytest_postgresql.factories
6
8
  import sqlalchemy as sa
@@ -15,16 +17,9 @@ from sqlmodel import Field, SQLModel
15
17
 
16
18
  from plexus.pulse.common.utils.apiutils import managed_db_session
17
19
  from plexus.pulse.common.utils.ormutils import (
18
- changing_model_mixin,
19
- make_base_model,
20
- make_changing_model_mixin,
21
- make_revision_model_mixin,
22
- make_revision_model_trigger,
23
- make_sequence_model_mixin,
24
- make_snapshot_model_mixin,
25
- make_snapshot_model_trigger,
26
- revision_model_mixin,
27
- snapshot_model_mixin,
20
+ changing_model_mixin, make_base_model, make_changing_model_mixin,
21
+ make_revision_model_mixin, make_revision_model_trigger, make_sequence_model_mixin, make_snapshot_model_mixin,
22
+ make_snapshot_model_trigger, model_copy_from, revision_model_mixin, snapshot_model_mixin,
28
23
  )
29
24
  from plexus.pulse.common.utils.ormutils import (
30
25
  db_activate_revision_model,
@@ -1217,3 +1212,57 @@ def test_make_revision_model_trigger(fixture_postgresql_test_proc, fixture_postg
1217
1212
  assert updated_record.updated_at is not None
1218
1213
  assert updated_record.updated_at == initial_record.expired_at
1219
1214
  assert updated_record.expired_at is None
1215
+
1216
+
1217
+ class CopyRecord(SQLModel):
1218
+ name: str | None = None
1219
+ count: int = 0
1220
+ values: list[int] = Field(default_factory=list)
1221
+ created_at: datetime.datetime | None = None
1222
+
1223
+ @pdt.field_serializer("created_at")
1224
+ def serialize_created_at(self, value):
1225
+ raise AssertionError("Copying must not invoke serializers")
1226
+
1227
+
1228
+ class OrmUtilsTest(unittest.TestCase):
1229
+ def test_model_copy_from__native_datetime_without_serialization(self):
1230
+ now = datetime.datetime.now(datetime.timezone.utc)
1231
+ result = model_copy_from(CopyRecord(), CopyRecord(created_at=now))
1232
+ self.assertIs(result.created_at, now)
1233
+
1234
+ def test_field_filters(self):
1235
+ for options in (
1236
+ {"include": {"count"}},
1237
+ {"exclude": frozenset({"name", "values", "created_at"})},
1238
+ {"exclude_unset": True},
1239
+ {"exclude_defaults": True},
1240
+ ):
1241
+ with self.subTest(options=options):
1242
+ dst = CopyRecord(name="keep", count=9, values=[1])
1243
+ model_copy_from(dst, CopyRecord(count=4), **options)
1244
+ self.assertEqual(dst.name, "keep")
1245
+ self.assertEqual(dst.count, 4)
1246
+ self.assertEqual(dst.values, [1])
1247
+
1248
+ def test_model_copy_from__none_filter(self):
1249
+ dst = CopyRecord(name="keep")
1250
+ model_copy_from(dst, CopyRecord(), exclude_none=True)
1251
+ self.assertEqual(dst.name, "keep")
1252
+ model_copy_from(dst, CopyRecord())
1253
+ self.assertIsNone(dst.name)
1254
+
1255
+ def test_model_copy_from__explicit_default_is_set(self):
1256
+ dst = CopyRecord(count=9)
1257
+ model_copy_from(dst, CopyRecord(count=0), exclude_unset=True)
1258
+ self.assertEqual(dst.count, 0)
1259
+
1260
+ def test_model_copy_from__exclude_wins_over_include(self):
1261
+ dst = CopyRecord(count=9)
1262
+ model_copy_from(dst, CopyRecord(count=4), include={"count"}, exclude={"count"})
1263
+ self.assertEqual(dst.count, 9)
1264
+
1265
+ def test_model_copy_from__unsupported_options_fail(self):
1266
+ for options in ({"include": {"name": True}}, {"by_alias": True}):
1267
+ with self.subTest(options=options), self.assertRaises(TypeError):
1268
+ model_copy_from(CopyRecord(), CopyRecord(), **options)
@@ -15,7 +15,7 @@ from testenv import resources_directory
15
15
 
16
16
  @contextlib.contextmanager
17
17
  def moto_server_endpoint_url():
18
- server = moto.server.ThreadedMotoServer(port=0) # random free port
18
+ server = moto.server.ThreadedMotoServer(ip_address="127.0.0.1", port=0) # random free port
19
19
  try:
20
20
  server.start()
21
21
  host, port = server.get_host_and_port()
@@ -481,9 +481,8 @@ class S3UtilsTest(unittest.TestCase):
481
481
 
482
482
  def test_s3_archive_list_files(self):
483
483
  with (
484
- moto.mock_aws(),
485
484
  moto_server_endpoint_url() as endpoint_url,
486
- s3_make_client(endpoint_url=endpoint_url) as client,
485
+ s3_make_client("testing", "testing", "us-east-1", endpoint_url=endpoint_url) as client,
487
486
  ):
488
487
  client.create_bucket(Bucket="dummy-bucket")
489
488
 
@@ -544,9 +543,8 @@ class S3UtilsTest(unittest.TestCase):
544
543
 
545
544
  def test_s3_archive_open_member(self):
546
545
  with (
547
- moto.mock_aws(),
548
546
  moto_server_endpoint_url() as endpoint_url,
549
- s3_make_client(endpoint_url=endpoint_url) as client,
547
+ s3_make_client("testing", "testing", "us-east-1", endpoint_url=endpoint_url) as client,
550
548
  ):
551
549
  client.create_bucket(Bucket="dummy-bucket")
552
550
 
@@ -571,9 +569,8 @@ class S3UtilsTest(unittest.TestCase):
571
569
 
572
570
  def test_s3_archive_open_members(self):
573
571
  with (
574
- moto.mock_aws(),
575
572
  moto_server_endpoint_url() as endpoint_url,
576
- s3_make_client(endpoint_url=endpoint_url) as client,
573
+ s3_make_client("testing", "testing", "us-east-1", endpoint_url=endpoint_url) as client,
577
574
  ):
578
575
  client.create_bucket(Bucket="dummy-bucket")
579
576
 
@@ -1,31 +0,0 @@
1
- from contextlib import AbstractContextManager
2
-
3
- import sqlalchemy.orm as sa_orm
4
- from fastapi import HTTPException
5
-
6
- __all__ = [
7
- "managed_db_session",
8
- ]
9
-
10
-
11
- class DBSessionExceptionManager(AbstractContextManager):
12
- def __init__(self, db: sa_orm.Session, commit_on_exit: bool):
13
- self.db = db
14
- self.commit_on_exit = commit_on_exit
15
-
16
- def __enter__(self):
17
- return self
18
-
19
- def __exit__(self, exc_type, exc_value, traceback):
20
- if isinstance(exc_type, HTTPException):
21
- return False # Propagate HTTPException
22
- if exc_type is not None:
23
- self.db.rollback() # Raise a new HTTPException (or any other exception type)
24
- raise HTTPException(status_code=500, detail=str(exc_value)) from exc_value
25
- if self.commit_on_exit:
26
- self.db.commit()
27
- return True
28
-
29
-
30
- def managed_db_session(db: sa_orm.Session, commit_on_exit: bool = True) -> DBSessionExceptionManager:
31
- return DBSessionExceptionManager(db, commit_on_exit)