aflib 0.0.2__tar.gz → 0.0.6__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 (30) hide show
  1. {aflib-0.0.2 → aflib-0.0.6}/PKG-INFO +83 -7
  2. {aflib-0.0.2 → aflib-0.0.6}/README.md +81 -5
  3. {aflib-0.0.2 → aflib-0.0.6}/pyproject.toml +8 -5
  4. {aflib-0.0.2 → aflib-0.0.6}/src/aflib/__init__.py +21 -4
  5. aflib-0.0.6/src/aflib/authz.py +86 -0
  6. {aflib-0.0.2 → aflib-0.0.6}/src/aflib/errors.py +4 -6
  7. aflib-0.0.6/src/aflib/functions.py +72 -0
  8. aflib-0.0.6/src/aflib/identity.py +20 -0
  9. {aflib-0.0.2 → aflib-0.0.6}/src/aflib/logging.py +5 -3
  10. {aflib-0.0.2 → aflib-0.0.6}/src/aflib/params.py +13 -1
  11. aflib-0.0.6/src/aflib/transaction.py +141 -0
  12. aflib-0.0.6/src/aflib/trigger.py +51 -0
  13. {aflib-0.0.2 → aflib-0.0.6}/tests/conftest.py +49 -8
  14. aflib-0.0.6/tests/test_authz.py +41 -0
  15. {aflib-0.0.2 → aflib-0.0.6}/tests/test_db.py +6 -6
  16. {aflib-0.0.2 → aflib-0.0.6}/tests/test_errors.py +8 -9
  17. aflib-0.0.6/tests/test_functions.py +50 -0
  18. aflib-0.0.6/tests/test_identity.py +11 -0
  19. {aflib-0.0.2 → aflib-0.0.6}/tests/test_logging.py +24 -5
  20. {aflib-0.0.2 → aflib-0.0.6}/tests/test_params.py +20 -0
  21. {aflib-0.0.2 → aflib-0.0.6}/tests/test_public_api.py +27 -1
  22. {aflib-0.0.2 → aflib-0.0.6}/tests/test_result.py +3 -3
  23. aflib-0.0.6/tests/test_transaction.py +193 -0
  24. aflib-0.0.6/tests/test_trigger.py +42 -0
  25. {aflib-0.0.2 → aflib-0.0.6}/.gitignore +0 -0
  26. {aflib-0.0.2 → aflib-0.0.6}/LICENSE +0 -0
  27. {aflib-0.0.2 → aflib-0.0.6}/src/aflib/codec.py +0 -0
  28. {aflib-0.0.2 → aflib-0.0.6}/src/aflib/db.py +0 -0
  29. {aflib-0.0.2 → aflib-0.0.6}/src/aflib/result.py +0 -0
  30. {aflib-0.0.2 → aflib-0.0.6}/tests/test_codec.py +0 -0
@@ -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.6
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,42 @@ 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.6` 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.6
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.6 gives you
41
+
42
+ 0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
43
+ the 2026-09 platform probes showed, plus two verified fixes:
44
+
45
+ - **`params.as_bool`** accepts a genuine Python `bool` as well as the
46
+ `true`/`false`/`1`/`0`/`t`/`f` text vocabulary. A `boolean` customParameter
47
+ on the non-HTTP path binds as `True`/`False`; converting that through
48
+ `as_bool` no longer raises.
49
+ - **`logging`** treats `source` as reserved, the same way it already treats
50
+ `level` and `event`. Passing `source=` as a field raises rather than
51
+ silently re-labelling the emitting function.
52
+ - **`authz.can(plpy, "Schema.Entity", "UPDATE")`** and **`authz.snapshot(plpy)`** —
53
+ table GRANTs via `has_table_privilege`. Not RLS. Snapshot before `SET ROLE`.
54
+ - **`trigger.changed(TD)` / `event` / `when`** — a plpython trigger can
55
+ write `TD["new"]` and return `"MODIFY"`. Compare OLD/NEW; ignore `search`.
56
+ - **`functions.call(plpy, "Schema.fn", args, argtypes)`** — compose through
57
+ SQL with **Postgres** bind types. Text binds to a typed signature do not
58
+ resolve.
59
+ - **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
60
+
61
+ Pin **name and version in separate fields** (`aflib` / `0.0.6`), not
62
+ `aflib==0.0.6` as one string.
63
+
64
+ ## Core API (0.0.1–0.0.3, still in 0.0.6)
38
65
 
39
66
  ```python
