flask-postgresql-audit 1.0.8__tar.gz → 1.1.0__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 (48) hide show
  1. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/.gitignore +3 -0
  2. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/PKG-INFO +2 -2
  3. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/__init__.py +1 -1
  4. flask_postgresql_audit-1.1.0/flask_postgresql_audit/alembic/__init__.py +7 -0
  5. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/alembic/schema.py +5 -5
  6. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/alembic/setup.py +58 -21
  7. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/base.py +6 -6
  8. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/core.py +9 -10
  9. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/descriptors.py +2 -2
  10. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/extensions/alembic_utils/pg_aggregate.py +2 -2
  11. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/extensions/document_staging/listener.py +10 -10
  12. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/extensions/document_staging/model.py +1 -2
  13. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/models.py +3 -3
  14. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/pyproject.toml +3 -3
  15. flask_postgresql_audit-1.1.0/tests/__init__.py +0 -0
  16. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/tests/app.py +8 -5
  17. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/tests/conftest.py +6 -8
  18. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/tests/migrations/env.py +11 -5
  19. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/tests/test_operators.py +5 -3
  20. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/tests/utils.py +16 -9
  21. flask_postgresql_audit-1.0.8/flask_postgresql_audit/alembic/__init__.py +0 -2
  22. flask_postgresql_audit-1.0.8/flask_postgresql_audit/extensions/__init__.py +0 -1
  23. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/CHANGES.md +0 -0
  24. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/LICENSE +0 -0
  25. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/README.md +0 -0
  26. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/alembic/entities.py +0 -0
  27. {flask_postgresql_audit-1.0.8/tests → flask_postgresql_audit-1.1.0/flask_postgresql_audit/extensions}/__init__.py +0 -0
  28. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/extensions/alembic_utils/__init__.py +0 -0
  29. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/extensions/document_staging/__init__.py +0 -0
  30. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/extensions/document_staging/enum.py +0 -0
  31. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/audit_trigger_delete.sql +0 -0
  32. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/audit_trigger_insert.sql +0 -0
  33. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/audit_trigger_update.sql +0 -0
  34. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/create_activity.sql +0 -0
  35. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/create_schema.sql +0 -0
  36. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/drop_schema.sql +0 -0
  37. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/get_pk_values.sql +0 -0
  38. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/get_setting.sql +0 -0
  39. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/get_table_relid.sql +0 -0
  40. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/jsonb_subtract.sql +0 -0
  41. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/row_audit_trigger.sql +0 -0
  42. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/templates/row_create_activity.sql +0 -0
  43. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/typing.py +0 -0
  44. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/flask_postgresql_audit/utils.py +0 -0
  45. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/tests/migrations/README +0 -0
  46. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/tests/migrations/alembic.ini +0 -0
  47. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/tests/migrations/script.py.mako +0 -0
  48. {flask_postgresql_audit-1.0.8 → flask_postgresql_audit-1.1.0}/tests/test_defaults.py +0 -0
@@ -63,3 +63,6 @@ uv.lock
63
63
 
64
64
  # VSCode
65
65
  .vscode
66
+
67
+ # Agents
68
+ .agents
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flask-postgresql_audit
3
- Version: 1.0.8
3
+ Version: 1.1.0
4
4
  Summary: Versioning and auditing extension for PostgreSQL and SQLAlchemy.
5
5
  Project-URL: Homepage, https://github.com/higamigu/flask-postgresql-audit
6
6
  Author-email: Higamigu <higamigu@gmail.com>, Konsta Vesterinen <konsta@fastmonkeys.com>
@@ -19,7 +19,7 @@ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Requires-Python: >=3.11
21
21
  Requires-Dist: alembic-utils>=0.8.8
22
- Requires-Dist: alembic>=1.17.0
22
+ Requires-Dist: alembic>=1.18.0
23
23
  Requires-Dist: flask-sqlalchemy>=3.1.1
