plexus-python-pulse-common 1.1.84__py3-none-any.whl → 1.1.86__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.
@@ -2,6 +2,7 @@ from contextlib import AbstractContextManager
2
2
 
3
3
  import sqlalchemy.orm as sa_orm
4
4
  from fastapi import HTTPException
5
+ from plexus.common.utils import logger
5
6
 
6
7
  __all__ = [
7
8
  "managed_db_session",
@@ -17,14 +18,29 @@ class DBSessionExceptionManager(AbstractContextManager):
17
18
  return self
18
19
 
19
20
  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
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
28
44
 
29
45
 
30
46
  def managed_db_session(db: sa_orm.Session, commit_on_exit: bool = True) -> DBSessionExceptionManager:
@@ -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.84
3
+ Version: 1.1.86
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.12
6
6
  Classifier: Programming Language :: Python :: 3.13
@@ -9,7 +9,7 @@ plexus/pulse/common/resources/tags/__init__.py,sha256=1BmOq9aIT2Fr4YTzDwTrYySDhi
9
9
  plexus/pulse/common/resources/tags/unittest-1.0.0.tagset.yaml,sha256=8zWjsYp3OxGfBJSiS6-r08q61Jhulol65iV5-pvF1-M,5338
10
10
  plexus/pulse/common/resources/tags/universal-1.0.0.tagset.yaml,sha256=PRxyS5xwWcNwUjAbYqN-SafLZVviE-QSzU-DXyXNffw,80736
11
11
  plexus/pulse/common/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- plexus/pulse/common/utils/apiutils.py,sha256=8H1wuahkPseOjZHQcrT70EUUDTJ-j7c9lw8kx8SoG0Y,1000
12
+ plexus/pulse/common/utils/apiutils.py,sha256=AEGyzteSJrCq13EvQO07xDs3rROMSvG6OaFdj7swFdA,1530
13
13
  plexus/pulse/common/utils/bagutils.py,sha256=JcTD-9xJAhV_BYXw-Zfa_85hkyO5wUVqTqw2eMJFgVI,13116
14
14
  plexus/pulse/common/utils/config.py,sha256=YPtFK5QHTVsXwcFBGV0rlZad0GFtsgwysQNyKYjdzvk,2409
15
15
  plexus/pulse/common/utils/datautils.py,sha256=OWlnyFSa6UaPbSSG3HzCJHxdiHyfHAE-FUVHaLfNZoM,9335
@@ -17,14 +17,14 @@ plexus/pulse/common/utils/dockerutils.py,sha256=7DF9yFGz8uHwpYk115xBWqOjs5etz0UE
17
17
  plexus/pulse/common/utils/flowutils.py,sha256=MGu15KL9mw-LyGGLceVmY-c2FGer5PURnMZWFSJN--I,10182
18
18
  plexus/pulse/common/utils/gisutils.py,sha256=Cx0ZB9-NA_7VuzbuzRArTJ5UfltG-KmvVC8YxPIgPE4,11600
19
19
  plexus/pulse/common/utils/jsonutils.py,sha256=zSbW94klN2jrkMdBECu4RNJX6zzNaSxauQ3FuihuB6E,3342
20
- plexus/pulse/common/utils/ormutils.py,sha256=JD-hKLEGkFWy7ojc3dC_90qtexe2UnLEvD90tEOHtCg,61897
20
+ plexus/pulse/common/utils/ormutils.py,sha256=FPkkdmiTZ1M0ohLTQakA8shO1MD_2mhM6NolpnD4OWo,64472
21
21
  plexus/pulse/common/utils/pathutils.py,sha256=eYfNK48wf1xZGK2IbpKxYOn4skZhWWA8OTyUwDXGMIc,9222
22
22
  plexus/pulse/common/utils/s3utils.py,sha256=5q5wSOavt-n5EdrflQX7WPGgDeWQkGO9lG5Nq3QXjCk,35904
23
23
  plexus/pulse/common/utils/sqlutils.py,sha256=D6kTBjhO5YlNRt3uFlPt6z3uH61m9ajEzPYmsI6NoFc,231
24
24
  plexus/pulse/common/utils/strutils.py,sha256=PkmMHCcHN9vALCy_8fJTf00Dg7I2N4QTCXdBmB3t7rk,16440
25
25
  plexus/pulse/common/utils/tagutils.py,sha256=V8eUxtWSijX6nHSCuRX2dTJz3why3TCOzPVK5YXt28s,66944
26
26
  plexus/pulse/common/utils/testutils.py,sha256=hKPRvoN4mg8QGQJH121l59a0uo8jN5rntGQAVYplGBw,6166
27
- plexus_python_pulse_common-1.1.84.dist-info/METADATA,sha256=lrRb4CQVIl31KxYwj9qX1Y0EVJ-eAQh_tV_hxyTjiQ8,1555
28
- plexus_python_pulse_common-1.1.84.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
29
- plexus_python_pulse_common-1.1.84.dist-info/top_level.txt,sha256=ug_g7CVwaMQuas5UzAXbHUrQvKGCn8ezc6ZNvvRlJOE,7
30
- plexus_python_pulse_common-1.1.84.dist-info/RECORD,,
27
+ plexus_python_pulse_common-1.1.86.dist-info/METADATA,sha256=iUzRtV5lw3poGoHcPb34GNzXZpMNynfVAQYT1cRpI5w,1555
28
+ plexus_python_pulse_common-1.1.86.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
29
+ plexus_python_pulse_common-1.1.86.dist-info/top_level.txt,sha256=ug_g7CVwaMQuas5UzAXbHUrQvKGCn8ezc6ZNvvRlJOE,7
30
+ plexus_python_pulse_common-1.1.86.dist-info/RECORD,,