40
67
  from aflib import Db, errors, fail, logging, ok, params
@@ -85,6 +112,9 @@ except Exception as exc:
85
112
  present, written through `plpy.log`. It never raises, even when handed a value that cannot
86
113
  be encoded: the line is most needed in the error path, so a single exotic field must not be
87
114
  able to silence it.
115
+ - **`transaction.atomic(plpy)`** — a savepoint that also rolls back a *returned* failure. See
116
+ [A returned failure does not roll back](#a-returned-failure-does-not-roll-back) — this is a
117
+ narrow helper for one specific trap, not a unit of work.
88
118
 
89
119
  ## Platform constraints worth knowing before you build on it
90
120
 
@@ -93,8 +123,9 @@ the library has the shape it does.
93
123
 
94
124
  - **Every HTTP-facing parameter must be declared `text`.** Declare `integer` and the platform
95
125
  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.
126
+ *function does not exist*. A `boolean` parameter binds `true`/`false`, but `?p_flag=maybe`
127
+ is HTTP 500 `Cannot cast to boolean` before the body runs. Convert with `params` inside
128
+ the body.
98
129
  - **Function bodies compose only through SQL** — `SELECT "Schema"."fn"(…)`. There is no
99
130
  shared module and no package-local import, so a helper another body needs is a database
100
131
  function, not a Python one.
@@ -137,6 +168,51 @@ Two related facts worth knowing before you design around logging:
137
168
  *type* does not. So a business outcome has to travel as a returned value — `ok`/`fail` — and
138
169
  never as an exception, and nothing sensitive may ever go into an exception message.
139
170
 
171
+ ### A returned failure does not roll back
172
+
173
+ A function body is **one transaction it does not control.** `plpy.commit()` fails with *invalid
174
+ transaction termination*, because a function invoked inside a query cannot terminate the
175
+ transaction it is running in. What a body *can* do is subdivide that transaction with savepoints
176
+ via `plpy.subtransaction()`, and those work exactly as you would hope: they roll back on any
177
+ exception, Python or SQL alike, the original exception continues unchanged, nesting rolls back
178
+ only the inner block, and a trigger's writes roll back together with the row that fired them.
179
+
180
+ Two further rules complete the picture: **an uncaught exception discards the whole call**, and
181
+ **a normal return commits the whole call.**
182
+
183
+ That last one is the trap, because it applies to a returned *failure* too:
184
+
185
+ ```python
186
+ with plpy.subtransaction():
187
+ db.query("INSERT …")
188
+ return fail("not_allowed", "…") # the INSERT is COMMITTED
189
+ ```
190
+
191
+ `plpy.subtransaction()` reacts to exceptions, and a business failure is not an exception — it
192
+ cannot be, since an exception's type does not survive crossing a function boundary. So the most
193
+ natural code silently half-applies the change. `transaction.atomic` exists for exactly this:
194
+
195
+ ```python
196
+ from aflib import errors, transaction
197
+
198
+ try:
199
+ with transaction.atomic(plpy) as tx:
200
+ db.query("INSERT …")
201
+ if not allowed:
202
+ tx.reject("not_allowed", "You may not do that") # rolls back
203
+ tx.succeed({"id": 7})
204
+ return tx.outcome
205
+ except Exception as exc:
206
+ return errors.to_fail(exc, log=log)
207
+ ```
208
+
209
+ `reject()` ends the block and rolls back; `outcome` is read afterwards. A scope that ends
210
+ without `succeed()` or `reject()` raises rather than returning an empty success for work that
211
+ may have half happened.
212
+
213
+ It is deliberately **not** a unit of work and cannot commit anything — the name describes the
214
+ guarantee it provides (the block is all-or-nothing), not machinery it does not have.
215
+
140
216
  ## Tests
141
217
 
142
218
  ```
@@ -17,15 +17,42 @@ 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.6` 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.6
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.6 gives you
32
+
33
+ 0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
34
+ the 2026-09 platform probes showed, plus two verified fixes:
35
+
36
+ - **`params.as_bool`** accepts a genuine Python `bool` as well as the
37
+ `true`/`false`/`1`/`0`/`t`/`f` text vocabulary. A `boolean` customParameter
38
+ on the non-HTTP path binds as `True`/`False`; converting that through
39
+ `as_bool` no longer raises.
40
+ - **`logging`** treats `source` as reserved, the same way it already treats
41
+ `level` and `event`. Passing `source=` as a field raises rather than
42
+ silently re-labelling the emitting function.
43
+ - **`authz.can(plpy, "Schema.Entity", "UPDATE")`** and **`authz.snapshot(plpy)`** —
44
+ table GRANTs via `has_table_privilege`. Not RLS. Snapshot before `SET ROLE`.
45
+ - **`trigger.changed(TD)` / `event` / `when`** — a plpython trigger can
46
+ write `TD["new"]` and return `"MODIFY"`. Compare OLD/NEW; ignore `search`.
47
+ - **`functions.call(plpy, "Schema.fn", args, argtypes)`** — compose through
48
+ SQL with **Postgres** bind types. Text binds to a typed signature do not
49
+ resolve.
50
+ - **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
51
+
52
+ Pin **name and version in separate fields** (`aflib` / `0.0.6`), not
53
+ `aflib==0.0.6` as one string.
54
+
55
+ ## Core API (0.0.1–0.0.3, still in 0.0.6)
29
56
 
30
57
  ```python
31
58
  from aflib import Db, errors, fail, logging, ok, params
@@ -76,6 +103,9 @@ except Exception as exc:
76
103
  present, written through `plpy.log`. It never raises, even when handed a value that cannot
77
104
  be encoded: the line is most needed in the error path, so a single exotic field must not be
78
105
  able to silence it.
106
+ - **`transaction.atomic(plpy)`** — a savepoint that also rolls back a *returned* failure. See
107
+ [A returned failure does not roll back](#a-returned-failure-does-not-roll-back) — this is a
108
+ narrow helper for one specific trap, not a unit of work.
79
109
 
80
110
  ## Platform constraints worth knowing before you build on it
81
111
 
@@ -84,8 +114,9 @@ the library has the shape it does.
84
114
 
85
115
  - **Every HTTP-facing parameter must be declared `text`.** Declare `integer` and the platform
86
116
  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.
117
+ *function does not exist*. A `boolean` parameter binds `true`/`false`, but `?p_flag=maybe`
118
+ is HTTP 500 `Cannot cast to boolean` before the body runs. Convert with `params` inside
119
+ the body.
89
120
  - **Function bodies compose only through SQL** — `SELECT "Schema"."fn"(…)`. There is no
90
121
  shared module and no package-local import, so a helper another body needs is a database
91
122
  function, not a Python one.
@@ -128,6 +159,51 @@ Two related facts worth knowing before you design around logging:
128
159
  *type* does not. So a business outcome has to travel as a returned value — `ok`/`fail` — and
129
160
  never as an exception, and nothing sensitive may ever go into an exception message.
130
161
 
162
+ ### A returned failure does not roll back
163
+
164
+ A function body is **one transaction it does not control.** `plpy.commit()` fails with *invalid
165
+ transaction termination*, because a function invoked inside a query cannot terminate the
166
+ transaction it is running in. What a body *can* do is subdivide that transaction with savepoints
167
+ via `plpy.subtransaction()`, and those work exactly as you would hope: they roll back on any
168
+ exception, Python or SQL alike, the original exception continues unchanged, nesting rolls back
169
+ only the inner block, and a trigger's writes roll back together with the row that fired them.
170
+
171
+ Two further rules complete the picture: **an uncaught exception discards the whole call**, and
172
+ **a normal return commits the whole call.**
173
+
174
+ That last one is the trap, because it applies to a returned *failure* too:
175
+
176
+ ```python
177
+ with plpy.subtransaction():
178
+ db.query("INSERT …")
179
+ return fail("not_allowed", "…") # the INSERT is COMMITTED
180
+ ```
181
+
182
+ `plpy.subtransaction()` reacts to exceptions, and a business failure is not an exception — it
183
+ cannot be, since an exception's type does not survive crossing a function boundary. So the most
184
+ natural code silently half-applies the change. `transaction.atomic` exists for exactly this:
185
+
186
+ ```python
187
+ from aflib import errors, transaction
188
+
189
+ try:
190
+ with transaction.atomic(plpy) as tx:
191
+ db.query("INSERT …")
192
+ if not allowed:
193
+ tx.reject("not_allowed", "You may not do that") # rolls back
194
+ tx.succeed({"id": 7})
195
+ return tx.outcome
196
+ except Exception as exc:
197
+ return errors.to_fail(exc, log=log)
198
+ ```
199
+
200
+ `reject()` ends the block and rolls back; `outcome` is read afterwards. A scope that ends
201
+ without `succeed()` or `reject()` raises rather than returning an empty success for work that
202
+ may have half happened.
203
+
204
+ It is deliberately **not** a unit of work and cannot commit anything — the name describes the
205
+ guarantee it provides (the block is all-or-nothing), not machinery it does not have.
206
+
131
207
  ## Tests
132
208
 
133
209
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aflib"
3
- version = "0.0.2"
3
+ version = "0.0.6"
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,14 +24,26 @@ 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
+ codec,
37
+ db,
38
+ errors,
39
+ functions,
40
+ identity,
41
+ logging,
42
+ params,
43
+ result,
44
+ transaction,
45
+ trigger,
46
+ )
35
47
  from aflib.db import Db
36
48
  from aflib.params import BadParameter
37
49
  from aflib.result import fail, ok
@@ -39,18 +51,23 @@ from aflib.result import fail, ok
39
51
  # Kept in step with `pyproject.toml` by hand. A deployed body returns this so a run can
40
52
  # prove which aflib it actually imported — a stale package otherwise looks exactly like a
41
53
  # working one.
42
- __version__ = "0.0.2"
54
+ __version__ = "0.0.6"
43
55
 
44
56
  __all__ = [
45
57
  "BadParameter",
46
58
  "Db",
59
+ "authz",
47
60
  "codec",
48
61
  "db",
49
62
  "errors",
50
63
  "fail",
64
+ "functions",
65
+ "identity",
51
66
  "logging",
52
67
  "ok",
53
68
  "params",
54
69
  "result",
70
+ "transaction",
71
+ "trigger",
55
72
  "__version__",
56
73
  ]
@@ -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)
@@ -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)
@@ -0,0 +1,72 @@
1
+ """Call another Airflows function with real Postgres types.
2
+
3
+ Bodies cannot import each other. The only composition channel is SQL:
4
+ `SELECT "Schema"."fn"(…)`. HTTP binds every query argument as `varchar`,
5
+ so a helper that prepared `text` for convenience would make every typed
6
+ callee unreachable — observed 2026-09-17: `aflib_probe_typed(integer,
7
+ numeric, jsonb, text)` succeeded with those binds and failed with four
8
+ `text` binds (`function does not exist`).
9
+
10
+ from aflib import functions
11
+
12
+ raw = functions.call(
13
+ plpy,
14
+ "Master.aflib_probe_typed",
15
+ [42, "1.50", '{"k": 1}', "hello"],
16
+ ["integer", "numeric", "jsonb", "text"],
17
+ )
18
+
19
+ The return is the first column of the first row (`AS r` in the generated
20
+ SELECT), or `None` if the statement returned nothing. This does not
21
+ decode JSON: if the callee returned an envelope string, the caller
22
+ loads it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+
29
+ _IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
30
+
31
+
32
+ def _qualified(name: str) -> str:
33
+ parts = name.split(".")
34
+ if len(parts) != 2:
35
+ raise ValueError("expected Schema.function, got {!r}".format(name))
36
+ for part in parts:
37
+ if not _IDENT.match(part):
38
+ raise ValueError("invalid identifier {!r} in {!r}".format(part, name))
39
+ return '"{}"."{}"'.format(parts[0], parts[1])
40
+
41
+
42
+ def call(plpy, name, args, argtypes):
43
+ """`SELECT "Schema"."fn"($1, …)` with explicit bind types.
44
+
45
+ `args` and `argtypes` must be the same length. Types are Postgres
46
+ names (`integer`, `jsonb`, …), never inferred from Python values.
47
+ """
48
+ if args is None:
49
+ args = []
50
+ argtypes = []
51
+ if argtypes is None or len(args) != len(argtypes):
52
+ raise ValueError(
53
+ "a call with {} args needs {} argtypes, got {}".format(
54
+ len(args),
55
+ len(args),
56
+ 0 if argtypes is None else len(argtypes),
57
+ )
58
+ )
59
+ qualified = _qualified(name)
60
+ if not args:
61
+ sql = "SELECT {}() AS r".format(qualified)
62
+ result = plpy.execute(sql)
63
+ else:
64
+ placeholders = ", ".join(
65
+ "${}".format(i) for i in range(1, len(args) + 1)
66
+ )
67
+ sql = "SELECT {}({}) AS r".format(qualified, placeholders)
68
+ plan = plpy.prepare(sql, list(argtypes))
69
+ result = plpy.execute(plan, list(args))
70
+ if not result:
71
+ return None
72
+ return result[0]["r"]
@@ -0,0 +1,20 @@
1
+ """Who is running this body.
2
+
3
+ A plpython body has no request object. The platform sets the connection
4
+ role to a Postgres role named after the caller's username — observed:
5
+ `current_user` is the caller, not a GUC and not `getUserId()` (that
6
+ name did not exist on the instance, 2026-09-17).
7
+
8
+ from aflib import identity
9
+
10
+ who = identity.current_user(plpy)
11
+
12
+ This is the string. Mapping it to `Models.User` is a domain query, not
13
+ this module. Do not cache it in `GD`/`SD`.
14
+ """
15
+
16
+
17
+ def current_user(plpy) -> str:
18
+ """`SELECT current_user` — the connection role for this call."""
19
+ row = plpy.execute("SELECT current_user AS u")[0]
20
+ return row["u"]
@@ -29,9 +29,11 @@ them cannot reduce that exposure and only makes it look deliberate.
29
29
  import json
30
30
  from decimal import Decimal
31
31
 
32
- # `level` and `event` are how a line is found in a log that is grepped rather than
33
- # queried. A field silently overwriting either would break every search built on them.
34
- _RESERVED = ("level", "event")
32
+ # `level`, `event` and `source` are how a line is found in a log that is grepped
33
+ # rather than queried. A field silently overwriting any of them would break every
34
+ # search built on them. `source` is set on the logger, not per call; letting a
35
+ # field overwrite it would re-label the emitting function.
36
+ _RESERVED = ("level", "event", "source")
35
37
 
36
38
 
37
39
  def _lenient(value):
@@ -58,11 +58,23 @@ _FALSE = frozenset(("false", "0", "f"))
58
58
 
59
59
 
60
60
  def as_bool(value, name=None, required=False):
61
- """Convert `value` to a `bool`, or `None` when it was left blank."""
61
+ """Convert `value` to a `bool`, or `None` when it was left blank.
62
+
63
+ Declare the HTTP parameter `text`. A `boolean` customParameter never
64
+ reaches this converter on a typo: the HTTP layer returns 500
65
+ `Cannot cast to boolean: "maybe"` before the body runs (observed
66
+ 2026-09-18). `true`/`false` as a boolean parameter do bind as Python
67
+ `bool`; still use `text` so a bad flag is `BadParameter`, not a 500.
68
+ """
62
69
  if _blank(value):
63
70
  if required:
64
71
  _fail(name, "a value is required")
65
72
  return None
73
+ # A `boolean` customParameter on the non-HTTP path binds as a real Python
74
+ # bool. That is never blank, and converting it through as_bool must not
75
+ # reject a value that is already the right type.
76
+ if isinstance(value, bool):
77
+ return value
66
78
  folded = value.strip().lower() if isinstance(value, str) else value
67
79
  if folded in _TRUE:
68
80
  return True