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.
Files changed (42) hide show
  1. {aflib-0.0.7 → aflib-0.0.9}/PKG-INFO +54 -14
  2. {aflib-0.0.7 → aflib-0.0.9}/README.md +53 -13
  3. {aflib-0.0.7 → aflib-0.0.9}/pyproject.toml +1 -1
  4. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/__init__.py +5 -1
  5. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/batch.py +17 -2
  6. aflib-0.0.9/src/aflib/config.py +64 -0
  7. aflib-0.0.9/src/aflib/http.py +113 -0
  8. aflib-0.0.9/src/aflib/identity.py +53 -0
  9. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/trigger.py +4 -3
  10. {aflib-0.0.7 → aflib-0.0.9}/tests/conftest.py +12 -0
  11. {aflib-0.0.7 → aflib-0.0.9}/tests/test_batch.py +20 -2
  12. aflib-0.0.9/tests/test_config.py +73 -0
  13. aflib-0.0.9/tests/test_http.py +131 -0
  14. aflib-0.0.9/tests/test_identity.py +57 -0
  15. {aflib-0.0.7 → aflib-0.0.9}/tests/test_trigger.py +9 -0
  16. aflib-0.0.7/src/aflib/identity.py +0 -20
  17. aflib-0.0.7/tests/test_identity.py +0 -11
  18. {aflib-0.0.7 → aflib-0.0.9}/.gitignore +0 -0
  19. {aflib-0.0.7 → aflib-0.0.9}/LICENSE +0 -0
  20. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/authz.py +0 -0
  21. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/codec.py +0 -0
  22. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/db.py +0 -0
  23. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/errors.py +0 -0
  24. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/events.py +0 -0
  25. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/functions.py +0 -0
  26. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/logging.py +0 -0
  27. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/params.py +0 -0
  28. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/result.py +0 -0
  29. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/transaction.py +0 -0
  30. {aflib-0.0.7 → aflib-0.0.9}/src/aflib/workflow.py +0 -0
  31. {aflib-0.0.7 → aflib-0.0.9}/tests/test_authz.py +0 -0
  32. {aflib-0.0.7 → aflib-0.0.9}/tests/test_codec.py +0 -0
  33. {aflib-0.0.7 → aflib-0.0.9}/tests/test_db.py +0 -0
  34. {aflib-0.0.7 → aflib-0.0.9}/tests/test_errors.py +0 -0
  35. {aflib-0.0.7 → aflib-0.0.9}/tests/test_events.py +0 -0
  36. {aflib-0.0.7 → aflib-0.0.9}/tests/test_functions.py +0 -0
  37. {aflib-0.0.7 → aflib-0.0.9}/tests/test_logging.py +0 -0
  38. {aflib-0.0.7 → aflib-0.0.9}/tests/test_params.py +0 -0
  39. {aflib-0.0.7 → aflib-0.0.9}/tests/test_public_api.py +0 -0
  40. {aflib-0.0.7 → aflib-0.0.9}/tests/test_result.py +0 -0
  41. {aflib-0.0.7 → aflib-0.0.9}/tests/test_transaction.py +0 -0
  42. {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.7
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. One version corresponds to one proven capability, which is why the numbers start
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 it in
29
- `models/pythonPackages/pythonPackages.airflows` by pinning **name and version in
30
- separate fields** — never as a single `aflib==0.0.7` string (that form produced
31
- malformed `name==ver==ver` entries in a real instance's package list):
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
- name: aflib
35
- version: 0.0.7
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
- Then import it from a function body.
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
- Pin **name and version in separate fields** (`aflib` / `0.0.7`), not
95
- `aflib==0.0.7` as one string.
96
-
97
- ## Core API (0.0.1–0.0.3, still in 0.0.7)
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. One version corresponds to one proven capability, which is why the numbers start
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 it in
20
- `models/pythonPackages/pythonPackages.airflows` by pinning **name and version in
21
- separate fields** — never as a single `aflib==0.0.7` string (that form produced
22
- malformed `name==ver==ver` entries in a real instance's package list):
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
- name: aflib
26
- version: 0.0.7
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
- Then import it from a function body.
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
- Pin **name and version in separate fields** (`aflib` / `0.0.7`), not
86
- `aflib==0.0.7` as one string.
87
-
88
- ## Core API (0.0.1–0.0.3, still in 0.0.7)
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.7"
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.7"
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 mentioning the alias is rejected rather than silently misplaced.
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 in pending:
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`, `update_by_trigger`) are ignored by default so a
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
- def test_a_qualified_predicate_is_refused_rather_than_misplaced(self, make_plpy):
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, "_t.id_status = $1")
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