24
24
  Requires-Dist: flask>=3.1.2
25
25
  Requires-Dist: psycopg2>=2.9.11
@@ -1,4 +1,4 @@
1
1
  # ruff: noqa: F401
2
2
  from .core import Audit, PostgreSQLAudit
3
3
 
4
- __version__ = "1.0.8"
4
+ __version__ = "1.1.0"
@@ -0,0 +1,7 @@
1
+ # ruff: noqa: F401
2
+ from .setup import (
3
+ chain_revision_directives,
4
+ register_triggers,
5
+ reorder_migration_ops,
6
+ setup_db,
7
+ )
@@ -32,16 +32,16 @@ class SchemaDrop(SchemaOperation):
32
32
 
33
33
  @Operations.implementation_for(SchemaCreate)
34
34
  def create_schema(operations: Operations, operation: SchemaCreate):
35
- stmt = """
36
- CREATE SCHEMA {name};
37
- REVOKE ALL ON SCHEMA {name} FROM public;
38
- """.format(name=operation.schema_name)
35
+ stmt = f"""
36
+ CREATE SCHEMA {operation.schema_name};
37
+ REVOKE ALL ON SCHEMA {operation.schema_name} FROM public;
38
+ """
39
39
  operations.execute(text(stmt))
40
40
 
41
41
 
42
42
  @Operations.implementation_for(SchemaDrop)
43
43
  def drop_schema(operations: Operations, operation: SchemaDrop):
44
- stmt = "DROP SCHEMA {name} CASCADE;".format(name=operation.schema_name)
44
+ stmt = f"DROP SCHEMA {operation.schema_name} CASCADE;"
45
45
  operations.execute(text(stmt))
46
46
 
47
47
 
@@ -1,9 +1,11 @@
1
1
  import logging
2
- from typing import TYPE_CHECKING, Optional, Set, Union
2
+ from typing import TYPE_CHECKING
3
3
 
4
4
  from alembic.autogenerate import comparators
5
5
  from alembic.autogenerate.api import AutogenContext
6
6
  from alembic.operations import ops
7
+ from alembic.operations.ops import MigrationScript
8
+ from alembic.util import DispatchPriority
7
9
  from alembic_utils.replaceable_entity import register_entities, registry
8
10
  from alembic_utils.reversible_op import CreateOp
9
11
  from sqlalchemy import Connection, text
@@ -26,38 +28,73 @@ def setup_db(audit: "PostgreSQLAudit"):
26
28
  register_triggers(audit)
27
29
  register_entities(audit.pg_audit_entities)
28
30
 
31
+ @comparators.dispatch_for("schema", priority=DispatchPriority.FIRST)
29
32
  def compare_audit_schema(
30
33
  autogen_context: AutogenContext,
31
34
  upgrade_ops: ops.UpgradeOps,
32
- schemas: Union[Set[None], Set[Optional[str]]],
35
+ schemas: set[None] | set[str | None],
33
36
  ) -> None:
34
- idx = 0
35
37
  if connection := autogen_context.connection:
36
38
  check_schema = """
37
39
  SELECT TRUE FROM information_schema.schemata
38
40
  WHERE schema_name = '{name}'
39
41
  """.format(name=audit.context["schema_name"])
40
42
  if not connection.scalar(text(check_schema)):
41
- upgrade_ops.ops.insert(idx, SchemaCreate(audit.context["schema_name"]))
42
- idx += 1
43
+ upgrade_ops.ops.append(SchemaCreate(audit.context["schema_name"]))
43
44
 
44
45
  for ent in audit.pg_audit_entities:
45
46
  if op := get_blind_migration_op(ent, connection):
