aflib 0.0.2__tar.gz → 0.0.7__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 (36) hide show
  1. {aflib-0.0.2 → aflib-0.0.7}/.gitignore +1 -0
  2. {aflib-0.0.2 → aflib-0.0.7}/PKG-INFO +116 -7
  3. {aflib-0.0.2 → aflib-0.0.7}/README.md +114 -5
  4. {aflib-0.0.2 → aflib-0.0.7}/pyproject.toml +8 -5
  5. {aflib-0.0.2 → aflib-0.0.7}/src/aflib/__init__.py +31 -4
  6. aflib-0.0.7/src/aflib/authz.py +86 -0
  7. aflib-0.0.7/src/aflib/batch.py +141 -0
  8. {aflib-0.0.2 → aflib-0.0.7}/src/aflib/errors.py +4 -6
  9. aflib-0.0.7/src/aflib/events.py +176 -0
  10. aflib-0.0.7/src/aflib/functions.py +72 -0
  11. aflib-0.0.7/src/aflib/identity.py +20 -0
  12. {aflib-0.0.2 → aflib-0.0.7}/src/aflib/logging.py +5 -3
  13. {aflib-0.0.2 → aflib-0.0.7}/src/aflib/params.py +13 -1
  14. aflib-0.0.7/src/aflib/transaction.py +141 -0
  15. aflib-0.0.7/src/aflib/trigger.py +51 -0
  16. aflib-0.0.7/src/aflib/workflow.py +161 -0
  17. {aflib-0.0.2 → aflib-0.0.7}/tests/conftest.py +61 -11
  18. aflib-0.0.7/tests/test_authz.py +41 -0
  19. aflib-0.0.7/tests/test_batch.py +145 -0
  20. {aflib-0.0.2 → aflib-0.0.7}/tests/test_db.py +6 -6
  21. {aflib-0.0.2 → aflib-0.0.7}/tests/test_errors.py +8 -9
  22. aflib-0.0.7/tests/test_events.py +184 -0
  23. aflib-0.0.7/tests/test_functions.py +50 -0
  24. aflib-0.0.7/tests/test_identity.py +11 -0
  25. {aflib-0.0.2 → aflib-0.0.7}/tests/test_logging.py +24 -5
  26. {aflib-0.0.2 → aflib-0.0.7}/tests/test_params.py +20 -0
  27. {aflib-0.0.2 → aflib-0.0.7}/tests/test_public_api.py +39 -1
  28. {aflib-0.0.2 → aflib-0.0.7}/tests/test_result.py +3 -3
  29. aflib-0.0.7/tests/test_transaction.py +193 -0
  30. aflib-0.0.7/tests/test_trigger.py +42 -0
  31. aflib-0.0.7/tests/test_workflow.py +133 -0
  32. {aflib-0.0.2 → aflib-0.0.7}/LICENSE +0 -0
  33. {aflib-0.0.2 → aflib-0.0.7}/src/aflib/codec.py +0 -0
  34. {aflib-0.0.2 → aflib-0.0.7}/src/aflib/db.py +0 -0
  35. {aflib-0.0.2 → aflib-0.0.7}/src/aflib/result.py +0 -0
  36. {aflib-0.0.2 → aflib-0.0.7}/tests/test_codec.py +0 -0
@@ -5,3 +5,4 @@ build/
5
5
  dist/
6
6
  .venv/
7
7
  .pytest_cache/
8
+ .env
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: aflib
3
- Version: 0.0.2
3
+ Version: 0.0.7
4
4
  Summary: Helper library for business logic inside Airflows plpython function bodies
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -26,15 +26,75 @@ so low.
26
26
 
27
27
  Airflows resolves Python packages from public PyPI by name and version only; there is no
28
28
  local-import, git or private-index route. Declare it in
