py-app-runner 0.5.49.dev0__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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from py_app_runner.registry import AppRegistry
|
|
4
|
+
|
|
5
|
+
__all__ = ["AppRegistry"]
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("py_app_runner")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
# Running straight from a source tree that was never installed
|
|
11
|
+
__version__ = "0.0.dev0"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""An append-only trail of who changed what, written explicitly at the call site."""
|
|
2
|
+
|
|
3
|
+
from py_app_runner.audit.audit import Audit
|
|
4
|
+
from py_app_runner.audit.errors import AuditError
|
|
5
|
+
from py_app_runner.audit.event import (
|
|
6
|
+
CREATED,
|
|
7
|
+
DELETED,
|
|
8
|
+
UPDATED,
|
|
9
|
+
Actor,
|
|
10
|
+
AuditEvent,
|
|
11
|
+
RequestContext,
|
|
12
|
+
current_context,
|
|
13
|
+
new_request_id,
|
|
14
|
+
request_context,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CREATED",
|
|
19
|
+
"DELETED",
|
|
20
|
+
"UPDATED",
|
|
21
|
+
"Actor",
|
|
22
|
+
"Audit",
|
|
23
|
+
"AuditError",
|
|
24
|
+
"AuditEvent",
|
|
25
|
+
"RequestContext",
|
|
26
|
+
"current_context",
|
|
27
|
+
"new_request_id",
|
|
28
|
+
"request_context",
|
|
29
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import pathlib
|
|
4
|
+
from argparse import Namespace
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
import psycopg
|
|
8
|
+
|
|
9
|
+
from py_app_runner.audit.commands import Out, cmd_install, cmd_prune
|
|
10
|
+
from py_app_runner.audit.errors import AuditError
|
|
11
|
+
from py_app_runner.migrations._service import connect_kwargs, resolve_targets
|
|
12
|
+
from py_app_runner.pybridge import PyBridge
|
|
13
|
+
from py_app_runner.registry import AppRegistry
|
|
14
|
+
|
|
15
|
+
_DEFAULT_DB = "main"
|
|
16
|
+
_DEFAULT_TABLE = "audit_log"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _migrations_dir(args: Namespace, config: dict) -> pathlib.Path:
|
|
20
|
+
"""Where `install` writes.
|
|
21
|
+
|
|
22
|
+
The file it produces is a migration and belongs wherever the rest of them are, so the
|
|
23
|
+
default comes from the migrations config rather than from an audit key of its own.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
raw = getattr(args, "dir", None)
|
|
27
|
+
if raw:
|
|
28
|
+
directory = pathlib.Path(raw)
|
|
29
|
+
if directory.is_absolute():
|
|
30
|
+
return directory
|
|
31
|
+
|
|
32
|
+
return pathlib.Path(config.get("current_path") or ".") / directory
|
|
33
|
+
|
|
34
|
+
targets = resolve_targets(config)
|
|
35
|
+
return next(iter(targets.values())).directory
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def init_service(args: Namespace, _pybridge: PyBridge, logger: logging.Logger) -> None:
|
|
39
|
+
out: Out = print
|
|
40
|
+
|
|
41
|
+
# Same reasoning as migrations: runner.py catches Exception around init_service and
|
|
42
|
+
# returns normally, which exits 0. A prune that could not reach the database must not
|
|
43
|
+
# report success to a retention job.
|
|
44
|
+
code = 1
|
|
45
|
+
try:
|
|
46
|
+
config = AppRegistry.config()
|
|
47
|
+
settings = config.get("audit") or {}
|
|
48
|
+
table = getattr(args, "table", None) or settings.get("table") or _DEFAULT_TABLE
|
|
49
|
+
|
|
50
|
+
if args.step == "install":
|
|
51
|
+
raise SystemExit(cmd_install(_migrations_dir(args, config), table, datetime.now(UTC), out))
|
|
52
|
+
|
|
53
|
+
db_name = getattr(args, "db", None) or settings.get("db") or _DEFAULT_DB
|
|
54
|
+
db_config = config.get("db") or {}
|
|
55
|
+
if db_name not in db_config:
|
|
56
|
+
out(
|
|
57
|
+
f'error: no database {db_name!r} in config["db"]; '
|
|
58
|
+
f"configured are: {', '.join(sorted(db_config)) or 'none'}."
|
|
59
|
+
)
|
|
60
|
+
raise SystemExit(2)
|
|
61
|
+
|
|
62
|
+
if args.step == "prune":
|
|
63
|
+
async with await psycopg.AsyncConnection.connect(**connect_kwargs(db_config[db_name])) as conn:
|
|
64
|
+
code = await cmd_prune(
|
|
65
|
+
conn,
|
|
66
|
+
table,
|
|
67
|
+
args.before,
|
|
68
|
+
args.batch,
|
|
69
|
+
getattr(args, "dry_run", False),
|
|
70
|
+
out,
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
out(f"error: unknown audit command {args.step!r}")
|
|
74
|
+
code = 1
|
|
75
|
+
|
|
76
|
+
except AuditError as e:
|
|
77
|
+
# Configuration and refusal messages already say what to do; a stack trace above
|
|
78
|
+
# them would bury it.
|
|
79
|
+
out(f"error: {e}")
|
|
80
|
+
raise SystemExit(1) from None
|
|
81
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
82
|
+
# Above `except Exception` because both derive from BaseException. An interrupted
|
|
83
|
+
# prune has deleted some batches and not others - safe to resume, but it must not
|
|
84
|
+
# be reported as complete.
|
|
85
|
+
logger.error("audit: interrupted; the prune is partially done")
|
|
86
|
+
raise SystemExit(1) from None
|
|
87
|
+
except Exception:
|
|
88
|
+
logger.exception("audit: unhandled failure")
|
|
89
|
+
raise SystemExit(1) from None
|
|
90
|
+
|
|
91
|
+
raise SystemExit(code)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CLI subparsers for the built-in audit service.
|
|
2
|
+
|
|
3
|
+
python3 src/app.py audit install [--dir PATH] [--table NAME]
|
|
4
|
+
python3 src/app.py audit prune --before YYYY-MM-DD [--batch N] [--dry-run] [--db NAME]
|
|
5
|
+
|
|
6
|
+
`--dry-run` shadows a real top-level flag on runner.py's parser and needs
|
|
7
|
+
`default=SUPPRESS`, the same collision migrations' `apply` has.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from argparse import SUPPRESS, ArgumentParser, _SubParsersAction # type: ignore
|
|
12
|
+
|
|
13
|
+
from py_app_runner.pybridge import PyBridge
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def reg_subparsers(
|
|
17
|
+
subparsers: "_SubParsersAction[ArgumentParser]",
|
|
18
|
+
_pybridge: PyBridge,
|
|
19
|
+
_base_logger: logging.Logger,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Command line subparsers"""
|
|
22
|
+
|
|
23
|
+
parser = subparsers.add_parser(
|
|
24
|
+
"audit",
|
|
25
|
+
description="Install the audit trail schema and prune old rows",
|
|
26
|
+
help="Audit trail",
|
|
27
|
+
)
|
|
28
|
+
group = parser.add_subparsers(title="command", dest="step", required=True)
|
|
29
|
+
|
|
30
|
+
install_parser = group.add_parser("install", help="Write the audit schema into the migrations directory")
|
|
31
|
+
install_parser.add_argument("--dir", default=None, help="Migrations directory to write into")
|
|
32
|
+
install_parser.add_argument("--table", default=None, help="Audit table (default: from config)")
|
|
33
|
+
|
|
34
|
+
prune_parser = group.add_parser("prune", help="Delete trail rows older than a date")
|
|
35
|
+
prune_parser.add_argument("--before", required=True, help="Delete rows older than this, YYYY-MM-DD")
|
|
36
|
+
prune_parser.add_argument("--batch", type=int, default=10000, help="Rows per statement (default: 10000)")
|
|
37
|
+
prune_parser.add_argument("--table", default=None, help="Audit table (default: from config)")
|
|
38
|
+
prune_parser.add_argument("--db", default=None, help='Entry of config["db"] to prune in (default: main)')
|
|
39
|
+
prune_parser.add_argument(
|
|
40
|
+
"--dry-run",
|
|
41
|
+
action="store_true",
|
|
42
|
+
default=SUPPRESS,
|
|
43
|
+
help="Count what would go, delete nothing",
|
|
44
|
+
)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""The call-site API.
|
|
2
|
+
|
|
3
|
+
Every audit row is the result of an explicit call. There is no ORM hook, no middleware and
|
|
4
|
+
no database trigger, because a trail that writes itself is one nobody can reason about at
|
|
5
|
+
the call site - and because a hook that does not fire produces a silent gap rather than a
|
|
6
|
+
loud error.
|
|
7
|
+
|
|
8
|
+
await audit.insert(cur, "people", {"name": "Anna"}, module="hr")
|
|
9
|
+
await audit.update(cur, "people", {"status": "left"}, {"id": 42}, module="hr")
|
|
10
|
+
|
|
11
|
+
`update` and `delete` read the affected rows before writing, so old values are recorded
|
|
12
|
+
without hand-written before-fetches at every call site.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import psycopg
|
|
19
|
+
from psycopg import sql
|
|
20
|
+
|
|
21
|
+
from py_app_runner.audit import diff
|
|
22
|
+
from py_app_runner.audit.errors import AuditError
|
|
23
|
+
from py_app_runner.audit.event import CREATED, DELETED, UPDATED, AuditEvent, current_context
|
|
24
|
+
from py_app_runner.audit.store import Store, assert_table_name, qualified
|
|
25
|
+
|
|
26
|
+
_logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_DEFAULTS: dict[str, Any] = {
|
|
29
|
+
"table": "audit_log",
|
|
30
|
+
"strict": True,
|
|
31
|
+
"max_rows": 1000,
|
|
32
|
+
"id_key": "id",
|
|
33
|
+
"exclude": {},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Audit:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
table: str = "audit_log",
|
|
41
|
+
strict: bool = True,
|
|
42
|
+
max_rows: int = 1000,
|
|
43
|
+
id_key: str = "id",
|
|
44
|
+
exclude: dict[str, list[str]] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.store = Store(table)
|
|
47
|
+
self.strict = strict
|
|
48
|
+
self.max_rows = max_rows
|
|
49
|
+
self.id_key = id_key
|
|
50
|
+
self.exclude = self._validated_exclude(exclude or {})
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_config(cls, config: dict[str, Any]) -> "Audit":
|
|
54
|
+
settings = {**_DEFAULTS, **(config.get("audit") or {})}
|
|
55
|
+
|
|
56
|
+
max_rows = settings["max_rows"]
|
|
57
|
+
if not isinstance(max_rows, int) or isinstance(max_rows, bool):
|
|
58
|
+
raise AuditError(
|
|
59
|
+
f'config["audit"]["max_rows"] must be an int; got {max_rows!r}. '
|
|
60
|
+
f"A string here would compare against a row count and silently never match."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return cls(
|
|
64
|
+
table=settings["table"],
|
|
65
|
+
strict=settings["strict"] is not False,
|
|
66
|
+
max_rows=max_rows,
|
|
67
|
+
id_key=settings["id_key"] or "id",
|
|
68
|
+
exclude=settings["exclude"] or {},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _validated_exclude(self, exclude: dict[str, list[str]]) -> dict[str, list[str]]:
|
|
72
|
+
"""Reject a malformed exclude block at construction.
|
|
73
|
+
|
|
74
|
+
Redaction fails open by its nature: a lookup that finds nothing to exclude simply
|
|
75
|
+
excludes nothing, so a mistake here leaks exactly the values it was meant to
|
|
76
|
+
withhold, silently and forever. Table names cannot be checked against the database
|
|
77
|
+
from here, but the shape can be, and that catches the common mistake of writing a
|
|
78
|
+
bare list where a mapping belongs.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
if not isinstance(exclude, dict):
|
|
82
|
+
raise AuditError(
|
|
83
|
+
f'config["audit"]["exclude"] must be a mapping of table name -> list of '
|
|
84
|
+
f"columns; got {type(exclude).__name__}."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for table, columns in exclude.items():
|
|
88
|
+
if not isinstance(columns, (list, tuple)) or not all(isinstance(c, str) for c in columns):
|
|
89
|
+
raise AuditError(
|
|
90
|
+
f'config["audit"]["exclude"][{table!r}] must be a list of column names; '
|
|
91
|
+
f"got {columns!r}. Anything unreadable here is a redaction that will not happen."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return {table: list(columns) for table, columns in exclude.items()}
|
|
95
|
+
|
|
96
|
+
def excluded(self, table: str) -> list[str]:
|
|
97
|
+
return self.exclude.get(table, [])
|
|
98
|
+
|
|
99
|
+
################
|
|
100
|
+
### Recording ##
|
|
101
|
+
################
|
|
102
|
+
|
|
103
|
+
async def record(self, cur: psycopg.AsyncCursor, event: AuditEvent) -> None:
|
|
104
|
+
"""Write one event, on the caller's cursor and inside the caller's transaction."""
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
await self.store.write(cur, event.with_resolved(current_context()))
|
|
108
|
+
except Exception as e:
|
|
109
|
+
self._fail(e)
|
|
110
|
+
|
|
111
|
+
def _fail(self, error: Exception) -> None:
|
|
112
|
+
if self.strict:
|
|
113
|
+
if isinstance(error, AuditError):
|
|
114
|
+
raise error
|
|
115
|
+
|
|
116
|
+
raise AuditError(f"Audit trail: {error}") from error
|
|
117
|
+
|
|
118
|
+
# Availability over completeness, which is rarely the trade an audit trail wants to
|
|
119
|
+
# make. It is a choice, not a silence: the line is always logged.
|
|
120
|
+
#
|
|
121
|
+
# Note what this cannot do on Postgres: a failed INSERT aborts the surrounding
|
|
122
|
+
# transaction, so swallowing the exception does not rescue the change - it converts
|
|
123
|
+
# a clear failure into an unattributable one at commit. Callers running inside a
|
|
124
|
+
# transaction should use a SAVEPOINT around the change if they set strict=False.
|
|
125
|
+
_logger.warning("Audit trail: %s", error)
|
|
126
|
+
|
|
127
|
+
################
|
|
128
|
+
### Wrappers ###
|
|
129
|
+
################
|
|
130
|
+
|
|
131
|
+
async def insert(
|
|
132
|
+
self,
|
|
133
|
+
cur: psycopg.AsyncCursor,
|
|
134
|
+
table: str,
|
|
135
|
+
data: dict[str, Any],
|
|
136
|
+
module: str = "",
|
|
137
|
+
entity_id: str | None = None,
|
|
138
|
+
tags: list[str] | None = None,
|
|
139
|
+
context: dict[str, Any] | None = None,
|
|
140
|
+
) -> Any:
|
|
141
|
+
"""Insert a row and record it. Returns the inserted key.
|
|
142
|
+
|
|
143
|
+
Always uses RETURNING rather than reading the id back afterwards. Postgres cannot
|
|
144
|
+
answer "what id did I just insert" without being told which sequence to look at, so
|
|
145
|
+
every fallback for it either guesses or returns nothing - and returning nothing here
|
|
146
|
+
quietly empties the column `idx_audit_log_entity` exists to search.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
assert_table_name(table)
|
|
150
|
+
columns = list(data.keys())
|
|
151
|
+
|
|
152
|
+
statement = sql.SQL("INSERT INTO {rel} ({cols}) VALUES ({vals}) RETURNING {id}").format(
|
|
153
|
+
rel=qualified(table),
|
|
154
|
+
cols=sql.SQL(", ").join(sql.Identifier(c) for c in columns),
|
|
155
|
+
vals=sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
|
156
|
+
id=sql.Identifier(self.id_key),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
await cur.execute(statement, tuple(data[c] for c in columns))
|
|
160
|
+
returned = await cur.fetchone()
|
|
161
|
+
new_id = returned[0] if returned else None
|
|
162
|
+
|
|
163
|
+
_, new_values = diff.between(None, data, self.excluded(table))
|
|
164
|
+
|
|
165
|
+
await self.record(
|
|
166
|
+
cur,
|
|
167
|
+
AuditEvent(
|
|
168
|
+
event=CREATED,
|
|
169
|
+
entity_type=table,
|
|
170
|
+
entity_id=str(entity_id if entity_id is not None else (new_id if new_id is not None else "")),
|
|
171
|
+
module=module,
|
|
172
|
+
new_values=new_values,
|
|
173
|
+
tags=tags or [],
|
|
174
|
+
context=context,
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return new_id
|
|
179
|
+
|
|
180
|
+
async def update(
|
|
181
|
+
self,
|
|
182
|
+
cur: psycopg.AsyncCursor,
|
|
183
|
+
table: str,
|
|
184
|
+
data: dict[str, Any],
|
|
185
|
+
where: dict[str, Any],
|
|
186
|
+
module: str = "",
|
|
187
|
+
tags: list[str] | None = None,
|
|
188
|
+
context: dict[str, Any] | None = None,
|
|
189
|
+
) -> int:
|
|
190
|
+
"""Update rows and record one event per row that actually changed."""
|
|
191
|
+
|
|
192
|
+
assert_table_name(table)
|
|
193
|
+
rows = await self._rows(cur, table, where)
|
|
194
|
+
self._assert_within_limit(table, len(rows))
|
|
195
|
+
|
|
196
|
+
columns = list(data.keys())
|
|
197
|
+
statement = sql.SQL("UPDATE {rel} SET {sets} WHERE {cond}").format(
|
|
198
|
+
rel=qualified(table),
|
|
199
|
+
sets=sql.SQL(", ").join(sql.SQL("{} = {}").format(sql.Identifier(c), sql.Placeholder()) for c in columns),
|
|
200
|
+
cond=self._condition(where),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
await cur.execute(statement, tuple(data[c] for c in columns) + tuple(where.values()))
|
|
204
|
+
affected = cur.rowcount
|
|
205
|
+
|
|
206
|
+
if not rows:
|
|
207
|
+
# Not an error, but it is what a mistyped condition looks like.
|
|
208
|
+
_logger.debug("Audit trail: update on %r matched no rows", table)
|
|
209
|
+
|
|
210
|
+
excluded = self.excluded(table)
|
|
211
|
+
for row in rows:
|
|
212
|
+
old_values, new_values = diff.between(row, data, excluded)
|
|
213
|
+
if new_values is None:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
await self.record(
|
|
217
|
+
cur,
|
|
218
|
+
AuditEvent(
|
|
219
|
+
event=UPDATED,
|
|
220
|
+
entity_type=table,
|
|
221
|
+
entity_id=self._row_id(row),
|
|
222
|
+
module=module,
|
|
223
|
+
old_values=old_values,
|
|
224
|
+
new_values=new_values,
|
|
225
|
+
tags=tags or [],
|
|
226
|
+
context=context,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return affected
|
|
231
|
+
|
|
232
|
+
async def delete(
|
|
233
|
+
self,
|
|
234
|
+
cur: psycopg.AsyncCursor,
|
|
235
|
+
table: str,
|
|
236
|
+
where: dict[str, Any],
|
|
237
|
+
module: str = "",
|
|
238
|
+
tags: list[str] | None = None,
|
|
239
|
+
context: dict[str, Any] | None = None,
|
|
240
|
+
) -> int:
|
|
241
|
+
"""Delete rows and record each one, carrying the whole row as old values."""
|
|
242
|
+
|
|
243
|
+
assert_table_name(table)
|
|
244
|
+
|
|
245
|
+
if not where:
|
|
246
|
+
# An empty condition builds no WHERE at all, so it deletes the table and audits
|
|
247
|
+
# every row of it. Nothing below this call would refuse that, so it is refused
|
|
248
|
+
# here.
|
|
249
|
+
raise AuditError(
|
|
250
|
+
f"Refusing to delete every row of {table!r} with an empty condition. "
|
|
251
|
+
f"Pass an explicit condition, or run the DELETE yourself and record it with record()."
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
rows = await self._rows(cur, table, where)
|
|
255
|
+
self._assert_within_limit(table, len(rows))
|
|
256
|
+
|
|
257
|
+
statement = sql.SQL("DELETE FROM {rel} WHERE {cond}").format(rel=qualified(table), cond=self._condition(where))
|
|
258
|
+
await cur.execute(statement, tuple(where.values()))
|
|
259
|
+
affected = cur.rowcount
|
|
260
|
+
|
|
261
|
+
excluded = self.excluded(table)
|
|
262
|
+
for row in rows:
|
|
263
|
+
old_values, _ = diff.between(row, None, excluded)
|
|
264
|
+
|
|
265
|
+
await self.record(
|
|
266
|
+
cur,
|
|
267
|
+
AuditEvent(
|
|
268
|
+
event=DELETED,
|
|
269
|
+
entity_type=table,
|
|
270
|
+
entity_id=self._row_id(row),
|
|
271
|
+
module=module,
|
|
272
|
+
old_values=old_values,
|
|
273
|
+
tags=tags or [],
|
|
274
|
+
context=context,
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return affected
|
|
279
|
+
|
|
280
|
+
###############
|
|
281
|
+
### Helpers ###
|
|
282
|
+
###############
|
|
283
|
+
|
|
284
|
+
def _condition(self, where: dict[str, Any]) -> sql.Composed:
|
|
285
|
+
return sql.SQL(" AND ").join(sql.SQL("{} = {}").format(sql.Identifier(c), sql.Placeholder()) for c in where)
|
|
286
|
+
|
|
287
|
+
async def _rows(self, cur: psycopg.AsyncCursor, table: str, where: dict[str, Any]) -> list[dict[str, Any]]:
|
|
288
|
+
"""Read the rows a change is about to affect, as dicts."""
|
|
289
|
+
|
|
290
|
+
statement = sql.SQL("SELECT * FROM {rel}{cond}").format(
|
|
291
|
+
rel=qualified(table),
|
|
292
|
+
cond=sql.SQL(" WHERE ") + self._condition(where) if where else sql.SQL(""),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
await cur.execute(statement, tuple(where.values()))
|
|
296
|
+
if cur.description is None:
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
names = [d.name for d in cur.description]
|
|
300
|
+
return [dict(zip(names, row, strict=True)) for row in await cur.fetchall()]
|
|
301
|
+
|
|
302
|
+
def _row_id(self, row: dict[str, Any]) -> str:
|
|
303
|
+
value = row.get(self.id_key)
|
|
304
|
+
return "" if value is None else str(value)
|
|
305
|
+
|
|
306
|
+
def _assert_within_limit(self, table: str, matched: int) -> None:
|
|
307
|
+
"""Checked before the write, so a mistyped condition matching the whole table is
|
|
308
|
+
refused rather than becoming one write plus half a million audit rows."""
|
|
309
|
+
|
|
310
|
+
if self.max_rows < 1 or matched <= self.max_rows:
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
self._fail(
|
|
314
|
+
AuditError(
|
|
315
|
+
f"Refusing to audit {matched} rows of {table!r} in one call; "
|
|
316
|
+
f'config["audit"]["max_rows"] is {self.max_rows}. Narrow the condition, or raise '
|
|
317
|
+
f"the limit."
|
|
318
|
+
)
|
|
319
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Command implementations, importable directly for programmatic use."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
from psycopg import sql
|
|
10
|
+
|
|
11
|
+
from py_app_runner.audit.errors import AuditError
|
|
12
|
+
from py_app_runner.audit.store import assert_table_name, qualified
|
|
13
|
+
from py_app_runner.migrations.discovery import MigrationError, new_filename
|
|
14
|
+
|
|
15
|
+
Out = Callable[[str], None]
|
|
16
|
+
|
|
17
|
+
_TEMPLATE = pathlib.Path(__file__).parent / "files" / "install.pgsql.sql"
|
|
18
|
+
|
|
19
|
+
# No relative forms. "yesterday" in a retention job is a question about whose clock, and
|
|
20
|
+
# the answer only ever surfaces once rows are gone.
|
|
21
|
+
_BEFORE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_install(migrations_dir: pathlib.Path, table: str, now: datetime.datetime, out: Out) -> int:
|
|
25
|
+
"""Write the schema into the project's own migrations directory.
|
|
26
|
+
|
|
27
|
+
The framework ships no migration of its own and never creates the table at runtime:
|
|
28
|
+
migrations are discovered by filename order and checksummed once applied, so a
|
|
29
|
+
framework-owned file would sort by dependency release date and turn every upgrade into
|
|
30
|
+
checksum drift on a file the application cannot edit.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
assert_table_name(table)
|
|
35
|
+
except AuditError as e:
|
|
36
|
+
out(f"error: {e}")
|
|
37
|
+
return 2
|
|
38
|
+
|
|
39
|
+
if not migrations_dir.is_dir():
|
|
40
|
+
out(f"error: no migrations directory at {migrations_dir}")
|
|
41
|
+
return 2
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
schema = _TEMPLATE.read_text(encoding="utf-8")
|
|
45
|
+
except OSError as e:
|
|
46
|
+
out(f"error: could not read {_TEMPLATE}: {e}")
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
if table != "audit_log":
|
|
50
|
+
# Renames the indexes along with the table, so two trails can coexist in one schema
|
|
51
|
+
# without their index names colliding.
|
|
52
|
+
schema = schema.replace("audit_log", table)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
target = migrations_dir / new_filename(f"create {table}", now)
|
|
56
|
+
except MigrationError as e:
|
|
57
|
+
out(f"error: {e}")
|
|
58
|
+
return 2
|
|
59
|
+
|
|
60
|
+
if target.exists():
|
|
61
|
+
out(f"error: {target} already exists")
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
target.write_text(schema, encoding="utf-8")
|
|
66
|
+
except OSError as e:
|
|
67
|
+
out(f"error: could not write {target}: {e}")
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
out(f"Wrote {target}")
|
|
71
|
+
out("Review it, then: python3 src/app.py migrations apply")
|
|
72
|
+
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def cmd_prune(
|
|
77
|
+
conn: psycopg.AsyncConnection,
|
|
78
|
+
table: str,
|
|
79
|
+
before: str,
|
|
80
|
+
batch: int,
|
|
81
|
+
dry_run: bool,
|
|
82
|
+
out: Out,
|
|
83
|
+
) -> int:
|
|
84
|
+
"""Delete trail rows older than a date, in batches.
|
|
85
|
+
|
|
86
|
+
Batched because a single DELETE over a year of a busy trail takes a lock long enough to
|
|
87
|
+
be noticed, and because an interrupted run should leave the work it already did done.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
assert_table_name(table)
|
|
92
|
+
except AuditError as e:
|
|
93
|
+
out(f"error: {e}")
|
|
94
|
+
return 2
|
|
95
|
+
|
|
96
|
+
if _BEFORE_RE.match(before) is None:
|
|
97
|
+
out(f"error: --before={before!r} must be YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'")
|
|
98
|
+
return 2
|
|
99
|
+
|
|
100
|
+
if batch < 1:
|
|
101
|
+
out("error: --batch must be at least 1")
|
|
102
|
+
return 2
|
|
103
|
+
|
|
104
|
+
relation = qualified(table)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
async with conn.cursor() as cur:
|
|
108
|
+
await cur.execute(
|
|
109
|
+
sql.SQL("SELECT count(*) FROM {rel} WHERE created_at < %s").format(rel=relation),
|
|
110
|
+
(before,),
|
|
111
|
+
)
|
|
112
|
+
row = await cur.fetchone()
|
|
113
|
+
total = row[0] if row else 0
|
|
114
|
+
except psycopg.Error as e:
|
|
115
|
+
out(f"error: cannot read {table}: {e}")
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
out(f"{total} rows in {table} older than {before}")
|
|
119
|
+
|
|
120
|
+
if total == 0:
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
if dry_run:
|
|
124
|
+
out("Nothing deleted (--dry-run).")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
statement = sql.SQL(
|
|
128
|
+
"DELETE FROM {rel} WHERE id IN (SELECT id FROM {rel} WHERE created_at < %s ORDER BY id LIMIT %s)"
|
|
129
|
+
).format(rel=relation)
|
|
130
|
+
|
|
131
|
+
deleted = 0
|
|
132
|
+
while deleted < total:
|
|
133
|
+
try:
|
|
134
|
+
async with conn.cursor() as cur:
|
|
135
|
+
await cur.execute(statement, (before, batch))
|
|
136
|
+
removed = cur.rowcount
|
|
137
|
+
except psycopg.Error as e:
|
|
138
|
+
out(f"error: cannot delete from {table}: {e}")
|
|
139
|
+
return 1
|
|
140
|
+
|
|
141
|
+
if removed <= 0:
|
|
142
|
+
# Nothing left to take. Breaking rather than looping keeps a miscounted total
|
|
143
|
+
# from spinning forever.
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
deleted += removed
|
|
147
|
+
out(f"Deleted {deleted}/{total}")
|
|
148
|
+
|
|
149
|
+
out(f"Done. {deleted} rows removed.")
|
|
150
|
+
|
|
151
|
+
return 0
|