46
- registry._entities.pop(ent.identity)
47
- if ent.signature == "btree_gist":
48
- upgrade_ops.ops.insert(idx, op)
49
- else:
50
- upgrade_ops.ops.append(op)
51
-
52
- for idx, comp_fn in enumerate(comparators._registry.get(("schema", "default"), [])):
53
- # Insert pg_audit comparators before alembic_utils comparators
54
- # to override alembic_utils migration caveats when entity
55
- # depend on new object in same migration
56
- if comp_fn.__module__ == "alembic_utils.replaceable_entity":
57
- comparators._registry.setdefault(("schema", "default"), []).insert(
58
- idx, compare_audit_schema
59
- )
60
- break
47
+ registry._entities.pop(ent.identity, None)
48
+ upgrade_ops.ops.append(op)
49
+
50
+
51
+ def reorder_migration_ops(context, revision, directives: list[MigrationScript]):
52
+ """
53
+ Alembic directive listener that reorders upgrade operations into correct
54
+ dependency order:
55
+ 1. Schema creation & btree_gist extension
56
+ 2. Tables, indexes, foreign keys, etc.
57
+ 3. Triggers & Replaceable audit entities
58
+ """
59
+ for directive in directives:
60
+ if not hasattr(directive, "upgrade_ops") or directive.upgrade_ops is None:
61
+ continue
62
+
63
+ upgrade_ops = directive.upgrade_ops.ops
64
+
65
+ schema_ops = []
66
+ extension_ops = []
67
+ trigger_ops = []
68
+ other_ops = []
69
+
70
+ for op in upgrade_ops:
71
+ if isinstance(op, SchemaCreate):
72
+ schema_ops.append(op)
73
+ elif (
74
+ isinstance(op, CreateOp)
75
+ and getattr(op.target, "signature", None) == "btree_gist"
76
+ ):
77
+ extension_ops.append(op)
78
+ elif isinstance(op, CreateOp):
79
+ trigger_ops.append(op)
80
+ else:
81
+ other_ops.append(op)
82
+
83
+ # Reassemble the ops sequence in strict dependency order
84
+ directive.upgrade_ops.ops = schema_ops + extension_ops + other_ops + trigger_ops
85
+
86
+
87
+ def chain_revision_directives(*callbacks):
88
+ """
89
+ Chain multiple process_revision_directives callbacks together into a single
90
+ listener for Alembic's context.configure().
91
+ """
92
+
93
+ def wrapper(context, revision, directives: list[MigrationScript]):
94
+ for cb in callbacks:
95
+ cb(context, revision, directives)
96
+
97
+ return wrapper
61
98
 
62
99
 
63
100
  def register_core_entities(audit: "PostgreSQLAudit"):
@@ -86,7 +123,7 @@ def register_triggers(audit: "PostgreSQLAudit"):
86
123
 
87
124
  def get_blind_migration_op(entity: "ReplaceableEntity", connection: Connection):
88
125
  session = Session(bind=connection)
89
- db_ents: list["ReplaceableEntity"] = entity.from_database(session, entity.schema)
126
+ db_ents: list[ReplaceableEntity] = entity.from_database(session, entity.schema)
90
127
 
91
128
  for db_ent in db_ents:
92
129
  if entity.identity == db_ent.identity:
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any
3
3
 
4
4
  from sqlalchemy import BigInteger, Text, func, text
5
5
  from sqlalchemy.dialects.postgresql import INET, JSONB
6
- from sqlalchemy.orm import Mapped, mapped_column
6
+ from sqlalchemy.orm import Mapped, declared_attr, mapped_column
7
7
 
8
8
  from .typing import OMap
9
9
 
@@ -27,14 +27,14 @@ class ActivityBase:
27
27
  changed_data: OMap[dict] = mapped_column(JSONB, default={}, server_default="{}")
28
28
 
29
29
  def __repr__(self):