29
- `models/pythonPackages/pythonPackages.airflows`:
29
+ `models/pythonPackages/pythonPackages.airflows` by pinning **name and version in
30
+ separate fields** — never as a single `aflib==0.0.7` string (that form produced
31
+ malformed `name==ver==ver` entries in a real instance's package list):
30
32
 
31
33
  ```
32
- pythonPackage aflib==0.0.2
34
+ name: aflib
35
+ version: 0.0.7
33
36
  ```
34
37
 
35
38
  Then import it from a function body.
36
39
 
37
- ## What 0.0.2 gives you
40
+ ## What 0.0.7 gives you
41
+
42
+ Everything in 0.0.6, plus three capabilities that ran on a real instance on
43
+ 2026-10-01 before they were extracted:
44
+
45
+ - **`Batch(plpy).run(...)`** — one committed chunk. The pending predicate is
46
+ placed in the outer `WHERE` and in the subquery, and the result carries
47
+ `done`, `remaining` and `seconds`.
48
+ - **`workflow.start` / `status` / `stuck`** — insert a `PENDING` workflow
49
+ instance and its variables, read the newest instance for a business key, and
50
+ list instances that are still `PENDING` or `IN_PROGRESS` after an interval.
51
+ - **`Outbox(plpy, outbox=..., delivery=..., subscription=...).emit` / `pump`** —
52
+ one event plus a pending delivery per active subscriber, then a chunk of
53
+ deliveries. Each subscriber runs inside `plpy.subtransaction()`. Failures
54
+ retry with `attempts`, `next_attempt_at` and `last_error`, and dead-letter
55
+ after 3 attempts.
56
+
57
+ ```python
58
+ from aflib import workflow
59
+ from aflib.events import Outbox
60
+
61
+ workflow.start(plpy, "WFP_cadena", "case-58", {
62
+ "SomeSchema.some_function.p_id": ("INTEGER", 42),
63
+ })
64
+
65
+ box = Outbox(plpy, outbox="app.outbox", delivery="app.delivery", subscription="app.sub")
66
+ box.emit("order.placed", {"id": 42}, 42)
67
+ box.pump(deliver, limit=50)
68
+ ```
69
+
70
+ `deliver(subscriber, outbox_id, attempt)` does the work and raises to fail the
71
+ attempt. Table names are identifiers you wrote, not request data.
72
+
73
+ ## What 0.0.6 gives you
74
+
75
+ 0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
76
+ the 2026-09 platform probes showed, plus two verified fixes:
77
+
78
+ - **`params.as_bool`** accepts a genuine Python `bool` as well as the
79
+ `true`/`false`/`1`/`0`/`t`/`f` text vocabulary. A `boolean` customParameter
80
+ on the non-HTTP path binds as `True`/`False`; converting that through
81
+ `as_bool` no longer raises.
82
+ - **`logging`** treats `source` as reserved, the same way it already treats
83
+ `level` and `event`. Passing `source=` as a field raises rather than
84
+ silently re-labelling the emitting function.
85
+ - **`authz.can(plpy, "Schema.Entity", "UPDATE")`** and **`authz.snapshot(plpy)`** —
86
+ table GRANTs via `has_table_privilege`. Not RLS. Snapshot before `SET ROLE`.
87
+ - **`trigger.changed(TD)` / `event` / `when`** — a plpython trigger can
88
+ write `TD["new"]` and return `"MODIFY"`. Compare OLD/NEW; ignore `search`.
89
+ - **`functions.call(plpy, "Schema.fn", args, argtypes)`** — compose through
90
+ SQL with **Postgres** bind types. Text binds to a typed signature do not
91
+ resolve.
92
+ - **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
93
+
94
+ Pin **name and version in separate fields** (`aflib` / `0.0.7`), not
95
+ `aflib==0.0.7` as one string.
96
+
97
+ ## Core API (0.0.1–0.0.3, still in 0.0.7)
38
98
 
39
99
  ```python
40
100
  from aflib import Db, errors, fail, logging, ok, params
@@ -85,6 +145,9 @@ except Exception as exc:
85
145
  present, written through `plpy.log`. It never raises, even when handed a value that cannot
86
146
  be encoded: the line is most needed in the error path, so a single exotic field must not be
87
147
  able to silence it.
148
+ - **`transaction.atomic(plpy)`** — a savepoint that also rolls back a *returned* failure. See
149
+ [A returned failure does not roll back](#a-returned-failure-does-not-roll-back) — this is a
150
+ narrow helper for one specific trap, not a unit of work.
88
151
 
89
152
  ## Platform constraints worth knowing before you build on it
90
153
 
@@ -93,8 +156,9 @@ the library has the shape it does.
93
156
 
94
157
  - **Every HTTP-facing parameter must be declared `text`.** Declare `integer` and the platform
95
158
  binds `varchar` at call time, no candidate signature matches, and the endpoint fails with
96
- *function does not exist* — it cannot be invoked at all. This is not a casting
97
- inconvenience; the endpoint is simply unreachable. Convert inside the body instead.
159
+ *function does not exist*. A `boolean` parameter binds `true`/`false`, but `?p_flag=maybe`
160
+ is HTTP 500 `Cannot cast to boolean` before the body runs. Convert with `params` inside
161
+ the body.
98
162
  - **Function bodies compose only through SQL** — `SELECT "Schema"."fn"(…)`. There is no
99
163
  shared module and no package-local import, so a helper another body needs is a database
100
164
  function, not a Python one.
@@ -137,6 +201,51 @@ Two related facts worth knowing before you design around logging:
137
201
  *type* does not. So a business outcome has to travel as a returned value — `ok`/`fail` — and
138
202
  never as an exception, and nothing sensitive may ever go into an exception message.
139
203
 
204
+ ### A returned failure does not roll back
205
+
206
+ A function body is **one transaction it does not control.** `plpy.commit()` fails with *invalid
207
+ transaction termination*, because a function invoked inside a query cannot terminate the
208
+ transaction it is running in. What a body *can* do is subdivide that transaction with savepoints
209
+ via `plpy.subtransaction()`, and those work exactly as you would hope: they roll back on any
210
+ exception, Python or SQL alike, the original exception continues unchanged, nesting rolls back
211
+ only the inner block, and a trigger's writes roll back together with the row that fired them.
212
+
213
+ Two further rules complete the picture: **an uncaught exception discards the whole call**, and
214
+ **a normal return commits the whole call.**
215
+
216
+ That last one is the trap, because it applies to a returned *failure* too:
217
+
218
+ ```python
219
+ with plpy.subtransaction():
220
+ db.query("INSERT …")
221
+ return fail("not_allowed", "…") # the INSERT is COMMITTED
222
+ ```
223
+
224
+ `plpy.subtransaction()` reacts to exceptions, and a business failure is not an exception — it
225
+ cannot be, since an exception's type does not survive crossing a function boundary. So the most
226
+ natural code silently half-applies the change. `transaction.atomic` exists for exactly this:
227
+
228
+ ```python
229
+ from aflib import errors, transaction
230
+
231
+ try:
232
+ with transaction.atomic(plpy) as tx:
233
+ db.query("INSERT …")
234
+ if not allowed:
235
+ tx.reject("not_allowed", "You may not do that") # rolls back
236
+ tx.succeed({"id": 7})
237
+ return tx.outcome
238
+ except Exception as exc:
239
+ return errors.to_fail(exc, log=log)
240
+ ```
241
+
242
+ `reject()` ends the block and rolls back; `outcome` is read afterwards. A scope that ends
243
+ without `succeed()` or `reject()` raises rather than returning an empty success for work that
244
+ may have half happened.
245
+
246
+ It is deliberately **not** a unit of work and cannot commit anything — the name describes the
247
+ guarantee it provides (the block is all-or-nothing), not machinery it does not have.
248
+
140
249
  ## Tests
141
250
 
142
251
  ```
@@ -17,15 +17,75 @@ so low.
17
17
 
18
18
  Airflows resolves Python packages from public PyPI by name and version only; there is no
19
19
  local-import, git or private-index route. Declare it in
20
- `models/pythonPackages/pythonPackages.airflows`:
20
+ `models/pythonPackages/pythonPackages.airflows` by pinning **name and version in
21
+ separate fields** — never as a single `aflib==0.0.7` string (that form produced
22
+ malformed `name==ver==ver` entries in a real instance's package list):
21
23
 
22
24
  ```
23
- pythonPackage aflib==0.0.2
25
+ name: aflib
26
+ version: 0.0.7
24
27
  ```
25
28
 
26
29
  Then import it from a function body.
27
30
 
28
- ## What 0.0.2 gives you
31
+ ## What 0.0.7 gives you
32
+
33
+ Everything in 0.0.6, plus three capabilities that ran on a real instance on
34
+ 2026-10-01 before they were extracted:
35
+
36
+ - **`Batch(plpy).run(...)`** — one committed chunk. The pending predicate is
37
+ placed in the outer `WHERE` and in the subquery, and the result carries
38
+ `done`, `remaining` and `seconds`.
39
+ - **`workflow.start` / `status` / `stuck`** — insert a `PENDING` workflow
40
+ instance and its variables, read the newest instance for a business key, and
41
+ list instances that are still `PENDING` or `IN_PROGRESS` after an interval.
42
+ - **`Outbox(plpy, outbox=..., delivery=..., subscription=...).emit` / `pump`** —
43
+ one event plus a pending delivery per active subscriber, then a chunk of
44
+ deliveries. Each subscriber runs inside `plpy.subtransaction()`. Failures
45
+ retry with `attempts`, `next_attempt_at` and `last_error`, and dead-letter
46
+ after 3 attempts.
47
+
48
+ ```python
49
+ from aflib import workflow
50
+ from aflib.events import Outbox
51
+
52
+ workflow.start(plpy, "WFP_cadena", "case-58", {
53
+ "SomeSchema.some_function.p_id": ("INTEGER", 42),
54
+ })
55
+
56
+ box = Outbox(plpy, outbox="app.outbox", delivery="app.delivery", subscription="app.sub")
57
+ box.emit("order.placed", {"id": 42}, 42)
58
+ box.pump(deliver, limit=50)
59
+ ```
60
+
61
+ `deliver(subscriber, outbox_id, attempt)` does the work and raises to fail the
62
+ attempt. Table names are identifiers you wrote, not request data.
63
+
64
+ ## What 0.0.6 gives you
65
+
66
+ 0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
67
+ the 2026-09 platform probes showed, plus two verified fixes:
68
+
69
+ - **`params.as_bool`** accepts a genuine Python `bool` as well as the
70
+ `true`/`false`/`1`/`0`/`t`/`f` text vocabulary. A `boolean` customParameter
71
+ on the non-HTTP path binds as `True`/`False`; converting that through
72
+ `as_bool` no longer raises.
73
+ - **`logging`** treats `source` as reserved, the same way it already treats
74
+ `level` and `event`. Passing `source=` as a field raises rather than
75
+ silently re-labelling the emitting function.
76
+ - **`authz.can(plpy, "Schema.Entity", "UPDATE")`** and **`authz.snapshot(plpy)`** —
77
+ table GRANTs via `has_table_privilege`. Not RLS. Snapshot before `SET ROLE`.
78
+ - **`trigger.changed(TD)` / `event` / `when`** — a plpython trigger can
79
+ write `TD["new"]` and return `"MODIFY"`. Compare OLD/NEW; ignore `search`.
80
+ - **`functions.call(plpy, "Schema.fn", args, argtypes)`** — compose through
81
+ SQL with **Postgres** bind types. Text binds to a typed signature do not
82
+ resolve.
83
+ - **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
84
+
85
+ Pin **name and version in separate fields** (`aflib` / `0.0.7`), not
86
+ `aflib==0.0.7` as one string.
87
+
88
+ ## Core API (0.0.1–0.0.3, still in 0.0.7)
29
89
 
30
90
  ```python
31
91
  from aflib import Db, errors, fail, logging, ok, params
@@ -76,6 +136,9 @@ except Exception as exc:
76
136
  present, written through `plpy.log`. It never raises, even when handed a value that cannot
77
137
  be encoded: the line is most needed in the error path, so a single exotic field must not be
78
138
  able to silence it.
139
+ - **`transaction.atomic(plpy)`** — a savepoint that also rolls back a *returned* failure. See
140
+ [A returned failure does not roll back](#a-returned-failure-does-not-roll-back) — this is a
141
+ narrow helper for one specific trap, not a unit of work.
79
142
 
80
143
  ## Platform constraints worth knowing before you build on it
81
144
 
@@ -84,8 +147,9 @@ the library has the shape it does.
84
147
 
85
148
  - **Every HTTP-facing parameter must be declared `text`.** Declare `integer` and the platform
86
149
  binds `varchar` at call time, no candidate signature matches, and the endpoint fails with
87
- *function does not exist* — it cannot be invoked at all. This is not a casting
88
- inconvenience; the endpoint is simply unreachable. Convert inside the body instead.
150
+ *function does not exist*. A `boolean` parameter binds `true`/`false`, but `?p_flag=maybe`
151
+ is HTTP 500 `Cannot cast to boolean` before the body runs. Convert with `params` inside
152
+ the body.
89
153
  - **Function bodies compose only through SQL** — `SELECT "Schema"."fn"(…)`. There is no
90
154
  shared module and no package-local import, so a helper another body needs is a database
91
155
  function, not a Python one.
@@ -128,6 +192,51 @@ Two related facts worth knowing before you design around logging:
128
192
  *type* does not. So a business outcome has to travel as a returned value — `ok`/`fail` — and
129
193
  never as an exception, and nothing sensitive may ever go into an exception message.
130
194
 
195
+ ### A returned failure does not roll back
196
+
197
+ A function body is **one transaction it does not control.** `plpy.commit()` fails with *invalid
198
+ transaction termination*, because a function invoked inside a query cannot terminate the
199
+ transaction it is running in. What a body *can* do is subdivide that transaction with savepoints
200
+ via `plpy.subtransaction()`, and those work exactly as you would hope: they roll back on any
201
+ exception, Python or SQL alike, the original exception continues unchanged, nesting rolls back
202
+ only the inner block, and a trigger's writes roll back together with the row that fired them.
203
+
204
+ Two further rules complete the picture: **an uncaught exception discards the whole call**, and
205
+ **a normal return commits the whole call.**
206
+
207
+ That last one is the trap, because it applies to a returned *failure* too:
208
+
209
+ ```python
210
+ with plpy.subtransaction():
211
+ db.query("INSERT …")
212
+ return fail("not_allowed", "…") # the INSERT is COMMITTED
213
+ ```
214
+
215
+ `plpy.subtransaction()` reacts to exceptions, and a business failure is not an exception — it
216
+ cannot be, since an exception's type does not survive crossing a function boundary. So the most
217
+ natural code silently half-applies the change. `transaction.atomic` exists for exactly this:
218
+
219
+ ```python
220
+ from aflib import errors, transaction
221
+
222
+ try:
223
+ with transaction.atomic(plpy) as tx:
224
+ db.query("INSERT …")
225
+ if not allowed:
226
+ tx.reject("not_allowed", "You may not do that") # rolls back
227
+ tx.succeed({"id": 7})
228
+ return tx.outcome
229
+ except Exception as exc:
230
+ return errors.to_fail(exc, log=log)
231
+ ```
232
+
233
+ `reject()` ends the block and rolls back; `outcome` is read afterwards. A scope that ends
234
+ without `succeed()` or `reject()` raises rather than returning an empty success for work that
235
+ may have half happened.
236
+
237
+ It is deliberately **not** a unit of work and cannot commit anything — the name describes the
238
+ guarantee it provides (the block is all-or-nothing), not machinery it does not have.
239
+
131
240
  ## Tests
132
241
 
133
242
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aflib"
3
- version = "0.0.2"
3
+ version = "0.0.7"
4
4
  description = "Helper library for business logic inside Airflows plpython function bodies"
5
5
  readme = "README.md"
6
6
  # The platform runs Python 3.11.2 (observed, not assumed — see docs/platform-constraints.md).
@@ -26,11 +26,14 @@ packages = ["src/aflib"]
26
26
  #
27
27
  # Note this file is itself published: nothing in these comments should be anything you
28
28
  # would not put on a public page.
29
+ # Patterns are gitignore-style: unanchored names match at ANY depth (a bare
30
+ # "README.md" also swept in docs/README.md — observed in the 0.0.6 build). The
31
+ # leading "/" anchors each entry to the repo root so only the intended files ship.
29
32
  [tool.hatch.build.targets.sdist]
30
33
  include = [
31
- "src/aflib",
32
- "tests",
33
- "README.md",
34
- "LICENSE",
34
+ "/src/aflib",
35
+ "/tests",
36
+ "/README.md",
37
+ "/LICENSE",
35
38
  ]
36
39
 
@@ -24,33 +24,60 @@ A function body's whole import line, and the shape of a body:
24
24
  return errors.to_fail(exc, log=log)
25
25
 
26
26
  **The `try` is not optional.** An exception that escapes a function body is returned to the
27
- HTTP caller along with the function's name, the failing line number and the literal source
28
- line — so catching everything is a security control, not a matter of taste. `except
27
+ HTTP caller carrying internal detail the caller should not see, and a body cannot influence
28
+ that response — so catching everything is a security control, not a matter of taste. `except
29
29
  Exception` is also the only clause that works: `plpy.Error` does not inherit from
30
30
  `plpy.SPIError`, so the narrower version catches neither `plpy.error()` nor an ordinary
31
31
  Python exception.
32
32
  """
33
33
 
34
- from aflib import codec, db, errors, logging, params, result
34
+ from aflib import (
35
+ authz,
36
+ batch,
37
+ codec,
38
+ db,
39
+ errors,
40
+ events,
41
+ functions,
42
+ identity,
43
+ logging,
44
+ params,
45
+ result,
46
+ transaction,
47
+ trigger,
48
+ workflow,
49
+ )
50
+ from aflib.batch import Batch
35
51
  from aflib.db import Db
52
+ from aflib.events import Outbox
36
53
  from aflib.params import BadParameter
37
54
  from aflib.result import fail, ok
38
55
 
39
56
  # Kept in step with `pyproject.toml` by hand. A deployed body returns this so a run can
40
57
  # prove which aflib it actually imported — a stale package otherwise looks exactly like a
41
58
  # working one.
42
- __version__ = "0.0.2"
59
+ __version__ = "0.0.7"
43
60
 
44
61
  __all__ = [
45
62
  "BadParameter",
63
+ "Batch",
46
64
  "Db",
65
+ "Outbox",
66
+ "authz",
67
+ "batch",
47
68
  "codec",
48
69
  "db",
49
70
  "errors",
71
+ "events",
50
72
  "fail",
73
+ "functions",
74
+ "identity",
51
75
  "logging",
52
76
  "ok",
53
77
  "params",
54
78
  "result",
79
+ "transaction",
80
+ "trigger",
81
+ "workflow",
55
82
  "__version__",
56
83
  ]
@@ -0,0 +1,86 @@
1
+ """Table GRANTs for the current caller (`entityPermission`).
2
+
3
+ Not RLS and not business rules. `can("AdminCentre.Delivery", "UPDATE")` is
4
+ `has_table_privilege` on this connection. A `SELECT` of a missing or
5
+ RLS-hidden id returns **zero rows**, not an error — do not turn that into
6
+ 403.
7
+
8
+ Take a `snapshot` **before** `SET ROLE modelsadmin`. After elevation,
9
+ `has_table_privilege(current_user, …)` is the elevated role. The snapshot
10
+ keeps the caller's answers in a local dict (never `GD`/`SD`).
11
+
12
+ from aflib import authz
13
+
14
+ az = authz.snapshot(plpy)
15
+ if not az.can("AdminCentre.Delivery", "INSERT"):
16
+ return fail("forbidden", "Cannot create a delivery")
17
+
18
+ A single check with no later SET ROLE can call `authz.can(plpy, entity, verb)`
19
+ and skip the snapshot.
20
+
21
+ Observed 2026-09-18: `has_table_privilege` is callable from a plpython
22
+ body; SET ROLE modelsadmin changes `current_user` (when the caller is
23
+ allowed to elevate). HTTP `boolean` params are unrelated (G55).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+
30
+ from aflib import identity
31
+
32
+ _IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
33
+ _VERBS = frozenset({"SELECT", "INSERT", "UPDATE", "DELETE"})
34
+
35
+
36
+ def _regclass(entity: str) -> str:
37
+ parts = entity.split(".")
38
+ if len(parts) != 2:
39
+ raise ValueError("expected Schema.Entity, got {!r}".format(entity))
40
+ for part in parts:
41
+ if not _IDENT.match(part):
42
+ raise ValueError("invalid identifier {!r} in {!r}".format(part, entity))
43
+ return '"{}"."{}"'.format(parts[0], parts[1])
44
+
45
+
46
+ def _verb(verb: str) -> str:
47
+ v = (verb or "").upper()
48
+ if v not in _VERBS:
49
+ raise ValueError(
50
+ "verb must be SELECT, INSERT, UPDATE or DELETE, got {!r}".format(verb)
51
+ )
52
+ return v
53
+
54
+
55
+ def can(plpy, entity, verb, cache=None):
56
+ """Whether *this connection* has GRANT `verb` on `entity`."""
57
+ table = _regclass(entity)
58
+ v = _verb(verb)
59
+ key = (table, v)
60
+ if cache is not None and key in cache:
61
+ return cache[key]
62
+ plan = plpy.prepare(
63
+ "SELECT has_table_privilege(current_user, $1, $2) AS ok",
64
+ ["text", "text"],
65
+ )
66
+ row = plpy.execute(plan, [table, v])[0]
67
+ ok = bool(row["ok"])
68
+ if cache is not None:
69
+ cache[key] = ok
70
+ return ok
71
+
72
+
73
+ class Snapshot:
74
+ """Caller's GRANTs, frozen at construction time."""
75
+
76
+ def __init__(self, plpy):
77
+ self.username = identity.current_user(plpy)
78
+ self._plpy = plpy
79
+ self._cache = {}
80
+
81
+ def can(self, entity, verb):
82
+ return can(self._plpy, entity, verb, cache=self._cache)
83
+
84
+
85
+ def snapshot(plpy) -> Snapshot:
86
+ return Snapshot(plpy)
@@ -0,0 +1,141 @@
1
+ """Work through a backlog one committed chunk at a time.
2
+
3
+ from aflib.batch import Batch
4
+
5
+ batch = Batch(plpy)
6
+
7
+ progress = batch.run(
8
+ table="\\"Economic\\".\\"Receipt\\"",
9
+ set_clause='id_status = $1',
10
+ pending='id_status = $2 AND (period_from IS NULL OR period_from <= CURRENT_DATE + 5)',
11
+ args=[pending_id, planned_id],
12
+ types=["integer", "integer"],
13
+ limit=1000,
14
+ )
15
+
16
+ return ok({"done": progress.done, "remaining": progress.remaining})
17
+
18
+ One call is one chunk. The caller decides what drives the next one — a `pg_cron` job, the
19
+ next step of a workflow, a human pressing a button again — and each call is its own
20
+ transaction, so each chunk is **kept** whether or not the next one happens.
21
+
22
+ **Why a function and not a loop.** A plpgsql or plpython function cannot commit: it runs
23
+ inside a transaction it does not own, and `plpy.commit()` fails with *invalid transaction
24
+ termination*. There are no `PROCEDURE`s on the platform either (401 functions, zero
25
+ procedures, measured 2026-10-01), so the usual `LOOP … COMMIT` shape has nowhere to live.
26
+ A chunk per invocation is not a stylistic choice; it is the only way to commit progress.
27
+
28
+ **The one rule this exists to enforce: the pending predicate goes in two places.** It
29
+ selects the ids *and* it guards the update. Written the obvious way, with the predicate
30
+ only in the subquery, this is wrong:
31
+
32
+ UPDATE t SET … WHERE id IN (SELECT id FROM t WHERE pending LIMIT 1000) -- BROKEN
33
+
34
+ Under `READ COMMITTED` the subquery runs against the statement's snapshot, but when the
35
+ update reaches a row another transaction has since changed, Postgres re-reads the latest
36
+ committed version and applies the update to **that**. A row that was pending when selected
37
+ and is now cancelled gets updated anyway. On 2026-10-01 that aborted a production run on
38
+ its second chunk: a receipt was cancelled by a user mid-batch and the trigger refused the
39
+ `Cancelled → Pending` transition. Repeating the predicate in the outer `WHERE` makes
40
+ Postgres re-evaluate it against the new version and skip the row.
41
+
42
+ `run()` takes the predicate once and places it twice, so getting this wrong requires
43
+ bypassing the helper.
44
+
45
+ **`table`, `set_clause` and `pending` are SQL, and are interpolated.** They are code
46
+ written by the caller, not values — values go in `args` and are bound. Never build them
47
+ from anything a request supplied.
48
+
49
+ **Chunk size is measured, not chosen.** Cost per row depends on the triggers the write
50
+ fires, and the figure is not guessable: the same 1.000-row chunk took 19 s on
51
+ `Economic.Receipt`, whose triggers recalculate and audit, and 0.07 ms per row on a bare
52
+ table. Run one chunk, read `progress.seconds`, and size the rest so a chunk finishes well
53
+ inside the environment's `statement_timeout` — 180 s on PRO, no limit on PRE, so **PRE
54
+ cannot tell you whether a chunk fits**.
55
+ """
56
+
57
+ import time
58
+
59
+ from aflib.db import Db
60
+
61
+
62
+ class Progress(object):
63
+ """What one chunk did, and whether there is more.
64
+
65
+ `remaining` is counted **after** the update, in the same transaction, so it already
66
+ reflects this chunk. A caller that loops until `complete` therefore terminates without
67
+ needing to subtract anything itself.
68
+ """
69
+
70
+ __slots__ = ("done", "remaining", "seconds")
71
+
72
+ def __init__(self, done, remaining, seconds):
73
+ self.done = done
74
+ self.remaining = remaining
75
+ self.seconds = seconds
76
+
77
+ @property
78
+ def complete(self):
79
+ """True when nothing is left to do."""
80
+ return self.remaining == 0
81
+
82
+ def as_dict(self):
83
+ return {"done": self.done, "remaining": self.remaining,
84
+ "seconds": round(self.seconds, 3), "complete": self.complete}
85
+
86
+ def __repr__(self):
87
+ return "Progress(done=%r, remaining=%r, complete=%r)" % (
88
+ self.done, self.remaining, self.complete)
89
+
90
+
91
+ class Batch(object):
92
+ """One committed chunk per call. `plpy` is injected, as everywhere in aflib."""
93
+
94
+ def __init__(self, plpy):
95
+ self._db = Db(plpy)
96
+
97
+ ALIAS = "_t"
98
+
99
+ def run(self, table, set_clause, pending, args=None, types=None,
100
+ limit=1000, key="id", order=None):
101
+ """Update at most `limit` rows matching `pending`, and report what is left.
102
+
103
+ `pending` must be **unqualified** — write `id_status = $1`, not
104
+ `"Receipt".id_status = $1`. That is not a style rule: the same text is placed in
105
+ the outer `WHERE`, where Postgres resolves it against the update target, and
106
+ inside the subquery, where it resolves against the subquery's own `FROM`. Both
107
+ resolutions are what we want, and either qualification breaks one of them. A
108
+ predicate mentioning the alias is rejected rather than silently misplaced.
109
+
110
+ `order` defaults to `key`, which makes the work deterministic and the chunks
111
+ disjoint. Ordering by something non-unique would let two concurrent callers pick
112
+ overlapping sets; the row-level guard still keeps them correct, but they would
113
+ waste each other's work.
114
+ """
115
+ if self.ALIAS in pending:
116
+ raise ValueError(
117
+ "`pending` must be unqualified: it is placed both in the outer WHERE and "
118
+ "inside the subquery, and %r resolves in only one of them." % self.ALIAS)
119
+
120
+ args = list(args or [])
121
+ types = list(types or [])
122
+ order = order or key
123
+
124
+ started = time.time()
125
+ updated = self._db.query(
126
+ "UPDATE {table} AS {alias} SET {set_clause} "
127
+ "WHERE {pending} "
128
+ " AND {alias}.{key} IN (SELECT {key} FROM {table} WHERE {pending} "
129
+ " ORDER BY {order} LIMIT {limit}) "
130
+ "RETURNING {alias}.{key}".format(
131
+ table=table, alias=self.ALIAS, set_clause=set_clause,
132
+ pending=pending, key=key, order=order, limit=int(limit)),
133
+ args, types).rows()
134
+ elapsed = time.time() - started
135
+
136
+ remaining = self._db.query(
137
+ "SELECT count(*)::int AS n FROM {table} WHERE {pending}".format(
138
+ table=table, pending=pending),
139
+ args, types).scalar("n")
140
+
141
+ return Progress(len(updated), remaining, elapsed)
@@ -10,11 +10,9 @@
10
10
  return errors.to_fail(exc, log=log)
11
11
 
12
12
  **Why a body must catch everything.** An exception that escapes is returned to the HTTP
13
- caller together with the function's name, the failing line number and the literal source
14
- line. Every `httpEnabled` endpoint is directly reachable, so an uncaught error is a
15
- source-disclosure primitive: make a function fail, read one line of it, and repeat. The
16
- platform produces that response in its own Java handler from the JDBC driver's message —
17
- there is no setting that suppresses it, and nothing a body can do except not raise.
13
+ caller carrying internal detail that the caller should not see. A body cannot influence that
14
+ response — it is assembled outside the database from the driver's error message, and no
15
+ setting trims it — so the only way to control what a caller receives is to not raise.
18
16
 
19
17
  **`except Exception` is not laziness here, it is the only sufficient clause.** `plpy.Error`
20
18
  does not inherit from `plpy.SPIError`, so the narrower `except plpy.SPIError` catches
@@ -79,7 +77,7 @@ def to_fail(exc, log=None):
79
77
  """Log what must stay hidden, and return the failure envelope for the caller.
80
78
 
81
79
  The envelope is returned even if logging fails. A broken sink must not turn a handled
82
- failure back into an escaping exception — that would disclose source at the very moment
80
+ failure back into an escaping exception — that would expose internal detail at the very moment
83
81
  the code was trying to prevent it.
84
82
  """
85
83
  failure = classify(exc)