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.
- {aflib-0.0.2 → aflib-0.0.6}/PKG-INFO +83 -7
- {aflib-0.0.2 → aflib-0.0.6}/README.md +81 -5
- {aflib-0.0.2 → aflib-0.0.6}/pyproject.toml +8 -5
- {aflib-0.0.2 → aflib-0.0.6}/src/aflib/__init__.py +21 -4
- aflib-0.0.6/src/aflib/authz.py +86 -0
- {aflib-0.0.2 → aflib-0.0.6}/src/aflib/errors.py +4 -6
- aflib-0.0.6/src/aflib/functions.py +72 -0
- aflib-0.0.6/src/aflib/identity.py +20 -0
- {aflib-0.0.2 → aflib-0.0.6}/src/aflib/logging.py +5 -3
- {aflib-0.0.2 → aflib-0.0.6}/src/aflib/params.py +13 -1
- aflib-0.0.6/src/aflib/transaction.py +141 -0
- aflib-0.0.6/src/aflib/trigger.py +51 -0
- {aflib-0.0.2 → aflib-0.0.6}/tests/conftest.py +49 -8
- aflib-0.0.6/tests/test_authz.py +41 -0
- {aflib-0.0.2 → aflib-0.0.6}/tests/test_db.py +6 -6
- {aflib-0.0.2 → aflib-0.0.6}/tests/test_errors.py +8 -9
- aflib-0.0.6/tests/test_functions.py +50 -0
- aflib-0.0.6/tests/test_identity.py +11 -0
- {aflib-0.0.2 → aflib-0.0.6}/tests/test_logging.py +24 -5
- {aflib-0.0.2 → aflib-0.0.6}/tests/test_params.py +20 -0
- {aflib-0.0.2 → aflib-0.0.6}/tests/test_public_api.py +27 -1
- {aflib-0.0.2 → aflib-0.0.6}/tests/test_result.py +3 -3
- aflib-0.0.6/tests/test_transaction.py +193 -0
- aflib-0.0.6/tests/test_trigger.py +42 -0
- {aflib-0.0.2 → aflib-0.0.6}/.gitignore +0 -0
- {aflib-0.0.2 → aflib-0.0.6}/LICENSE +0 -0
- {aflib-0.0.2 → aflib-0.0.6}/src/aflib/codec.py +0 -0
- {aflib-0.0.2 → aflib-0.0.6}/src/aflib/db.py +0 -0
- {aflib-0.0.2 → aflib-0.0.6}/src/aflib/result.py +0 -0
- {aflib-0.0.2 → aflib-0.0.6}/tests/test_codec.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: aflib
|
|
3
|
-
Version: 0.0.
|
|
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
|
-
|
|
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.
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
88
|
-
|
|
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.
|
|
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
|
|
28
|
-
|
|
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
|
|
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.
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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 `
|
|
33
|
-
# queried. A field silently overwriting
|
|
34
|
-
|
|
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
|