30
- return "<{cls} table_name={table_name!r} id={id!r}>".format(
31
- cls=self.__class__.__name__, table_name=self.table_name, id=self.id
30
+ return (
31
+ f"<{self.__class__.__name__} table_name={self.table_name!r} id={self.id!r}>"
32
32
  )
33
33
 
34
34
 
35
35
  class TransactionBase:
36
36
  if TYPE_CHECKING:
37
- actor_id: OMap[Any]
37
+ actor_id: declared_attr[Any]
38
38
 
39
39
  id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
40
40
  native_transaction_id: OMap[int] = mapped_column(BigInteger)
@@ -47,6 +47,6 @@ class TransactionBase:
47
47
  return func.tsrange(cls.issued_at - interval, cls.issued_at)
48
48
 
49
49
  def __repr__(self):
50
- return "<{cls} id={id!r} issued_at={issued_at!r}>".format(
51
- cls=self.__class__.__name__, id=self.id, issued_at=self.issued_at
50
+ return (
51
+ f"<{self.__class__.__name__} id={self.id!r} issued_at={self.issued_at!r}>"
52
52
  )
@@ -4,11 +4,10 @@ from contextlib import contextmanager
4
4
  from itertools import groupby
5
5
 
6
6
  import sqlalchemy as sa
7
- import sqlalchemy.event as event
8
- import sqlalchemy.orm as orm
9
7
  from alembic_utils.replaceable_entity import ReplaceableEntity
10
8
  from flask import Flask
11
9
  from flask_sqlalchemy import SQLAlchemy
10
+ from sqlalchemy import event, orm
12
11
  from sqlalchemy.dialects.postgresql import insert as pg_insert
13
12
  from sqlalchemy.util import OrderedSet
14
13
 
@@ -26,7 +25,7 @@ class ImproperlyConfigured(Exception):
26
25
 
27
26
 
28
27
  class Audit:
29
- __audit_args__: dict = {}
28
+ __audit_args__: t.ClassVar[dict] = {}
30
29
  __audit_schema__: str = ""
31
30
 
32
31
  __audit_relid__: orm.Mapped[int]
@@ -72,7 +71,7 @@ def get_modified_columns(obj: orm.DeclarativeBase):
72
71
  modified: set[sa.ColumnElement[t.Any]] = set()
73
72
  mapper = sa.inspect(obj.__class__)
74
73
  for key, attr in sa.inspect(obj).attrs.items():
75
- if key in mapper.synonyms.keys():
74
+ if key in mapper.synonyms:
76
75
  continue
77
76
  if not attr.history.has_changes():
78
77
  continue
@@ -148,10 +147,10 @@ class PostgreSQLAudit:
148
147
  def __init__(
149
148
  self,
150
149
  *,
151
- actor_cls: t.Optional[str] = None,
150
+ actor_cls: str | None = None,
152
151
  actor_id_getter: t.Callable[[], t.Any] = _default_actor_id,
153
152
  client_address_getter: t.Callable[[], t.Any] = _default_client_addr,
154
- schema_name: t.Optional[str] = None,
153
+ schema_name: str | None = None,
155
154
  **kw,
156
155
  ):
157
156
  self._actor_cls = actor_cls
@@ -164,7 +163,7 @@ class PostgreSQLAudit:
164
163
 
165
164
  @property
166
165
  def context(self):
167
- ctx = dict(schema_name=self.schema_name or "public")
166
+ ctx = {"schema_name": self.schema_name or "public"}
168
167
  ctx["schema_prefix"] = f"{ctx['schema_name']}."
169
168
  ctx["revoke_cmd"] = f"REVOKE ALL ON {ctx['schema_prefix']}activity FROM public;"
170
169
  if "jsonb_subtract_verbose" in self.options:
@@ -262,7 +261,7 @@ class PostgreSQLAudit:
262
261
  self.pg_audit_enabled = True
263
262
  self.pg_audit_entities = OrderedSet()
264
263
 
265
- self.Base: type["orm.DeclarativeBase"] = db.Model # type: ignore
264
+ self.Base: type[orm.DeclarativeBase] = db.Model # type: ignore
266
265
 
267
266
  self.Transaction = transaction_model_factory(
268
267
  self.Base,
@@ -300,7 +299,7 @@ class PostgreSQLAudit:
300
299
  self.Activity.changed_data,
301
300
  )
302
301
  .select_from(self.Activity)
303
- .join(self.Transaction)
302
+ .outerjoin(self.Transaction)
304
303
  .order_by(self.Activity.id.desc())
305
304
  )
306
305
 
@@ -309,7 +308,7 @@ class PostgreSQLAudit:
309
308
  stmt = stmt.filter(self.Activity.table_name == class_.__tablename__)
310
309
 
311
310
  else:
312
- objects = list(obj) if isinstance(obj, t.Sequence) else list([obj])
311
+ objects = list(obj) if isinstance(obj, t.Sequence) else [obj]
313
312
  conditions = []
314
313
  for class_, objs in groupby(objects, key=lambda o: o.__class__):
315
314
  conditions.append(
@@ -13,7 +13,7 @@ class hybridmethod(t.Generic[_P, _R]):
13
13
  def __init__(
14
14
  self,
15
15
  inst_func: t.Callable[t.Concatenate[t.Any, _P], _R],
16
- cls_func: t.Optional[t.Callable[t.Concatenate[t.Any, _P], _R]] = None,
16
+ cls_func: t.Callable[t.Concatenate[t.Any, _P], _R] | None = None,
17
17
  ):
18
18
  self.inst_func = inst_func
19
19
  if cls_func is not None:
@@ -22,7 +22,7 @@ class hybridmethod(t.Generic[_P, _R]):
22
22
  self.classmethod(inst_func)
23
23
 
24
24
  def __get__(
25
- self, instance: t.Optional[object], owner: t.Type[object]
25
+ self, instance: object | None, owner: type[object]
26
26
  ) -> t.Callable[t.Concatenate[t.Any, _P], _R]:
27
27
  if instance is None:
28
28
  return self.cls_func.__get__(owner, owner)
@@ -49,7 +49,7 @@ class PGAggregate(ReplaceableEntity):
49
49
  )
50
50
 
51
51
  def to_sql_statement_create_or_replace(self):
52
- return self.to_sql_statement_create(replace=True)
52
+ yield self.to_sql_statement_create(replace=True)
53
53
 
54
54
  def to_sql_statement_drop(self, cascade=False):
55
55
  return sa.text(
@@ -58,7 +58,7 @@ class PGAggregate(ReplaceableEntity):
58
58
  )
59
59
 
60
60
  @classmethod
61
- def from_database(cls, sess: Session, schema: str) -> "list[PGAggregate]":
61
+ def from_database(cls, sess: Session, schema: str = "%") -> "list[PGAggregate]":
62
62
  PG_GTE_11 = """
63
63
  and p.prokind = 'a'
64
64
  """
@@ -1,9 +1,8 @@
1
1
  import typing as t
2
- from datetime import datetime
2
+ from datetime import UTC, datetime
3
3
 
4
- import sqlalchemy.event as event
5
- import sqlalchemy.orm as orm
6
4
  from flask_sqlalchemy.session import Session
5
+ from sqlalchemy import event, orm
7
6
 
8
7
  from flask_postgresql_audit.core import _default_actor_id
9
8
 
@@ -18,18 +17,19 @@ def attach_listener(actor_id_getter: t.Callable[[], t.Any] = _default_actor_id):
18
17
  def __receive_before_flush__(
19
18
  session: Session,
20
19
  flush_context: orm.UOWTransaction,
21
- instances: t.Optional[t.Sequence[_O]],
20
+ instances: t.Sequence[_O] | None,
22
21
  ):
23
22
  for obj in session.new:
24
- if isinstance(obj, DocumentStaging):
25
- if obj.docstatus == Docstatus.DRAFT or obj.docstatus is None:
26
- obj.created_by = actor_id_getter()
27
- obj.created_on = datetime.now()
23
+ if isinstance(obj, DocumentStaging) and (
24
+ obj.docstatus == Docstatus.DRAFT or obj.docstatus is None
25
+ ):
26
+ obj.created_by = actor_id_getter()
27
+ obj.created_on = datetime.now(UTC)
28
28
  for obj in session.dirty:
29
29
  if isinstance(obj, DocumentStaging):
30
30
  if obj.docstatus == Docstatus.SUBMITTED:
31
31
  obj.submitted_by = actor_id_getter()
32
- obj.submitted_on = datetime.now()
32
+ obj.submitted_on = datetime.now(UTC)
33
33
  if obj.docstatus == Docstatus.CANCELLED:
34
34
  obj.cancelled_by = actor_id_getter()
35
- obj.cancelled_on = datetime.now()
35
+ obj.cancelled_on = datetime.now(UTC)
@@ -2,9 +2,8 @@ import typing as t
2
2
  from datetime import datetime
3
3
 
4
4
  import sqlalchemy as sa
5
- import sqlalchemy.event as event
6
- import sqlalchemy.orm as orm
7
5
  from flask_sqlalchemy.session import Session
6
+ from sqlalchemy import event, orm
8
7
  from sqlalchemy.dialects.postgresql import ENUM as PGEnum
9
8
 
10
9
  from flask_postgresql_audit.typing import OMap
@@ -28,7 +28,7 @@ def activity_model_factory(
28
28
  ):
29
29
  class PGAuditActivity(base_cls, activity_base):
30
30
  __tablename__ = "pga_activity"
31
- __table_args__ = {"schema": schema_name}
31
+ __table_args__: t.ClassVar[dict[str, t.Any]] = {"schema": schema_name}
32
32
 
33
33
  transaction_id = mapped_column(sa.BigInteger, sa.ForeignKey(transaction_cls.id))
34
34
  transaction = relationship(transaction_cls, backref="activities")
@@ -44,7 +44,7 @@ def transaction_model_factory(
44
44
  **kwargs,
45
45
  ):
46
46
  if actor_cls:
47
- actor_mapper: "Mapper[DeclarativeBase]" = sa.inspect(actor_cls)
47
+ actor_mapper: Mapper[DeclarativeBase] = sa.inspect(actor_cls)
48
48
  if len(actor_mapper.primary_key) != 1:
49
49
  raise PGAuditFactoryError(
50
50
  "PGAudit does not support actor class"
@@ -59,7 +59,7 @@ def transaction_model_factory(
59
59
  @declared_attr
60
60
  def actor_id(cls) -> OMap[t.Any]:
61
61
  if actor_cls:
62
- return mapped_column(actor_pk.type, ForeignKey(actor_pk))
62
+ return mapped_column(actor_pk.type, ForeignKey(actor_pk)) # type: ignore
63
63
  return mapped_column(sa.Text)
64
64
 
65
65
  if actor_cls:
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Topic :: Software Development :: Libraries :: Python Modules",
26
26
  ]
27
27
  dependencies = [
28
- "alembic>=1.17.0",
28
+ "alembic>=1.18.0",
29
29
  "alembic-utils>=0.8.8",
30
30
  "flask>=3.1.2",
31
31
  "flask-sqlalchemy>=3.1.1",
@@ -38,9 +38,9 @@ Homepage = "https://github.com/higamigu/flask-postgresql-audit"
38
38
  [dependency-groups]
39
39
  dev = [
40
40
  "pytest>=8.4.2",
41
- "ruff>=0.14.1",
41
+ "ruff>=0.16.1",
42
42
  "setuptools>=80.9.0",
43
- "tox>=4.31.0"
43
+ "tox>=4.31.0",
44
44
  ]
45
45
 
46
46
  [tool.hatch.version]
File without changes
@@ -10,18 +10,21 @@ from flask_postgresql_audit import Audit, PostgreSQLAudit
10
10
 
11
11
 
12
12
  class DefaultConfig:
13
- DB_USER = os.environ.get("POSTGRESQL_AUDIT_TEST_USER", "postgres")
14
- DB_PASSWORD = os.environ.get("POSTGRESQL_AUDIT_TEST_PASSWORD", "")
15
- DB_NAME = os.environ.get("POSTGRESQL_AUDIT_TEST_DB", "postgresql_audit_test")
13
+ DB_USER = os.environ.get("POSTGRES_USER", "postgres")
14
+ DB_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
15
+ DB_NAME = os.environ.get("POSTGRES_DB", "pg_audit_test")
16
+ DB_PORT = os.environ.get("POSTGRES_PORT", "5432")
16
17
 
17
18
  SQLALCHEMY_DATABASE_URI = (
18
- f"postgresql://{DB_USER}:{DB_PASSWORD}@localhost/{DB_NAME}"
19
+ f"postgresql://{DB_USER}:{DB_PASSWORD}@localhost:{DB_PORT}/{DB_NAME}"
19
20
  )
20
21
  SQLALCHEMY_ECHO = False
21
22
 
23
+ PGA_SCHEMA_NAME = os.environ.get("SCHEMA_NAME", None)
24
+
22
25
 
23
26
  db = SQLAlchemy()
24
- audit = PostgreSQLAudit()
27
+ audit = PostgreSQLAudit(schema_name=DefaultConfig.PGA_SCHEMA_NAME)
25
28
 
26
29
 
27
30
  class BaseModel(db.Model):
@@ -10,18 +10,16 @@ ALEMBIC_CONFIG = REPO_ROOT / "tests/migrations"
10
10
  @pytest.fixture(scope="session")
11
11
  def test_client():
12
12
  test_client = app.test_client()
13
- with app.app_context():
14
- with alembic_migration(db, ALEMBIC_CONFIG):
15
- yield test_client
13
+ with app.app_context(), alembic_migration(db, ALEMBIC_CONFIG):
14
+ yield test_client
16
15
 
17
16
 
18
17
  @pytest.fixture(autouse=True)
19
18
  def join_transaction_mode(test_client):
20
- with db.engine.connect() as connection:
21
- with test_session(connection) as session:
22
- db.session = session
23
- yield
24
- db.session.close()
19
+ with db.engine.connect() as connection, test_session(connection) as session:
20
+ db.session = session
21
+ yield
22
+ db.session.close()
25
23
 
26
24
 
27
25
  @pytest.fixture
@@ -4,6 +4,10 @@ from alembic import context
4
4
  from alembic.operations.ops import MigrationScript
5
5
  from sqlalchemy import engine_from_config, pool
6
6
 
7
+ from flask_postgresql_audit.alembic import (
8
+ chain_revision_directives,
9
+ reorder_migration_ops,
10
+ )
7
11
  from tests.app import db
8
12
 
9
13
  # this is the Alembic Config object, which provides
@@ -30,10 +34,9 @@ target_metadata = db.metadata
30
34
  def process_revision_directives(context, revision, directives: list[MigrationScript]):
31
35
  if getattr(config.cmd_opts, "autogenerate", False):
32
36
  script = directives[0]
33
- if upgrade_ops := script.upgrade_ops:
34
- if upgrade_ops.is_empty():
35
- directives[:] = []
36
- print("No changes in schema detected.")
37
+ if (upgrade_ops := script.upgrade_ops) and upgrade_ops.is_empty():
38
+ directives[:] = []
39
+ print("No changes in schema detected.")
37
40
 
38
41
 
39
42
  def run_migrations_online() -> None:
@@ -54,7 +57,10 @@ def run_migrations_online() -> None:
54
57
  connection=connection,
55
58
  target_metadata=target_metadata,
56
59
  include_schemas=True,
57
- process_revision_directives=process_revision_directives,
60
+ process_revision_directives=chain_revision_directives(
61
+ reorder_migration_ops,
62
+ process_revision_directives,
63
+ ),
58
64
  )
59
65
 
60
66
  with context.begin_transaction():
@@ -3,7 +3,9 @@ import json
3
3
  import pytest
4
4
  from sqlalchemy import text
5
5
 
6
- from .app import db
6
+ from .app import audit, db
7
+
8
+ JSONB_SUBTRACT = "SELECT {schema}jsonb_subtract((:new)::jsonb, (:old)::jsonb)"
7
9
 
8
10
 
9
11
  @pytest.mark.parametrize(
@@ -24,8 +26,8 @@ from .app import db
24
26
  def test_jsonb_subtract(test_client, old, new, result):
25
27
  assert (
26
28
  db.session.scalar(
27
- text("SELECT jsonb_subtract((:new)::jsonb, (:old)::jsonb)"),
28
- dict(old=json.dumps(old), new=json.dumps(new)),
29
+ text(JSONB_SUBTRACT.format(schema=audit.context["schema_prefix"])),
30
+ {"old": json.dumps(old), "new": json.dumps(new)},
29
31
  )
30
32
  == result
31
33
  )
@@ -52,30 +52,37 @@ def alembic_config(cmd: str, engine: Engine, alembic_cfg_path: Path, **kw):
52
52
 
53
53
  def revision(engine: Engine, alembic_cfg_path: Path, **kw):
54
54
  stdout = StringIO()
55
- with alembic_config("revision", engine, alembic_cfg_path, **kw) as cfg:
56
- with contextlib.redirect_stdout(stdout):
57
- command.revision(cfg, **kw)
55
+ with (
56
+ alembic_config("revision", engine, alembic_cfg_path, **kw) as cfg,
57
+ contextlib.redirect_stdout(stdout),
58
+ ):
59
+ command.revision(cfg, **kw)
58
60
  return stdout.getvalue()
59
61
 
60
62
 
61
63
  def upgrade(engine: Engine, alembic_cfg_path: Path, **kw):
62
64
  stdout = StringIO()
63
- with alembic_config("upgrade", engine, alembic_cfg_path, **kw) as cfg:
64
- with contextlib.redirect_stdout(stdout):
65
- command.upgrade(cfg, **kw)
65
+ with (
66
+ alembic_config("upgrade", engine, alembic_cfg_path, **kw) as cfg,
67
+ contextlib.redirect_stdout(stdout),
68
+ ):
69
+ command.upgrade(cfg, **kw)
66
70
  return stdout.getvalue()
67
71
 
68
72
 
69
73
  def downgrade(engine: Engine, alembic_cfg_path: Path, **kw):
70
74
  stdout = StringIO()
71
- with alembic_config("downgrade", engine, alembic_cfg_path, **kw) as cfg:
72
- with contextlib.redirect_stdout(stdout):
73
- command.upgrade(cfg, **kw)
75
+ with (
76
+ alembic_config("downgrade", engine, alembic_cfg_path, **kw) as cfg,
77
+ contextlib.redirect_stdout(stdout),
78
+ ):
79
+ command.upgrade(cfg, **kw)
74
80
  return stdout.getvalue()
75
81
 
76
82
 
77
83
  def clear_migrations(engine: Engine, alembic_cfg_path: Path, **kw):
78
84
  with engine.begin() as connection:
85
+ connection.execute(text("DROP EXTENSION IF EXISTS btree_gist CASCADE;"))
79
86
  connection.execute(text("DROP SCHEMA IF EXISTS public CASCADE;"))
80
87
  connection.execute(text("DROP SCHEMA IF EXISTS audit CASCADE;"))
81
88
  connection.execute(text("CREATE SCHEMA public;"))
@@ -1,2 +0,0 @@
1
- # ruff: noqa: F401
2
- from .setup import register_triggers, setup_db