aflib 0.0.7__tar.gz → 0.0.9__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.7 → aflib-0.0.9}/PKG-INFO +54 -14
- {aflib-0.0.7 → aflib-0.0.9}/README.md +53 -13
- {aflib-0.0.7 → aflib-0.0.9}/pyproject.toml +1 -1
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/__init__.py +5 -1
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/batch.py +17 -2
- aflib-0.0.9/src/aflib/config.py +64 -0
- aflib-0.0.9/src/aflib/http.py +113 -0
- aflib-0.0.9/src/aflib/identity.py +53 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/trigger.py +4 -3
- {aflib-0.0.7 → aflib-0.0.9}/tests/conftest.py +12 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_batch.py +20 -2
- aflib-0.0.9/tests/test_config.py +73 -0
- aflib-0.0.9/tests/test_http.py +131 -0
- aflib-0.0.9/tests/test_identity.py +57 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_trigger.py +9 -0
- aflib-0.0.7/src/aflib/identity.py +0 -20
- aflib-0.0.7/tests/test_identity.py +0 -11
- {aflib-0.0.7 → aflib-0.0.9}/.gitignore +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/LICENSE +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/authz.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/codec.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/db.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/errors.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/events.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/functions.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/logging.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/params.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/result.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/transaction.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/src/aflib/workflow.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_authz.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_codec.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_db.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_errors.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_events.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_functions.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_logging.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_params.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_public_api.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_result.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_transaction.py +0 -0
- {aflib-0.0.7 → aflib-0.0.9}/tests/test_workflow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aflib
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.9
|
|
4
4
|
Summary: Helper library for business logic inside Airflows plpython function bodies
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -19,23 +19,66 @@ same result envelope. aflib is that plumbing, written once.
|
|
|
19
19
|
|
|
20
20
|
**Status: early, and deliberately so.** Nothing enters this library until it has run on a
|
|
21
21
|
real Airflows instance — written first as a plain function body, deployed, called, and
|
|
22
|
-
observed.
|
|
22
|
+
observed. A version ships only capabilities that have each run there, which is why the numbers start
|
|
23
23
|
so low.
|
|
24
24
|
|
|
25
25
|
## Install
|
|
26
26
|
|
|
27
27
|
Airflows resolves Python packages from public PyPI by name and version only; there is no
|
|
28
|
-
local-import, git or private-index route. Declare
|
|
29
|
-
`
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
local-import, git or private-index route. Declare `aflib` in the instance's Python packages
|
|
29
|
+
pinned to an exact version (`0.0.9`). The exported package list may show the pin as
|
|
30
|
+
`aflib==0.0.9==0.0.9`: that is how Airflows stores a pinned version, not an error.
|
|
31
|
+
|
|
32
|
+
Then import it from a function body. To check which version a body really imports, return
|
|
33
|
+
`aflib.__version__` from it.
|
|
34
|
+
|
|
35
|
+
## What 0.0.9 gives you
|
|
36
|
+
|
|
37
|
+
- **`config.get(plpy, name, required=False, secret=False)`** — a platform variable, read with
|
|
38
|
+
the name bound as a query parameter. The platform's own `get_env_variable` pastes the name
|
|
39
|
+
into the SQL text, so a name containing `'` breaks or changes the query. A missing variable
|
|
40
|
+
is `None`; `required=True` raises `config.MissingConfig`, which `errors.to_fail` reports
|
|
41
|
+
as a plain internal error (the variable's name goes to the log, not to the caller).
|
|
42
|
+
`secret=True` returns a `config.Secret`: its `str`, `repr` and `format` say
|
|
43
|
+
`Secret('***')`, `json` refuses it, and `reveal()` is the only way to the value.
|
|
44
|
+
- **`http.request(method, url, *, timeout, headers=None, json_body=None, data=None)`** —
|
|
45
|
+
urllib with a **mandatory** timeout: nothing else can stop a body blocked on a socket, and
|
|
46
|
+
the socket has none by default. A 4xx/5xx comes back as a `Response` (`status`, `ok`,
|
|
47
|
+
`body`, `text`, `json()`); `raise_for_status()` and every network failure raise
|
|
48
|
+
`http.HttpError`, whose text names the method, host and status but **never the path or
|
|
49
|
+
query**, so a token in the URL cannot reach a log. Only `http://` and `https://` URLs.
|
|
50
|
+
Not built on the `requests` the platform bundles: its exceptions copy the full URL.
|
|
51
|
+
- **`identity.unscoped(plpy, role="modelsadmin")`** — run a block as another role and
|
|
52
|
+
`SET ROLE` back to the caller in `finally`, also when the block raises. Never `RESET ROLE`:
|
|
53
|
+
the session user is the connection pool's superuser login. Take authorization decisions
|
|
54
|
+
before the block.
|
|
55
|
+
- **`trigger.changed`** ignores the recursion flag under both spellings,
|
|
56
|
+
`update_by_trigger` and `updated_by_trigger`.
|
|
57
|
+
|
|
58
|
+
All three ran on a real instance on 2026-10-20 before release, exact source, called by a
|
|
59
|
+
non-admin user.
|
|
32
60
|
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
|
|
61
|
+
```python
|
|
62
|
+
from aflib import config, http, identity
|
|
63
|
+
|
|
64
|
+
base = config.get(plpy, "IAM_URL", required=True)
|
|
65
|
+
password = config.get(plpy, "IAM_ADMIN_PASSWORD", required=True, secret=True)
|
|
66
|
+
r = http.request("POST", base + "/token", timeout=10, json_body={"password": password.reveal()})
|
|
67
|
+
r.raise_for_status()
|
|
68
|
+
|
|
69
|
+
with identity.unscoped(plpy):
|
|
70
|
+
plpy.execute("...") # as modelsadmin; back to the caller afterwards
|
|
36
71
|
```
|
|
37
72
|
|
|
38
|
-
|
|
73
|
+
`config` queries as the calling user, so the variables must be readable by that user's role.
|
|
74
|
+
|
|
75
|
+
## What 0.0.8 fixes
|
|
76
|
+
|
|
77
|
+
`Batch.run` accepts a predicate whose column name contains the letters `_t`.
|
|
78
|
+
`id_tuition IS NOT NULL`, `start_time < now()` and `is_test = false` are
|
|
79
|
+
predicates. 0.0.7 refused them, because the guard searched for `_t` as a
|
|
80
|
+
substring. `_t.col` and `"_t".col` are still refused: that alias exists only on
|
|
81
|
+
the outer `UPDATE`.
|
|
39
82
|
|
|
40
83
|
## What 0.0.7 gives you
|
|
41
84
|
|
|
@@ -91,10 +134,7 @@ the 2026-09 platform probes showed, plus two verified fixes:
|
|
|
91
134
|
resolve.
|
|
92
135
|
- **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
|
|
93
136
|
|
|
94
|
-
|
|
95
|
-
`aflib==0.0.7` as one string.
|
|
96
|
-
|
|
97
|
-
## Core API (0.0.1–0.0.3, still in 0.0.7)
|
|
137
|
+
## Core API (0.0.1–0.0.3, still in 0.0.9)
|
|
98
138
|
|
|
99
139
|
```python
|
|
100
140
|
from aflib import Db, errors, fail, logging, ok, params
|
|
@@ -10,23 +10,66 @@ same result envelope. aflib is that plumbing, written once.
|
|
|
10
10
|
|
|
11
11
|
**Status: early, and deliberately so.** Nothing enters this library until it has run on a
|
|
12
12
|
real Airflows instance — written first as a plain function body, deployed, called, and
|
|
13
|
-
observed.
|
|
13
|
+
observed. A version ships only capabilities that have each run there, which is why the numbers start
|
|
14
14
|
so low.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
18
18
|
Airflows resolves Python packages from public PyPI by name and version only; there is no
|
|
19
|
-
local-import, git or private-index route. Declare
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
local-import, git or private-index route. Declare `aflib` in the instance's Python packages
|
|
20
|
+
pinned to an exact version (`0.0.9`). The exported package list may show the pin as
|
|
21
|
+
`aflib==0.0.9==0.0.9`: that is how Airflows stores a pinned version, not an error.
|
|
22
|
+
|
|
23
|
+
Then import it from a function body. To check which version a body really imports, return
|
|
24
|
+
`aflib.__version__` from it.
|
|
25
|
+
|
|
26
|
+
## What 0.0.9 gives you
|
|
27
|
+
|
|
28
|
+
- **`config.get(plpy, name, required=False, secret=False)`** — a platform variable, read with
|
|
29
|
+
the name bound as a query parameter. The platform's own `get_env_variable` pastes the name
|
|
30
|
+
into the SQL text, so a name containing `'` breaks or changes the query. A missing variable
|
|
31
|
+
is `None`; `required=True` raises `config.MissingConfig`, which `errors.to_fail` reports
|
|
32
|
+
as a plain internal error (the variable's name goes to the log, not to the caller).
|
|
33
|
+
`secret=True` returns a `config.Secret`: its `str`, `repr` and `format` say
|
|
34
|
+
`Secret('***')`, `json` refuses it, and `reveal()` is the only way to the value.
|
|
35
|
+
- **`http.request(method, url, *, timeout, headers=None, json_body=None, data=None)`** —
|
|
36
|
+
urllib with a **mandatory** timeout: nothing else can stop a body blocked on a socket, and
|
|
37
|
+
the socket has none by default. A 4xx/5xx comes back as a `Response` (`status`, `ok`,
|
|
38
|
+
`body`, `text`, `json()`); `raise_for_status()` and every network failure raise
|
|
39
|
+
`http.HttpError`, whose text names the method, host and status but **never the path or
|
|
40
|
+
query**, so a token in the URL cannot reach a log. Only `http://` and `https://` URLs.
|
|
41
|
+
Not built on the `requests` the platform bundles: its exceptions copy the full URL.
|
|
42
|
+
- **`identity.unscoped(plpy, role="modelsadmin")`** — run a block as another role and
|
|
43
|
+
`SET ROLE` back to the caller in `finally`, also when the block raises. Never `RESET ROLE`:
|
|
44
|
+
the session user is the connection pool's superuser login. Take authorization decisions
|
|
45
|
+
before the block.
|
|
46
|
+
- **`trigger.changed`** ignores the recursion flag under both spellings,
|
|
47
|
+
`update_by_trigger` and `updated_by_trigger`.
|
|
48
|
+
|
|
49
|
+
All three ran on a real instance on 2026-10-20 before release, exact source, called by a
|
|
50
|
+
non-admin user.
|
|
23
51
|
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
```python
|
|
53
|
+
from aflib import config, http, identity
|
|
54
|
+
|
|
55
|
+
base = config.get(plpy, "IAM_URL", required=True)
|
|
56
|
+
password = config.get(plpy, "IAM_ADMIN_PASSWORD", required=True, secret=True)
|
|
57
|
+
r = http.request("POST", base + "/token", timeout=10, json_body={"password": password.reveal()})
|
|
58
|
+
r.raise_for_status()
|
|
59
|
+
|
|
60
|
+
with identity.unscoped(plpy):
|
|
61
|
+
plpy.execute("...") # as modelsadmin; back to the caller afterwards
|
|
27
62
|
```
|
|
28
63
|
|
|
29
|
-
|
|
64
|
+
`config` queries as the calling user, so the variables must be readable by that user's role.
|
|
65
|
+
|
|
66
|
+
## What 0.0.8 fixes
|
|
67
|
+
|
|
68
|
+
`Batch.run` accepts a predicate whose column name contains the letters `_t`.
|
|
69
|
+
`id_tuition IS NOT NULL`, `start_time < now()` and `is_test = false` are
|
|
70
|
+
predicates. 0.0.7 refused them, because the guard searched for `_t` as a
|
|
71
|
+
substring. `_t.col` and `"_t".col` are still refused: that alias exists only on
|
|
72
|
+
the outer `UPDATE`.
|
|
30
73
|
|
|
31
74
|
## What 0.0.7 gives you
|
|
32
75
|
|
|
@@ -82,10 +125,7 @@ the 2026-09 platform probes showed, plus two verified fixes:
|
|
|
82
125
|
resolve.
|
|
83
126
|
- **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
|
|
84
127
|
|
|
85
|
-
|
|
86
|
-
`aflib==0.0.7` as one string.
|
|
87
|
-
|
|
88
|
-
## Core API (0.0.1–0.0.3, still in 0.0.7)
|
|
128
|
+
## Core API (0.0.1–0.0.3, still in 0.0.9)
|
|
89
129
|
|
|
90
130
|
```python
|
|
91
131
|
from aflib import Db, errors, fail, logging, ok, params
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aflib"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.9"
|
|
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).
|
|
@@ -35,10 +35,12 @@ from aflib import (
|
|
|
35
35
|
authz,
|
|
36
36
|
batch,
|
|
37
37
|
codec,
|
|
38
|
+
config,
|
|
38
39
|
db,
|
|
39
40
|
errors,
|
|
40
41
|
events,
|
|
41
42
|
functions,
|
|
43
|
+
http,
|
|
42
44
|
identity,
|
|
43
45
|
logging,
|
|
44
46
|
params,
|
|
@@ -56,7 +58,7 @@ from aflib.result import fail, ok
|
|
|
56
58
|
# Kept in step with `pyproject.toml` by hand. A deployed body returns this so a run can
|
|
57
59
|
# prove which aflib it actually imported — a stale package otherwise looks exactly like a
|
|
58
60
|
# working one.
|
|
59
|
-
__version__ = "0.0.
|
|
61
|
+
__version__ = "0.0.9"
|
|
60
62
|
|
|
61
63
|
__all__ = [
|
|
62
64
|
"BadParameter",
|
|
@@ -66,11 +68,13 @@ __all__ = [
|
|
|
66
68
|
"authz",
|
|
67
69
|
"batch",
|
|
68
70
|
"codec",
|
|
71
|
+
"config",
|
|
69
72
|
"db",
|
|
70
73
|
"errors",
|
|
71
74
|
"events",
|
|
72
75
|
"fail",
|
|
73
76
|
"functions",
|
|
77
|
+
"http",
|
|
74
78
|
"identity",
|
|
75
79
|
"logging",
|
|
76
80
|
"ok",
|
|
@@ -54,6 +54,7 @@ inside the environment's `statement_timeout` — 180 s on PRO, no limit on PRE,
|
|
|
54
54
|
cannot tell you whether a chunk fits**.
|
|
55
55
|
"""
|
|
56
56
|
|
|
57
|
+
import re
|
|
57
58
|
import time
|
|
58
59
|
|
|
59
60
|
from aflib.db import Db
|
|
@@ -88,6 +89,18 @@ class Progress(object):
|
|
|
88
89
|
self.done, self.remaining, self.complete)
|
|
89
90
|
|
|
90
91
|
|
|
92
|
+
def _alias_as_qualifier(alias, pending):
|
|
93
|
+
"""True when `pending` uses `alias` as a qualifier, not as letters inside a name.
|
|
94
|
+
|
|
95
|
+
Searching for the substring `_t` refuses `id_tuition IS NOT NULL`,
|
|
96
|
+
`start_time < now()` and `is_test = false`. Observed on PRE 2026-10-02,
|
|
97
|
+
acceptance probe `aflib-007-accept-r1`, case `batch`. The alias is a problem
|
|
98
|
+
only as `_t.col` or `"_t".col`, with optional space before the dot.
|
|
99
|
+
"""
|
|
100
|
+
pattern = r'(?<![\w"])"?{0}"?\s*\.'.format(re.escape(alias))
|
|
101
|
+
return re.search(pattern, pending) is not None
|
|
102
|
+
|
|
103
|
+
|
|
91
104
|
class Batch(object):
|
|
92
105
|
"""One committed chunk per call. `plpy` is injected, as everywhere in aflib."""
|
|
93
106
|
|
|
@@ -105,14 +118,16 @@ class Batch(object):
|
|
|
105
118
|
the outer `WHERE`, where Postgres resolves it against the update target, and
|
|
106
119
|
inside the subquery, where it resolves against the subquery's own `FROM`. Both
|
|
107
120
|
resolutions are what we want, and either qualification breaks one of them. A
|
|
108
|
-
predicate
|
|
121
|
+
predicate that qualifies a column with the alias (`_t.col` or `"_t".col`) is
|
|
122
|
+
rejected rather than silently misplaced. A column whose name merely contains
|
|
123
|
+
those letters — `id_tuition`, `start_time`, `is_test` — is not a qualification.
|
|
109
124
|
|
|
110
125
|
`order` defaults to `key`, which makes the work deterministic and the chunks
|
|
111
126
|
disjoint. Ordering by something non-unique would let two concurrent callers pick
|
|
112
127
|
overlapping sets; the row-level guard still keeps them correct, but they would
|
|
113
128
|
waste each other's work.
|
|
114
129
|
"""
|
|
115
|
-
if self.ALIAS
|
|
130
|
+
if _alias_as_qualifier(self.ALIAS, pending):
|
|
116
131
|
raise ValueError(
|
|
117
132
|
"`pending` must be unqualified: it is placed both in the outer WHERE and "
|
|
118
133
|
"inside the subquery, and %r resolves in only one of them." % self.ALIAS)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Platform variables, read with a bound query.
|
|
2
|
+
|
|
3
|
+
from aflib import config
|
|
4
|
+
|
|
5
|
+
base = config.get(plpy, "IAM_URL", required=True)
|
|
6
|
+
key = config.get(plpy, "IAM_ADMIN_PASSWORD", required=True, secret=True)
|
|
7
|
+
login(base, password=key.reveal())
|
|
8
|
+
|
|
9
|
+
**Why not `airflows.get_env_variable`.** On the platform it is an f-string: the name is
|
|
10
|
+
pasted inside the SQL text of a query on `"Models"."Variable"`, so a name containing `'`
|
|
11
|
+
breaks the query or changes its meaning (observed source, PRE 2026-10-20, probe
|
|
12
|
+
`aflib-edge-r1`). This module runs the same query with the name bound as a parameter.
|
|
13
|
+
|
|
14
|
+
**It runs as the caller.** The query uses the connection role, which is the calling user. A
|
|
15
|
+
director reads the variables (observed, same probe); other roles have not been tried.
|
|
16
|
+
|
|
17
|
+
**A missing variable is `None`**, as on the platform. `required=True` turns a missing, NULL
|
|
18
|
+
or blank value into `MissingConfig`, which `errors.to_fail` reports to the caller as a
|
|
19
|
+
plain internal error: a misconfiguration is never the caller's fault, and the variable's
|
|
20
|
+
name goes to the log, not to them.
|
|
21
|
+
|
|
22
|
+
**`secret=True` returns a `Secret`.** Its `str` and `repr` say `Secret('***')`, so it cannot
|
|
23
|
+
leak through a log line or an f-string; `json` and `codec` refuse to serialise it, so it
|
|
24
|
+
cannot leak into a response either. `reveal()` is the one way to the value, and the place
|
|
25
|
+
it is called is where the value leaves the body.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_SQL = 'SELECT "value" FROM "Models"."Variable" WHERE "name" = $1'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MissingConfig(Exception):
|
|
32
|
+
"""A required variable is absent, NULL or blank. Classified as an internal error."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Secret(object):
|
|
36
|
+
"""A value that must not be printed, logged or returned. `reveal()` gives it back."""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("_value",)
|
|
39
|
+
|
|
40
|
+
def __init__(self, value):
|
|
41
|
+
self._value = value
|
|
42
|
+
|
|
43
|
+
def reveal(self):
|
|
44
|
+
return self._value
|
|
45
|
+
|
|
46
|
+
def __repr__(self):
|
|
47
|
+
return "Secret('***')"
|
|
48
|
+
|
|
49
|
+
__str__ = __repr__
|
|
50
|
+
|
|
51
|
+
def __format__(self, spec):
|
|
52
|
+
return repr(self)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get(plpy, name, required=False, secret=False):
|
|
56
|
+
"""The value of variable `name`, or `None` when it does not exist."""
|
|
57
|
+
plan = plpy.prepare(_SQL, ["text"])
|
|
58
|
+
rows = plpy.execute(plan, [name])
|
|
59
|
+
value = rows[0]["value"] if len(rows) else None
|
|
60
|
+
if required and (value is None or not str(value).strip()):
|
|
61
|
+
raise MissingConfig("platform variable {!r} is missing or blank".format(name))
|
|
62
|
+
if value is None:
|
|
63
|
+
return None
|
|
64
|
+
return Secret(value) if secret else value
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Outbound HTTP from a function body: urllib, a mandatory timeout, errors without the URL.
|
|
2
|
+
|
|
3
|
+
from aflib import http
|
|
4
|
+
|
|
5
|
+
r = http.request("POST", url, timeout=10, headers={"Authorization": "Bearer " + tok},
|
|
6
|
+
json_body={"id": 7})
|
|
7
|
+
r.raise_for_status()
|
|
8
|
+
data = r.json()
|
|
9
|
+
|
|
10
|
+
**The timeout has no default.** Nothing else can stop a body waiting on a socket: Postgres
|
|
11
|
+
cancels a statement only when the body calls back into `plpy`, so `statement_timeout` and
|
|
12
|
+
`pg_cancel_backend` do not reach Python that is blocked or busy (G62). The socket's own
|
|
13
|
+
default is no timeout at all (observed on PRE 2026-10-20, probe `aflib-edge-r1`).
|
|
14
|
+
|
|
15
|
+
**urllib, not the `requests` bundled in `airflows`.** On the platform `requests` copies the
|
|
16
|
+
full URL, query string and token included, into the text of its exceptions; urllib does not
|
|
17
|
+
(same probe). Whatever an exception says ends up in a log line or, uncaught, in the HTTP
|
|
18
|
+
response. `HttpError` names the method, the host and the status — never the path or query.
|
|
19
|
+
|
|
20
|
+
**A 4xx/5xx is a `Response`, not an exception.** `raise_for_status()` turns it into an
|
|
21
|
+
`HttpError` when the caller wants that. Network failures (timeout, DNS, refused, reset)
|
|
22
|
+
always raise `HttpError`. `errors.to_fail` reports `HttpError` to the caller as a plain
|
|
23
|
+
internal error and logs its text.
|
|
24
|
+
|
|
25
|
+
**Only `http://` and `https://` URLs.** urllib also opens `file://` and `ftp://`, which from
|
|
26
|
+
a function body would read the database server's own files.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
33
|
+
|
|
34
|
+
# Module-level so tests can replace it; nothing else should.
|
|
35
|
+
_urlopen = urllib.request.urlopen
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HttpError(Exception):
|
|
39
|
+
"""`kind` is `timeout`, `connection` or `status`. The message never holds path or query."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, kind, method, host, status=None, reason=None):
|
|
42
|
+
self.kind = kind
|
|
43
|
+
self.status = status
|
|
44
|
+
text = "{} {}: {}".format(method, host, kind)
|
|
45
|
+
if status is not None:
|
|
46
|
+
text += " {}".format(status)
|
|
47
|
+
if reason:
|
|
48
|
+
text += " ({})".format(reason)
|
|
49
|
+
super().__init__(text)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Response(object):
|
|
53
|
+
def __init__(self, method, host, status, headers, body):
|
|
54
|
+
self._method = method
|
|
55
|
+
self._host = host
|
|
56
|
+
self.status = status
|
|
57
|
+
self.headers = headers
|
|
58
|
+
self.body = body
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def ok(self):
|
|
62
|
+
return 200 <= self.status < 300
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def text(self):
|
|
66
|
+
return self.body.decode("utf-8", "replace")
|
|
67
|
+
|
|
68
|
+
def json(self):
|
|
69
|
+
return json.loads(self.body)
|
|
70
|
+
|
|
71
|
+
def raise_for_status(self):
|
|
72
|
+
if not self.ok:
|
|
73
|
+
raise HttpError("status", self._method, self._host, status=self.status)
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def request(method, url, *, timeout, headers=None, json_body=None, data=None):
|
|
78
|
+
"""Send one request and return a `Response`. `timeout` is seconds per socket operation.
|
|
79
|
+
|
|
80
|
+
ponytail: the timeout bounds each connect/read, not the whole call; a server that
|
|
81
|
+
trickles bytes can exceed it. Add a total deadline if a real endpoint does that.
|
|
82
|
+
"""
|
|
83
|
+
if timeout is None or timeout <= 0:
|
|
84
|
+
raise ValueError("timeout must be a positive number of seconds")
|
|
85
|
+
parts = urllib.parse.urlsplit(url)
|
|
86
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
87
|
+
raise ValueError("only absolute http:// or https:// URLs are allowed")
|
|
88
|
+
if json_body is not None and data is not None:
|
|
89
|
+
raise ValueError("pass json_body or data, not both")
|
|
90
|
+
method = method.upper()
|
|
91
|
+
host = parts.hostname
|
|
92
|
+
send = dict(headers or {})
|
|
93
|
+
if json_body is not None:
|
|
94
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
95
|
+
send.setdefault("Content-Type", "application/json")
|
|
96
|
+
req = urllib.request.Request(url, data=data, headers=send, method=method)
|
|
97
|
+
try:
|
|
98
|
+
with _urlopen(req, timeout=timeout) as resp:
|
|
99
|
+
return Response(method, host, resp.status, dict(resp.headers), resp.read())
|
|
100
|
+
except urllib.error.HTTPError as exc:
|
|
101
|
+
# A status answer, not a failure to talk: hand it back like any other response.
|
|
102
|
+
return Response(method, host, exc.code, dict(exc.headers or {}), exc.read())
|
|
103
|
+
except (TimeoutError, OSError, urllib.error.URLError) as exc:
|
|
104
|
+
kind = "timeout" if _is_timeout(exc) else "connection"
|
|
105
|
+
# `from None`: the original's text is safe today, but a chained exception would be
|
|
106
|
+
# printed with any traceback, so it is dropped rather than trusted.
|
|
107
|
+
raise HttpError(kind, method, host, reason=type(exc).__name__) from None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_timeout(exc):
|
|
111
|
+
if isinstance(exc, TimeoutError):
|
|
112
|
+
return True
|
|
113
|
+
return isinstance(getattr(exc, "reason", None), TimeoutError)
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
`unscoped` runs a block as another role and always comes back:
|
|
16
|
+
|
|
17
|
+
with identity.unscoped(plpy) as caller:
|
|
18
|
+
plpy.execute(...) # as modelsadmin
|
|
19
|
+
# back to the caller, also when the block raised
|
|
20
|
+
|
|
21
|
+
Take every authorization decision **before** the block (`authz.snapshot`), while the
|
|
22
|
+
connection is still the caller.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def current_user(plpy) -> str:
|
|
29
|
+
"""`SELECT current_user` — the connection role for this call."""
|
|
30
|
+
row = plpy.execute("SELECT current_user AS u")[0]
|
|
31
|
+
return row["u"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextlib.contextmanager
|
|
35
|
+
def unscoped(plpy, role="modelsadmin"):
|
|
36
|
+
"""Run the block as `role`, then `SET ROLE` back to the caller, whatever happens.
|
|
37
|
+
|
|
38
|
+
`SET ROLE` lasts for the session, and an HTTP call runs on a pooled connection: a role
|
|
39
|
+
left behind is the role of the next request on that connection. So the restore is in
|
|
40
|
+
`finally`, and it names the caller captured on entry. Never `RESET ROLE`: the session
|
|
41
|
+
user is the pool's login role, `postgres` (G15), so a reset would leave a superuser.
|
|
42
|
+
|
|
43
|
+
Both names go through `plpy.quote_ident`. Observed on PRE 2026-10-20 (probe
|
|
44
|
+
`aflib-edge-r1`), called by a director: elevate and restore after a normal exit, a
|
|
45
|
+
Python error, an SQL error (the transaction stayed usable), nesting, and a hostile
|
|
46
|
+
role name, which became a role that does not exist.
|
|
47
|
+
"""
|
|
48
|
+
caller = current_user(plpy)
|
|
49
|
+
plpy.execute("SET ROLE " + plpy.quote_ident(role))
|
|
50
|
+
try:
|
|
51
|
+
yield caller
|
|
52
|
+
finally:
|
|
53
|
+
plpy.execute("SET ROLE " + plpy.quote_ident(caller))
|
|
@@ -9,13 +9,14 @@ This module does not dispatch. The function body (or a plpgsql stub that
|
|
|
9
9
|
two things every UPDATE handler needs: which event fired, and which
|
|
10
10
|
columns actually changed.
|
|
11
11
|
|
|
12
|
-
Noise columns (`search
|
|
13
|
-
search-index rewrite does not look like a business change.
|
|
12
|
+
Noise columns (`search` and the recursion flag) are ignored by default so a
|
|
13
|
+
search-index rewrite does not look like a business change. The flag is spelled
|
|
14
|
+
`update_by_trigger` on some entities and `updated_by_trigger` on others; both are noise.
|
|
14
15
|
"""
|
|
15
16
|
|
|
16
17
|
from __future__ import annotations
|
|
17
18
|
|
|
18
|
-
DEFAULT_IGNORE = frozenset({"search", "update_by_trigger"})
|
|
19
|
+
DEFAULT_IGNORE = frozenset({"search", "update_by_trigger", "updated_by_trigger"})
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def event(td: dict) -> str:
|
|
@@ -116,6 +116,18 @@ class FakePlpy:
|
|
|
116
116
|
# make that mistake fail a test rather than pass one.
|
|
117
117
|
raise FakeError(message)
|
|
118
118
|
|
|
119
|
+
def quote_ident(self, name):
|
|
120
|
+
"""Postgres' rule: quote only when the name is not a plain lowercase identifier.
|
|
121
|
+
|
|
122
|
+
On PRE `quote_ident("modelsadmin; SELECT 1")` became a role name that does not
|
|
123
|
+
exist, so the statement failed instead of running (probe aflib-edge-r1).
|
|
124
|
+
"""
|
|
125
|
+
import re
|
|
126
|
+
|
|
127
|
+
if re.fullmatch(r"[a-z_][a-z0-9_$]*", name):
|
|
128
|
+
return name
|
|
129
|
+
return '"' + name.replace('"', '""') + '"'
|
|
130
|
+
|
|
119
131
|
def subtransaction(self):
|
|
120
132
|
"""Stands in for `PLySubtransaction`.
|
|
121
133
|
|
|
@@ -66,13 +66,31 @@ class TestThePredicateIsPlacedTwice:
|
|
|
66
66
|
where = sql.split("SET")[1].split("AND")[0]
|
|
67
67
|
assert PENDING in where, "the first predicate must sit in the UPDATE's own WHERE"
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
@pytest.mark.parametrize("pending", [
|
|
70
|
+
"_t.id_status = $1",
|
|
71
|
+
'"_t".estado = \'pending\'',
|
|
72
|
+
'"_t" . estado = \'pending\'',
|
|
73
|
+
"_t . id = 1",
|
|
74
|
+
])
|
|
75
|
+
def test_a_qualified_predicate_is_refused_rather_than_misplaced(self, make_plpy, pending):
|
|
70
76
|
# It would resolve in the outer WHERE and break inside the subquery, so the
|
|
71
77
|
# helper must not accept it and quietly produce a statement that fails at runtime.
|
|
78
|
+
# The quoted form is what the PRE acceptance probe sends.
|
|
72
79
|
with pytest.raises(ValueError) as exc:
|
|
73
|
-
Batch(make_plpy()).run(TABLE, SET,
|
|
80
|
+
Batch(make_plpy()).run(TABLE, SET, pending)
|
|
74
81
|
assert "unqualified" in str(exc.value)
|
|
75
82
|
|
|
83
|
+
@pytest.mark.parametrize("pending", [
|
|
84
|
+
"id_tuition IS NOT NULL", "start_time < now()", "is_test = false",
|
|
85
|
+
])
|
|
86
|
+
def test_a_column_containing_the_alias_text_is_not_a_qualification(self, make_plpy, pending):
|
|
87
|
+
# Failing on 0.0.7: the guard looks for the substring "_t" anywhere, so every
|
|
88
|
+
# column containing it is refused. Observed on PRE 2026-10-02, probe
|
|
89
|
+
# aflib-007-accept-r1, case `batch`: `id_tuition IS NOT NULL` raised ValueError.
|
|
90
|
+
fake = _plpy(make_plpy, 0, 0)
|
|
91
|
+
Batch(fake).run(TABLE, SET, pending)
|
|
92
|
+
assert _sql(fake.executed[0]).count(pending) == 2
|
|
93
|
+
|
|
76
94
|
|
|
77
95
|
class TestChunkBoundaries:
|
|
78
96
|
def test_limit_bounds_the_subquery_not_the_update(self, make_plpy):
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""config.get reads a platform variable with a bound query. Observed on PRE, aflib-edge-r1."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from aflib import codec, config, errors, logging
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_reads_the_value_with_the_name_bound_not_interpolated(make_plpy):
|
|
11
|
+
fake = make_plpy(rows=[{"value": "https://iam.example"}])
|
|
12
|
+
assert config.get(fake, "IAM_URL") == "https://iam.example"
|
|
13
|
+
_, plan_sql, types = fake.prepared[0]
|
|
14
|
+
assert "$1" in plan_sql and '"Models"."Variable"' in plan_sql
|
|
15
|
+
assert types == ("text",)
|
|
16
|
+
assert fake.executed[0][1] == ["IAM_URL"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_a_name_with_a_quote_is_only_ever_a_bound_value(make_plpy):
|
|
20
|
+
# The platform's own get_env_variable puts the name inside the SQL text (G92).
|
|
21
|
+
fake = make_plpy(rows=[], colnames=["value"])
|
|
22
|
+
assert config.get(fake, "X' OR '1'='1") is None
|
|
23
|
+
_, plan_sql, _ = fake.prepared[0]
|
|
24
|
+
assert "OR" not in plan_sql
|
|
25
|
+
assert fake.executed[0][1] == ["X' OR '1'='1"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_a_missing_variable_is_none(make_plpy):
|
|
29
|
+
assert config.get(make_plpy(rows=[], colnames=["value"]), "NOPE") is None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize("rows", [[], [{"value": None}], [{"value": " "}]])
|
|
33
|
+
def test_required_refuses_missing_null_and_blank(make_plpy, rows):
|
|
34
|
+
fake = make_plpy(rows=rows, colnames=["value"])
|
|
35
|
+
with pytest.raises(config.MissingConfig) as info:
|
|
36
|
+
config.get(fake, "IAM_URL", required=True)
|
|
37
|
+
assert "IAM_URL" in str(info.value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_a_missing_required_variable_is_an_internal_error_for_the_caller(make_plpy):
|
|
41
|
+
# Misconfiguration is never the caller's fault, and the variable's name is not theirs
|
|
42
|
+
# to know: it goes to the log only.
|
|
43
|
+
fake = make_plpy(rows=[], colnames=["value"])
|
|
44
|
+
log = logging.logger(fake, source="Demo.fn")
|
|
45
|
+
try:
|
|
46
|
+
config.get(fake, "IAM_URL", required=True)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
envelope = errors.to_fail(exc, log=log)
|
|
49
|
+
assert json.loads(envelope)["error"]["code"] == "internal_error"
|
|
50
|
+
assert "IAM_URL" not in envelope
|
|
51
|
+
assert "IAM_URL" in fake.logged[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_a_secret_hides_its_value_from_str_repr_and_logs(make_plpy):
|
|
55
|
+
fake = make_plpy(rows=[{"value": "s3cr3t"}])
|
|
56
|
+
key = config.get(fake, "API_KEY", secret=True)
|
|
57
|
+
assert key.reveal() == "s3cr3t"
|
|
58
|
+
assert "s3cr3t" not in str(key) and "s3cr3t" not in repr(key)
|
|
59
|
+
assert "s3cr3t" not in "{}".format(key) and "s3cr3t" not in "%s" % (key,)
|
|
60
|
+
logging.logger(fake, source="Demo.fn").info("called", key=key)
|
|
61
|
+
assert "s3cr3t" not in fake.logged[0]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_a_secret_cannot_be_serialised_into_a_response(make_plpy):
|
|
65
|
+
key = config.get(make_plpy(rows=[{"value": "s3cr3t"}]), "API_KEY", secret=True)
|
|
66
|
+
with pytest.raises(TypeError):
|
|
67
|
+
codec.dumps({"key": key})
|
|
68
|
+
with pytest.raises(TypeError):
|
|
69
|
+
json.dumps({"key": key})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_a_missing_secret_is_none_not_an_empty_secret(make_plpy):
|
|
73
|
+
assert config.get(make_plpy(rows=[], colnames=["value"]), "API_KEY", secret=True) is None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""http: urllib with a mandatory timeout, and errors that never carry the URL. Probe aflib-edge-r1."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from aflib import errors, http, logging
|
|
10
|
+
|
|
11
|
+
TOKEN = "s3cr3ttoken"
|
|
12
|
+
URL = "https://api.example/v1/things?token=" + TOKEN
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FakeResponse:
|
|
16
|
+
def __init__(self, status=200, body=b"", headers=None):
|
|
17
|
+
self.status = status
|
|
18
|
+
self.headers = headers or {}
|
|
19
|
+
self._body = body
|
|
20
|
+
|
|
21
|
+
def read(self):
|
|
22
|
+
return self._body
|
|
23
|
+
|
|
24
|
+
def __enter__(self):
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
def __exit__(self, *exc):
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture()
|
|
32
|
+
def opened(monkeypatch):
|
|
33
|
+
"""Records each urlopen call; `opened.reply` decides what it returns or raises."""
|
|
34
|
+
calls = []
|
|
35
|
+
|
|
36
|
+
def fake_urlopen(request, timeout=None):
|
|
37
|
+
calls.append({"request": request, "timeout": timeout})
|
|
38
|
+
reply = calls_reply[0]
|
|
39
|
+
if isinstance(reply, BaseException):
|
|
40
|
+
raise reply
|
|
41
|
+
return reply
|
|
42
|
+
|
|
43
|
+
calls_reply = [FakeResponse(200, b'{"a": 1}')]
|
|
44
|
+
monkeypatch.setattr(http, "_urlopen", fake_urlopen)
|
|
45
|
+
|
|
46
|
+
class Opened(list):
|
|
47
|
+
def reply(self, value):
|
|
48
|
+
calls_reply[0] = value
|
|
49
|
+
|
|
50
|
+
opened = Opened()
|
|
51
|
+
opened.calls = calls
|
|
52
|
+
return opened
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_the_timeout_is_mandatory(opened):
|
|
56
|
+
with pytest.raises(TypeError):
|
|
57
|
+
http.request("GET", URL)
|
|
58
|
+
for bad in (None, 0, -1):
|
|
59
|
+
with pytest.raises(ValueError):
|
|
60
|
+
http.request("GET", URL, timeout=bad)
|
|
61
|
+
assert opened.calls == []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://host/x", "/relative", "api.example/x"])
|
|
65
|
+
def test_only_http_and_https_urls_are_opened(opened, url):
|
|
66
|
+
# urllib would open file:// and ftp:// too; from a body that reads the database server.
|
|
67
|
+
with pytest.raises(ValueError):
|
|
68
|
+
http.request("GET", url, timeout=5)
|
|
69
|
+
assert opened.calls == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_sends_method_headers_json_body_and_the_timeout(opened):
|
|
73
|
+
r = http.request("POST", URL, timeout=7, headers={"Authorization": "Bearer x"},
|
|
74
|
+
json_body={"n": 1})
|
|
75
|
+
req = opened.calls[0]["request"]
|
|
76
|
+
assert opened.calls[0]["timeout"] == 7
|
|
77
|
+
assert req.get_method() == "POST"
|
|
78
|
+
assert req.get_header("Authorization") == "Bearer x"
|
|
79
|
+
assert req.get_header("Content-type") == "application/json"
|
|
80
|
+
assert json.loads(req.data) == {"n": 1}
|
|
81
|
+
assert (r.status, r.ok, r.json(), r.text) == (200, True, {"a": 1}, '{"a": 1}')
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_json_body_and_data_are_exclusive(opened):
|
|
85
|
+
with pytest.raises(ValueError):
|
|
86
|
+
http.request("POST", URL, timeout=5, json_body={}, data=b"x")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_an_http_error_status_is_a_response_not_an_exception(opened):
|
|
90
|
+
opened.reply(urllib.error.HTTPError(URL, 503, "SERVICE UNAVAILABLE", {}, io.BytesIO(b"down")))
|
|
91
|
+
r = http.request("GET", URL, timeout=5)
|
|
92
|
+
assert (r.status, r.ok, r.body) == (503, False, b"down")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_raise_for_status_names_status_and_host_only(opened):
|
|
96
|
+
opened.reply(urllib.error.HTTPError(URL, 503, "SERVICE UNAVAILABLE", {}, io.BytesIO(b"")))
|
|
97
|
+
r = http.request("GET", URL, timeout=5)
|
|
98
|
+
with pytest.raises(http.HttpError) as info:
|
|
99
|
+
r.raise_for_status()
|
|
100
|
+
text = str(info.value)
|
|
101
|
+
assert "503" in text and "api.example" in text
|
|
102
|
+
assert TOKEN not in text and "/v1/things" not in text
|
|
103
|
+
assert info.value.kind == "status" and info.value.status == 503
|
|
104
|
+
assert http.request("GET", URL, timeout=5).status == 503 # 2xx-only check is opt-in
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("raised, kind", [
|
|
108
|
+
(TimeoutError("The read operation timed out"), "timeout"),
|
|
109
|
+
(urllib.error.URLError("[Errno -2] Name or service not known"), "connection"),
|
|
110
|
+
(ConnectionResetError("reset by peer"), "connection"),
|
|
111
|
+
])
|
|
112
|
+
def test_network_failures_raise_http_error_without_the_url(opened, raised, kind):
|
|
113
|
+
opened.reply(raised)
|
|
114
|
+
with pytest.raises(http.HttpError) as info:
|
|
115
|
+
http.request("GET", URL, timeout=2)
|
|
116
|
+
assert info.value.kind == kind
|
|
117
|
+
assert TOKEN not in str(info.value) and "/v1/things" not in str(info.value)
|
|
118
|
+
# The original exception is dropped, so no traceback can print it either.
|
|
119
|
+
assert info.value.__cause__ is None and info.value.__suppress_context__
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_an_http_error_reaches_the_caller_as_an_internal_error(opened, make_plpy):
|
|
123
|
+
opened.reply(TimeoutError("timed out"))
|
|
124
|
+
fake = make_plpy()
|
|
125
|
+
try:
|
|
126
|
+
http.request("GET", URL, timeout=2)
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
envelope = errors.to_fail(exc, log=logging.logger(fake, source="Demo.fn"))
|
|
129
|
+
assert json.loads(envelope)["error"]["code"] == "internal_error"
|
|
130
|
+
assert TOKEN not in envelope and TOKEN not in fake.logged[0]
|
|
131
|
+
assert "api.example" in fake.logged[0]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""current_user is the caller. Observed on PRE; getUserId() was absent."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from aflib import identity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_current_user_reads_the_sql_builtin(make_plpy):
|
|
9
|
+
fake = make_plpy(rows=[{"u": "alice"}], colnames=["u"])
|
|
10
|
+
assert identity.current_user(fake) == "alice"
|
|
11
|
+
sql, args = fake.executed[0]
|
|
12
|
+
assert "current_user" in sql
|
|
13
|
+
assert args is None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _role_fake(make_plpy, caller="javier_director"):
|
|
17
|
+
# Each statement gets its own result: current_user, then the two SET ROLEs.
|
|
18
|
+
return make_plpy(results=[([{"u": caller}], ["u"])] + [([], [])] * 4)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _statements(fake):
|
|
22
|
+
return [sql for sql, _ in fake.executed]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_unscoped_elevates_and_restores_the_caller(make_plpy):
|
|
26
|
+
fake = _role_fake(make_plpy)
|
|
27
|
+
with identity.unscoped(fake) as caller:
|
|
28
|
+
assert caller == "javier_director"
|
|
29
|
+
assert _statements(fake)[-1] == "SET ROLE modelsadmin"
|
|
30
|
+
assert _statements(fake)[-1] == "SET ROLE javier_director"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("boom", [ValueError("python"), RuntimeError("sql stand-in")])
|
|
34
|
+
def test_unscoped_restores_the_caller_when_the_block_raises(make_plpy, boom):
|
|
35
|
+
fake = _role_fake(make_plpy)
|
|
36
|
+
with pytest.raises(type(boom)) as info:
|
|
37
|
+
with identity.unscoped(fake):
|
|
38
|
+
raise boom
|
|
39
|
+
assert info.value is boom
|
|
40
|
+
assert _statements(fake)[-1] == "SET ROLE javier_director"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_unscoped_never_resets_the_role(make_plpy):
|
|
44
|
+
# session_user is the pool's login role, postgres (G15): RESET ROLE would leave the
|
|
45
|
+
# connection as a superuser.
|
|
46
|
+
fake = _role_fake(make_plpy)
|
|
47
|
+
with identity.unscoped(fake):
|
|
48
|
+
pass
|
|
49
|
+
assert not any("RESET" in sql.upper() for sql in _statements(fake))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_unscoped_quotes_both_role_names(make_plpy):
|
|
53
|
+
fake = _role_fake(make_plpy, caller='Odd"Name')
|
|
54
|
+
with identity.unscoped(fake, role="modelsadmin; SELECT 1"):
|
|
55
|
+
pass
|
|
56
|
+
assert _statements(fake)[1] == 'SET ROLE "modelsadmin; SELECT 1"'
|
|
57
|
+
assert _statements(fake)[-1] == 'SET ROLE "Odd""Name"'
|
|
@@ -37,6 +37,15 @@ def test_search_and_update_by_trigger_are_noise_by_default():
|
|
|
37
37
|
assert changed(td) == set()
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def test_updated_by_trigger_spelling_is_noise_too():
|
|
41
|
+
# Both spellings exist in the consuming model, three entities each.
|
|
42
|
+
td = {
|
|
43
|
+
"old": {"id_status": 22, "updated_by_trigger": False},
|
|
44
|
+
"new": {"id_status": 22, "updated_by_trigger": True},
|
|
45
|
+
}
|
|
46
|
+
assert changed(td) == set()
|
|
47
|
+
|
|
48
|
+
|
|
40
49
|
def test_insert_treats_new_columns_as_changed():
|
|
41
50
|
td = {"event": "INSERT", "old": None, "new": {"id": 1, "search": "x"}}
|
|
42
51
|
assert changed(td) == {"id"}
|
|
@@ -1,20 +0,0 @@
|
|
|
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"]
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
"""current_user is the caller. Observed on PRE; getUserId() was absent."""
|
|
2
|
-
|
|
3
|
-
from aflib import identity
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_current_user_reads_the_sql_builtin(make_plpy):
|
|
7
|
-
fake = make_plpy(rows=[{"u": "alice"}], colnames=["u"])
|
|
8
|
-
assert identity.current_user(fake) == "alice"
|
|
9
|
-
sql, args = fake.executed[0]
|
|
10
|
-
assert "current_user" in sql
|
|
11
|
-
assert args is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|