aflib 0.0.1__tar.gz → 0.0.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aflib
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Helper library for business logic inside Airflows plpython function bodies
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -29,34 +29,38 @@ local-import, git or private-index route. Declare it in
29
29
  `models/pythonPackages/pythonPackages.airflows`:
30
30
 
31
31
  ```
32
- pythonPackage aflib==0.0.1
32
+ pythonPackage aflib==0.0.2
33
33
  ```
34
34
 
35
35
  Then import it from a function body.
36
36
 
37
- ## What 0.0.1 gives you
37
+ ## What 0.0.2 gives you
38
38
 
39
39
  ```python
40
- from aflib import BadParameter, Db, fail, ok, params
40
+ from aflib import Db, errors, fail, logging, ok, params
41
41
 
42
42
  db = Db(plpy)
43
+ log = logging.logger(plpy, source="Economic.receipt_cancel")
43
44
 
44
45
  try:
45
46
  receipt_id = params.as_int(p_id_receipt, name="p_id_receipt", required=True)
46
- except BadParameter as exc:
47
- return fail("invalid_param", str(exc))
48
47
 
49
- row = db.query(
50
- 'SELECT id, amount FROM "Economic"."Receipt" WHERE id = $1',
51
- [receipt_id], ["integer"],
52
- ).one()
48
+ row = db.query(
49
+ 'SELECT id, amount FROM "Economic"."Receipt" WHERE id = $1',
50
+ [receipt_id], ["integer"],
51
+ ).one()
53
52
 
54
- if row is None:
55
- return fail("not_found", "No receipt with that id")
53
+ if row is None:
54
+ return fail("not_found", "No receipt with that id")
56
55
 
57
- return ok(row)
56
+ return ok(row)
57
+ except Exception as exc:
58
+ return errors.to_fail(exc, log=log)
58
59
  ```
59
60
 
61
+ **The `try` is not optional, and `except Exception` is not laziness.** See
62
+ [Never let an exception escape](#never-let-an-exception-escape) below — both are load-bearing.
63
+
60
64
  - **`Db(plpy)`** — `plpy` is injected, never imported, because the function body is the only
61
65
  composition root available. `db.query(...)` returns a `Rows` you narrow with `.one()`
62
66
  (`None` for no row, and it refuses more than one rather than silently taking the first),
@@ -71,6 +75,16 @@ return ok(row)
71
75
  - **`codec.dumps` / `loads`** — JSON that raises on anything it cannot serialize instead of
72
76
  stringifying it. `numeric` columns arrive as `decimal.Decimal` and are encoded as strings,
73
77
  never floats, so exactness survives the round trip.
78
+ - **`errors.to_fail(exc, log=log)`** — converts any exception into a failure envelope, keeping
79
+ the caller's message and the log's detail as two separate strings. A `BadParameter` is
80
+ described to the caller in full, because it is about their own input; anything else becomes
81
+ a generic `internal_error` whose detail goes only to the log. It returns the envelope even
82
+ if logging itself fails, because a broken log sink must not turn a handled failure back
83
+ into an escaping one.
84
+ - **`logging.logger(plpy, source=…)`** — one JSON object per line, `level` and `event` always
85
+ present, written through `plpy.log`. It never raises, even when handed a value that cannot
86
+ be encoded: the line is most needed in the error path, so a single exotic field must not be
87
+ able to silence it.
74
88
 
75
89
  ## Platform constraints worth knowing before you build on it
76
90
 
@@ -93,6 +107,36 @@ the library has the shape it does.
93
107
  - **Never cache request-scoped state in `GD` or `SD`.** Consecutive calls land on different
94
108
  pooled backends, and the connection role varies per caller.
95
109
 
110
+ ### Never let an exception escape
111
+
112
+ **An unhandled exception's message is returned to the caller, and it includes internal detail
113
+ that the caller should not see.** A function body cannot influence that response: it is built
114
+ outside the database from the driver's error message, and there is no setting that trims it.
115
+ The only way to control what a caller receives is to not raise — catch everything and return
116
+ a failure envelope instead. That is what `errors.to_fail` is for, and it is why every example
117
+ here wraps its work in a `try`.
118
+
119
+ `except Exception` is required rather than merely convenient. **`plpy.Error` does not inherit
120
+ from `plpy.SPIError`**, so the narrower `except plpy.SPIError` catches neither `plpy.error()`
121
+ nor an ordinary Python exception — only their common ancestor covers all three.
122
+
123
+ Two related facts worth knowing before you design around logging:
124
+
125
+ - **Of plpython's five informational sinks, only `log` and `warning` are written.** `debug`,
126
+ `info` and `notice` return successfully and are discarded, because `log_min_messages`
127
+ defaults to `WARNING` and those three rank below it while `log` ranks above. A level that
128
+ vanishes is worse than one that does not exist, which is why `aflib.logging` puts severity
129
+ in the payload and writes everything through `plpy.log`.
130
+ - **The platform logs every call's parameter values in plaintext** before the body runs. So
131
+ never pass a secret as a function parameter — it is recorded no matter how careful the body
132
+ is — and do not log parameter values yourself, since that cannot reduce the exposure and
133
+ only makes it look deliberate.
134
+
135
+ - **A callee's error message reaches its caller unredacted.** Function bodies compose through
136
+ SQL, and when a callee raises, its message crosses to the caller intact while its exception
137
+ *type* does not. So a business outcome has to travel as a returned value — `ok`/`fail` — and
138
+ never as an exception, and nothing sensitive may ever go into an exception message.
139
+
96
140
  ## Tests
97
141
 
98
142
  ```
@@ -20,34 +20,38 @@ local-import, git or private-index route. Declare it in
20
20
  `models/pythonPackages/pythonPackages.airflows`:
21
21
 
22
22
  ```
23
- pythonPackage aflib==0.0.1
23
+ pythonPackage aflib==0.0.2
24
24
  ```
25
25
 
26
26
  Then import it from a function body.
27
27
 
28
- ## What 0.0.1 gives you
28
+ ## What 0.0.2 gives you
29
29
 
30
30
  ```python
31
- from aflib import BadParameter, Db, fail, ok, params
31
+ from aflib import Db, errors, fail, logging, ok, params
32
32
 
33
33
  db = Db(plpy)
34
+ log = logging.logger(plpy, source="Economic.receipt_cancel")
34
35
 
35
36
  try:
36
37
  receipt_id = params.as_int(p_id_receipt, name="p_id_receipt", required=True)
37
- except BadParameter as exc:
38
- return fail("invalid_param", str(exc))
39
38
 
40
- row = db.query(
41
- 'SELECT id, amount FROM "Economic"."Receipt" WHERE id = $1',
42
- [receipt_id], ["integer"],
43
- ).one()
39
+ row = db.query(
40
+ 'SELECT id, amount FROM "Economic"."Receipt" WHERE id = $1',
41
+ [receipt_id], ["integer"],
42
+ ).one()
44
43
 
45
- if row is None:
46
- return fail("not_found", "No receipt with that id")
44
+ if row is None:
45
+ return fail("not_found", "No receipt with that id")
47
46
 
48
- return ok(row)
47
+ return ok(row)
48
+ except Exception as exc:
49
+ return errors.to_fail(exc, log=log)
49
50
  ```
50
51
 
52
+ **The `try` is not optional, and `except Exception` is not laziness.** See
53
+ [Never let an exception escape](#never-let-an-exception-escape) below — both are load-bearing.
54
+
51
55
  - **`Db(plpy)`** — `plpy` is injected, never imported, because the function body is the only
52
56
  composition root available. `db.query(...)` returns a `Rows` you narrow with `.one()`
53
57
  (`None` for no row, and it refuses more than one rather than silently taking the first),
@@ -62,6 +66,16 @@ return ok(row)
62
66
  - **`codec.dumps` / `loads`** — JSON that raises on anything it cannot serialize instead of
63
67
  stringifying it. `numeric` columns arrive as `decimal.Decimal` and are encoded as strings,
64
68
  never floats, so exactness survives the round trip.
69
+ - **`errors.to_fail(exc, log=log)`** — converts any exception into a failure envelope, keeping
70
+ the caller's message and the log's detail as two separate strings. A `BadParameter` is
71
+ described to the caller in full, because it is about their own input; anything else becomes
72
+ a generic `internal_error` whose detail goes only to the log. It returns the envelope even
73
+ if logging itself fails, because a broken log sink must not turn a handled failure back
74
+ into an escaping one.
75
+ - **`logging.logger(plpy, source=…)`** — one JSON object per line, `level` and `event` always
76
+ present, written through `plpy.log`. It never raises, even when handed a value that cannot
77
+ be encoded: the line is most needed in the error path, so a single exotic field must not be
78
+ able to silence it.
65
79
 
66
80
  ## Platform constraints worth knowing before you build on it
67
81
 
@@ -84,6 +98,36 @@ the library has the shape it does.
84
98
  - **Never cache request-scoped state in `GD` or `SD`.** Consecutive calls land on different
85
99
  pooled backends, and the connection role varies per caller.
86
100
 
101
+ ### Never let an exception escape
102
+
103
+ **An unhandled exception's message is returned to the caller, and it includes internal detail
104
+ that the caller should not see.** A function body cannot influence that response: it is built
105
+ outside the database from the driver's error message, and there is no setting that trims it.
106
+ The only way to control what a caller receives is to not raise — catch everything and return
107
+ a failure envelope instead. That is what `errors.to_fail` is for, and it is why every example
108
+ here wraps its work in a `try`.
109
+
110
+ `except Exception` is required rather than merely convenient. **`plpy.Error` does not inherit
111
+ from `plpy.SPIError`**, so the narrower `except plpy.SPIError` catches neither `plpy.error()`
112
+ nor an ordinary Python exception — only their common ancestor covers all three.
113
+
114
+ Two related facts worth knowing before you design around logging:
115
+
116
+ - **Of plpython's five informational sinks, only `log` and `warning` are written.** `debug`,
117
+ `info` and `notice` return successfully and are discarded, because `log_min_messages`
118
+ defaults to `WARNING` and those three rank below it while `log` ranks above. A level that
119
+ vanishes is worse than one that does not exist, which is why `aflib.logging` puts severity
120
+ in the payload and writes everything through `plpy.log`.
121
+ - **The platform logs every call's parameter values in plaintext** before the body runs. So
122
+ never pass a secret as a function parameter — it is recorded no matter how careful the body
123
+ is — and do not log parameter values yourself, since that cannot reduce the exposure and
124
+ only makes it look deliberate.
125
+
126
+ - **A callee's error message reaches its caller unredacted.** Function bodies compose through
127
+ SQL, and when a callee raises, its message crosses to the caller intact while its exception
128
+ *type* does not. So a business outcome has to travel as a returned value — `ok`/`fail` — and
129
+ never as an exception, and nothing sensitive may ever go into an exception message.
130
+
87
131
  ## Tests
88
132
 
89
133
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aflib"
3
- version = "0.0.1"
3
+ version = "0.0.2"
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).
@@ -33,3 +33,4 @@ include = [
33
33
  "README.md",
34
34
  "LICENSE",
35
35
  ]
36
+
@@ -0,0 +1,56 @@
1
+ """Helper library for business logic inside Airflows `plpython3u` function bodies.
2
+
3
+ Nothing enters this package until it has run on a real Airflows instance. See
4
+ `docs/02-roadmap.md` for what exists and what is still only a plan.
5
+
6
+ `src/aflib/` imports the standard library, `plpy` and `airflows` — nothing else. See
7
+ `docs/adr/0001-stdlib-only-no-third-party-dependencies.md`.
8
+
9
+ A function body's whole import line, and the shape of a body:
10
+
11
+ from aflib import Db, errors, fail, logging, ok, params
12
+
13
+ db = Db(plpy)
14
+ log = logging.logger(plpy, source="Economic.receipt_cancel")
15
+
16
+ try:
17
+ tid = params.as_int(p_id_receipt, name="p_id_receipt", required=True)
18
+
19
+ row = db.query("SELECT * FROM t WHERE id = $1", [tid], ["integer"]).one()
20
+ if row is None:
21
+ return fail("not_found", "No receipt with that id")
22
+ return ok(row)
23
+ except Exception as exc:
24
+ return errors.to_fail(exc, log=log)
25
+
26
+ **The `try` is not optional.** An exception that escapes a function body is returned to the
27
+ HTTP caller along with the function's name, the failing line number and the literal source
28
+ line — so catching everything is a security control, not a matter of taste. `except
29
+ Exception` is also the only clause that works: `plpy.Error` does not inherit from
30
+ `plpy.SPIError`, so the narrower version catches neither `plpy.error()` nor an ordinary
31
+ Python exception.
32
+ """
33
+
34
+ from aflib import codec, db, errors, logging, params, result
35
+ from aflib.db import Db
36
+ from aflib.params import BadParameter
37
+ from aflib.result import fail, ok
38
+
39
+ # Kept in step with `pyproject.toml` by hand. A deployed body returns this so a run can
40
+ # prove which aflib it actually imported — a stale package otherwise looks exactly like a
41
+ # working one.
42
+ __version__ = "0.0.2"
43
+
44
+ __all__ = [
45
+ "BadParameter",
46
+ "Db",
47
+ "codec",
48
+ "db",
49
+ "errors",
50
+ "fail",
51
+ "logging",
52
+ "ok",
53
+ "params",
54
+ "result",
55
+ "__version__",
56
+ ]
@@ -0,0 +1,111 @@
1
+ """Turning any exception into something safe to return.
2
+
3
+ from aflib import Db, errors, logging as aflog, ok
4
+
5
+ log = aflog.logger(plpy, source="Economic.receipt_cancel")
6
+ try:
7
+ ...
8
+ return ok(data)
9
+ except Exception as exc: # noqa: BLE001 - see below, this is the point
10
+ return errors.to_fail(exc, log=log)
11
+
12
+ **Why a body must catch everything.** An exception that escapes is returned to the HTTP
13
+ caller together with the function's name, the failing line number and the literal source
14
+ line. Every `httpEnabled` endpoint is directly reachable, so an uncaught error is a
15
+ source-disclosure primitive: make a function fail, read one line of it, and repeat. The
16
+ platform produces that response in its own Java handler from the JDBC driver's message —
17
+ there is no setting that suppresses it, and nothing a body can do except not raise.
18
+
19
+ **`except Exception` is not laziness here, it is the only sufficient clause.** `plpy.Error`
20
+ does not inherit from `plpy.SPIError`, so the narrower `except plpy.SPIError` catches
21
+ neither `plpy.error()` nor any ordinary Python exception. Only their common ancestor covers
22
+ all three.
23
+
24
+ **The caller's message and the log's detail are two different strings.** A callee's message
25
+ text reaches its caller unredacted, so anything a body knows — a connection string, a token,
26
+ another customer's row — must go to the log and never into what is returned.
27
+ """
28
+
29
+ from collections import namedtuple
30
+
31
+ from aflib import result
32
+
33
+ Failure = namedtuple("Failure", ("code", "message", "detail"))
34
+ """What a caller may be told (`code`, `message`) and what it may not (`detail`).
35
+
36
+ `detail` is `None` when nothing is being hidden — a bad parameter is already described to
37
+ the caller in full, so there is nothing left to log.
38
+ """
39
+
40
+ # The message every unrecognised failure gets. Deliberately says nothing: not the exception
41
+ # type, not the text, not where it happened.
42
+ _INTERNAL_MESSAGE = "An internal error occurred."
43
+
44
+ # Only SQLSTATEs OBSERVED on the platform are mapped, and only those safe to name. This is
45
+ # short on purpose. Inventing a table from the Postgres manual would put codes here that no
46
+ # run has ever produced, and the mapping is what a caller sees — a wrong entry is a wrong
47
+ # answer to a user. The write slices will observe the constraint violations (unique,
48
+ # foreign key, check, not-null) and each one earns its place then.
49
+ #
50
+ # 22P02 invalid_text_representation — 'abc' where a number was wanted. The caller's own
51
+ # input, so naming the category tells them something useful and reveals nothing.
52
+ _SAFE_SQLSTATES = {
53
+ "22P02": ("invalid_value", "A value was not in the expected format."),
54
+ }
55
+
56
+
57
+ def classify(exc):
58
+ """Describe `exc` as a `Failure`, splitting what is safe to return from what is not."""
59
+ # Imported lazily: `params` imports nothing from here, but keeping the dependency at
60
+ # call time leaves the two modules independent of import order.
61
+ from aflib.params import BadParameter
62
+
63
+ if isinstance(exc, BadParameter):
64
+ # Already a description of the caller's own input, so it can be quoted back
65
+ # verbatim and there is nothing to hide.
66
+ return Failure("invalid_param", str(exc), None)
67
+
68
+ sqlstate = getattr(exc, "sqlstate", None)
69
+ detail = _detail(exc, sqlstate)
70
+
71
+ if sqlstate in _SAFE_SQLSTATES:
72
+ code, message = _SAFE_SQLSTATES[sqlstate]
73
+ return Failure(code, message, detail)
74
+
75
+ return Failure("internal_error", _INTERNAL_MESSAGE, detail)
76
+
77
+
78
+ def to_fail(exc, log=None):
79
+ """Log what must stay hidden, and return the failure envelope for the caller.
80
+
81
+ The envelope is returned even if logging fails. A broken sink must not turn a handled
82
+ failure back into an escaping exception — that would disclose source at the very moment
83
+ the code was trying to prevent it.
84
+ """
85
+ failure = classify(exc)
86
+
87
+ if log is not None and failure.detail is not None:
88
+ try:
89
+ log.error("request_failed", code=failure.code, detail=failure.detail)
90
+ except Exception:
91
+ # Swallowed deliberately, and this is the one place it is right to do so: the
92
+ # caller's response matters more than the log line, and re-raising here would
93
+ # defeat the module's whole purpose.
94
+ pass
95
+
96
+ return result.fail(failure.code, failure.message)
97
+
98
+
99
+ def _detail(exc, sqlstate):
100
+ """The hidden half: everything useful for diagnosis, none of it caller-facing.
101
+
102
+ The exception's class name is included because it is how the platform distinguishes a
103
+ callee's Python raise (`ExternalRoutineException`) from `plpy.error()`
104
+ (`InternalError`) from a real database error (`DivisionByZero`) — all of which arrive
105
+ as SPI errors once they cross a function boundary.
106
+ """
107
+ parts = [type(exc).__name__]
108
+ if sqlstate is not None:
109
+ parts.append("sqlstate=" + str(sqlstate))
110
+ parts.append(str(exc))
111
+ return " ".join(parts)
@@ -0,0 +1,119 @@
1
+ """Structured logging through the one sink the server actually writes.
2
+
3
+ from aflib import logging as aflog
4
+
5
+ log = aflog.logger(plpy, source="Economic.receipt_cancel")
6
+ log.info("receipt_cancelled", receipt_id=7)
7
+ log.error("unexpected_failure", detail=str(exc))
8
+
9
+ One JSON object per line, `level` and `event` always present. Severity lives **in the
10
+ payload**, not in the choice of sink, and there is a reason for that.
11
+
12
+ plpython offers five informational sinks and only two of them are written: `log` and
13
+ `warning`. `debug`, `info` and `notice` succeed, report nothing wrong, and are discarded by
14
+ the server, because `log_min_messages` sits at its default of `WARNING` and those three
15
+ rank below it. Offering them as levels would be a false promise — the call works and the
16
+ line is gone.
17
+
18
+ Of the two that survive, this module uses **`log`**. It is written under the default
19
+ configuration and is not delivered to a client; a `warning` is also written but IS
20
+ delivered to a connected SQL client, which is a leak risk for nothing `log` does not
21
+ already give. And never `plpy.error()`, which *raises*: reaching for it in an error path
22
+ would abort the handler that was trying to report the failure.
23
+
24
+ **Do not log parameter values.** The platform already writes every call's parameters —
25
+ names, declared types and values — to its log in plaintext before the body runs. Repeating
26
+ them cannot reduce that exposure and only makes it look deliberate.
27
+ """
28
+
29
+ import json
30
+ from decimal import Decimal
31
+
32
+ # `level` and `event` are how a line is found in a log that is grepped rather than
33
+ # queried. A field silently overwriting either would break every search built on them.
34
+ _RESERVED = ("level", "event")
35
+
36
+
37
+ def _lenient(value):
38
+ """Called by `json.dumps` only for values it cannot serialise itself.
39
+
40
+ Unlike `codec.dumps`, this **never refuses**. That difference is deliberate: refusing
41
+ is right for a response, where a wrong value must not reach a caller, and wrong for a
42
+ log, where the line is most needed in the error path. One exotic field must not be able
43
+ to silence the record that explains a failure.
44
+ """
45
+ if isinstance(value, Decimal):
46
+ # A string, not a float, for the same reason a response does it: `float` loses
47
+ # exactness and drops the trailing zero that makes a price a price.
48
+ return str(value)
49
+ return repr(value)
50
+
51
+
52
+ class Logger:
53
+ """Writes one JSON line per call. Never raises on its own account."""
54
+
55
+ def __init__(self, plpy, source=None):
56
+ self._plpy = plpy
57
+ self._source = source
58
+
59
+ # `event` is positional-ONLY (the `/`) so that `event=` lands in `**fields` and meets
60
+ # the reserved-name guard below. Left as a normal parameter, Python raises
61
+ # "multiple values for argument 'event'" instead — a different error for the same
62
+ # mistake that `level=` makes, and one that says nothing about why the name is taken.
63
+ def info(self, event, /, **fields):
64
+ """Something happened that is worth a line."""
65
+ self._write("info", event, fields)
66
+
67
+ def error(self, event, /, **fields):
68
+ """Something failed. Tags the line `error`; does not raise, and does not abort."""
69
+ self._write("error", event, fields)
70
+
71
+ def _write(self, level, event, fields):
72
+ for name in _RESERVED:
73
+ if name in fields:
74
+ raise ValueError(
75
+ "a field may not be named {!r}: it would overwrite the record's own "
76
+ "{} and break every log search that relies on it".format(name, name)
77
+ )
78
+
79
+ record = {"level": level, "event": event}
80
+ if self._source is not None:
81
+ # Omitted rather than written as null when absent: a cron or a trigger has no
82
+ # meaningful source, and `"source": null` on every such line is noise.
83
+ record["source"] = self._source
84
+ record.update(fields)
85
+
86
+ self._plpy.log(self._encode(record))
87
+
88
+ @staticmethod
89
+ def _encode(record):
90
+ """Encode the record, falling back rather than raising.
91
+
92
+ `default=` is never consulted for a **circular** structure — `json.dumps` raises
93
+ before it gets there — so a lenient default alone does not make this safe. Losing
94
+ the fields is an acceptable outcome; losing the line is not, because a silently
95
+ dropped record makes a failure look like nothing happened at all.
96
+ """
97
+ try:
98
+ return json.dumps(record, default=_lenient)
99
+ except Exception as exc:
100
+ # Rebuilt from the keys that are known to be encodable, following the same
101
+ # absent-not-null rule as the normal path.
102
+ safe = {key: record[key] for key in ("level", "event", "source")
103
+ if key in record}
104
+ # Names the cause so the missing fields are explained rather than a mystery,
105
+ # and keeps the field NAMES, which are often enough to identify the call site.
106
+ safe["unencodable"] = {
107
+ "reason": type(exc).__name__ + ": " + str(exc),
108
+ "fields": sorted(k for k in record if k not in safe),
109
+ }
110
+ return json.dumps(safe)
111
+
112
+
113
+ def logger(plpy, source=None):
114
+ """Build a logger over an injected `plpy`.
115
+
116
+ `plpy` is passed in rather than imported for the same reason `Db` takes it: the
117
+ function body is the composition root, and tests hand over a double.
118
+ """
119
+ return Logger(plpy, source=source)
@@ -57,14 +57,55 @@ class FakeResult:
57
57
  return iter(self._rows)
58
58
 
59
59
 
60
+ class FakeError(Exception):
61
+ """Stands in for `plpy.Error`, what `plpy.error()` raises.
62
+
63
+ Deliberately NOT a subclass of anything resembling `SPIError`: on the platform
64
+ `plpy.Error` does not inherit from `plpy.SPIError` (gap G32, probe `slice2-r1`), which
65
+ is why `except plpy.SPIError` fails to catch it.
66
+ """
67
+
68
+
60
69
  class FakePlpy:
61
70
  """Stands in for the plpython `plpy` global."""
62
71
 
72
+ Error = FakeError
73
+
63
74
  def __init__(self, rows=None, colnames=None):
64
75
  self.rows = rows if rows is not None else []
65
76
  self.colnames = colnames
66
77
  self.executed = []
67
78
  self.prepared = []
79
+ # Every sink is recorded SEPARATELY, because which one a line went to is the
80
+ # finding that matters: only `log` and `warning` are written by the server at all,
81
+ # and the other three are silently discarded (gap G35, probe `slice2-r1`). A double
82
+ # that lumped them together could not catch a logger writing into the void.
83
+ self.logged = []
84
+ self.noticed = []
85
+ self.warned = []
86
+ self.informed = []
87
+ self.debugged = []
88
+
89
+ def log(self, message):
90
+ self.logged.append(message)
91
+
92
+ def notice(self, message):
93
+ self.noticed.append(message)
94
+
95
+ def warning(self, message):
96
+ self.warned.append(message)
97
+
98
+ def info(self, message):
99
+ self.informed.append(message)
100
+
101
+ def debug(self, message):
102
+ self.debugged.append(message)
103
+
104
+ def error(self, message):
105
+ # RAISES, as the real one does. A logger that reached for this in an error path
106
+ # would abort the very handler trying to report a failure, so the double has to
107
+ # make that mistake fail a test rather than pass one.
108
+ raise FakeError(message)
68
109
 
69
110
  def prepare(self, sql, argtypes=None):
70
111
  plan = ("PLAN", sql, tuple(argtypes or ()))
@@ -0,0 +1,159 @@
1
+ """Turning any exception into something safe to return.
2
+
3
+ This module exists because of one observed platform behaviour: an exception that escapes a
4
+ function body is returned to the HTTP caller **with the function's name, the failing line
5
+ number and the literal source line** (gap G33). Every `httpEnabled` endpoint is directly
6
+ reachable, so an uncaught error is a source-disclosure primitive — make a function fail,
7
+ read a line of it, repeat. Catching everything is therefore a security control, not tidiness.
8
+
9
+ Two more findings shape it:
10
+
11
+ - `except Exception` is the only sufficient guard. `plpy.Error` does not inherit from
12
+ `plpy.SPIError`, so the obvious clause catches neither `plpy.error()` nor a plain Python
13
+ exception (G32).
14
+ - A callee's message text reaches its caller unredacted (G32). So the message a caller sees
15
+ and the detail that goes to the log are **two different strings**, and conflating them is
16
+ the bug this module is built to prevent.
17
+ """
18
+
19
+ import json
20
+
21
+ from aflib import errors, logging as aflog
22
+ from aflib.params import BadParameter
23
+
24
+
25
+ class FakeSPIError(Exception):
26
+ """An exception carrying `sqlstate`, as every plpy SPI error does (G32)."""
27
+
28
+ def __init__(self, message, sqlstate):
29
+ Exception.__init__(self, message)
30
+ self.sqlstate = sqlstate
31
+
32
+
33
+ SECRET = "customers.card_token = 4111111111111111"
34
+
35
+
36
+ # --- classify: what the caller may see, and what it may not -----------
37
+
38
+ def test_a_bad_parameter_is_the_callers_own_fault_and_may_be_quoted_back():
39
+ # The message describes what the caller sent, so it reveals nothing they did not
40
+ # already know. Nothing is hidden, so there is nothing to log.
41
+ failure = errors.classify(BadParameter("p_int: expected a whole number, got 'abc'"))
42
+
43
+ assert failure.code == "invalid_param"
44
+ assert failure.message == "p_int: expected a whole number, got 'abc'"
45
+ assert failure.detail is None
46
+
47
+
48
+ def test_an_unrecognised_exception_becomes_a_generic_internal_error():
49
+ failure = errors.classify(RuntimeError("connection to " + SECRET + " failed"))
50
+
51
+ assert failure.code == "internal_error"
52
+
53
+
54
+ def test_an_internal_errors_message_never_carries_the_original_text():
55
+ # THE test this module exists for. If this ever fails, the module is worse than
56
+ # nothing: it would look like a safety layer while forwarding the detail anyway.
57
+ failure = errors.classify(RuntimeError("connection to " + SECRET + " failed"))
58
+
59
+ assert SECRET not in failure.message
60
+ assert "RuntimeError" not in failure.message
61
+
62
+
63
+ def test_the_original_text_is_preserved_for_the_log():
64
+ # Hidden from the caller, not thrown away — otherwise nothing could be diagnosed.
65
+ failure = errors.classify(RuntimeError("connection to " + SECRET + " failed"))
66
+
67
+ assert SECRET in failure.detail
68
+ assert "RuntimeError" in failure.detail
69
+
70
+
71
+ def test_an_observed_sqlstate_is_reported_as_the_callers_fault():
72
+ # 22P02 is invalid_text_representation: 'abc' where a number was wanted. Observed on
73
+ # the platform, so it is mapped. The code is named; the text still is not.
74
+ failure = errors.classify(
75
+ FakeSPIError('invalid input syntax for type integer: "abc"', "22P02"))
76
+
77
+ assert failure.code == "invalid_value"
78
+ assert "abc" not in failure.message
79
+
80
+
81
+ def test_an_unmapped_sqlstate_stays_internal():
82
+ # Deliberately conservative: only SQLSTATEs actually observed on the platform are
83
+ # mapped. Anything else is internal until a real run proves what it should be.
84
+ failure = errors.classify(FakeSPIError("something odd", "99999"))
85
+
86
+ assert failure.code == "internal_error"
87
+
88
+
89
+ def test_the_sqlstate_is_recorded_in_the_detail():
90
+ failure = errors.classify(FakeSPIError("something odd", "99999"))
91
+
92
+ assert "99999" in failure.detail
93
+
94
+
95
+ # --- to_fail: the whole error path a body needs -----------------------
96
+
97
+ def test_to_fail_returns_a_failure_envelope():
98
+ envelope = json.loads(errors.to_fail(RuntimeError("boom " + SECRET)))
99
+
100
+ assert envelope["ok"] is False
101
+ assert envelope["error"]["code"] == "internal_error"
102
+
103
+
104
+ def test_the_envelope_never_carries_the_original_text():
105
+ envelope = errors.to_fail(RuntimeError("boom " + SECRET))
106
+
107
+ assert SECRET not in envelope
108
+
109
+
110
+ def test_to_fail_logs_the_hidden_detail_when_given_a_logger(make_plpy):
111
+ fake = make_plpy()
112
+
113
+ errors.to_fail(RuntimeError("boom " + SECRET), log=aflog.logger(fake))
114
+
115
+ assert len(fake.logged) == 1
116
+ record = json.loads(fake.logged[0])
117
+ assert record["level"] == "error"
118
+ assert SECRET in record["detail"]
119
+
120
+
121
+ def test_to_fail_logs_the_code_it_returned(make_plpy):
122
+ # So a log line can be tied to the response the caller actually received.
123
+ fake = make_plpy()
124
+
125
+ errors.to_fail(RuntimeError("boom"), log=aflog.logger(fake))
126
+
127
+ assert json.loads(fake.logged[0])["code"] == "internal_error"
128
+
129
+
130
+ def test_to_fail_logs_nothing_when_there_is_nothing_hidden(make_plpy):
131
+ # A bad parameter is already fully described to the caller. Logging it again would fill
132
+ # the log with other people's typos.
133
+ fake = make_plpy()
134
+
135
+ errors.to_fail(BadParameter("p_int: expected a whole number"), log=aflog.logger(fake))
136
+
137
+ assert fake.logged == []
138
+
139
+
140
+ def test_to_fail_works_without_a_logger():
141
+ # A body that has not built a logger must still be able to fail safely — otherwise the
142
+ # safe path is the inconvenient one.
143
+ envelope = json.loads(errors.to_fail(RuntimeError("boom")))
144
+
145
+ assert envelope["ok"] is False
146
+
147
+
148
+ def test_to_fail_still_returns_an_envelope_if_logging_itself_fails():
149
+ # The response matters more than the log line. If the sink is broken, the caller must
150
+ # still get a clean failure rather than an escaping exception — which would disclose
151
+ # source (G33) at the worst possible moment.
152
+ class BrokenLogger:
153
+ def error(self, event, **fields):
154
+ raise RuntimeError("sink is down")
155
+
156
+ envelope = json.loads(errors.to_fail(RuntimeError("boom"), log=BrokenLogger()))
157
+
158
+ assert envelope["ok"] is False
159
+ assert envelope["error"]["code"] == "internal_error"
@@ -0,0 +1,170 @@
1
+ """What a body may rely on when it writes a log line.
2
+
3
+ Every choice tested here is forced by something observed on the platform, not preferred:
4
+
5
+ - Of plpython's five informational sinks, only `log` and `warning` are written by the
6
+ server at all — `debug`, `info` and `notice` succeed and are silently discarded (gap
7
+ G35). A level that vanishes is a false promise, so severity travels in the payload and
8
+ every line goes to one sink.
9
+ - That sink is `log`. It is written under the server's default configuration and is not
10
+ delivered to a client, whereas a warning IS delivered to a connected SQL client for no
11
+ benefit `log` does not already give (G35).
12
+ - Never `plpy.error()`: it raises. In an error path that would abort the handler trying to
13
+ report the failure (G32).
14
+ - The platform already logs every call's parameter values in plaintext (G34), so a logger
15
+ must not be the thing that makes that worse.
16
+ """
17
+
18
+ import json
19
+
20
+ import pytest
21
+
22
+ from aflib import logging as aflog
23
+
24
+
25
+ def test_a_line_is_written_through_plpy_log(make_plpy):
26
+ fake = make_plpy()
27
+ aflog.logger(fake).info("receipt_cancelled")
28
+
29
+ assert len(fake.logged) == 1
30
+
31
+
32
+ def test_no_other_sink_is_ever_touched(make_plpy):
33
+ # The three silent sinks would lose the line; `warning` would reach a SQL client.
34
+ fake = make_plpy()
35
+ log = aflog.logger(fake)
36
+
37
+ log.info("a")
38
+ log.error("b")
39
+
40
+ assert fake.noticed == []
41
+ assert fake.warned == []
42
+ assert fake.informed == []
43
+ assert fake.debugged == []
44
+
45
+
46
+ def test_error_does_not_raise(make_plpy):
47
+ # `plpy.error()` raises. If `error()` reached for it, reporting a failure would abort
48
+ # the handler doing the reporting — the worst possible moment.
49
+ fake = make_plpy()
50
+
51
+ aflog.logger(fake).error("unexpected_failure")
52
+
53
+ assert len(fake.logged) == 1
54
+
55
+
56
+ def test_each_line_is_one_json_object_carrying_level_and_event(make_plpy):
57
+ fake = make_plpy()
58
+ aflog.logger(fake).info("receipt_cancelled")
59
+
60
+ record = json.loads(fake.logged[0])
61
+ assert record["level"] == "info"
62
+ assert record["event"] == "receipt_cancelled"
63
+
64
+
65
+ def test_error_lines_are_marked_as_such_in_the_payload(make_plpy):
66
+ fake = make_plpy()
67
+ aflog.logger(fake).error("boom")
68
+
69
+ assert json.loads(fake.logged[0])["level"] == "error"
70
+
71
+
72
+ def test_fields_are_merged_into_the_object(make_plpy):
73
+ fake = make_plpy()
74
+ aflog.logger(fake).info("receipt_cancelled", receipt_id=7, reason="duplicate")
75
+
76
+ record = json.loads(fake.logged[0])
77
+ assert record["receipt_id"] == 7
78
+ assert record["reason"] == "duplicate"
79
+
80
+
81
+ def test_source_is_included_when_given(make_plpy):
82
+ fake = make_plpy()
83
+ aflog.logger(fake, source="Economic.receipt_cancel").info("started")
84
+
85
+ assert json.loads(fake.logged[0])["source"] == "Economic.receipt_cancel"
86
+
87
+
88
+ def test_source_is_absent_rather_than_null_when_not_given(make_plpy):
89
+ # A cron or trigger has no meaningful source. Writing `"source": null` on every line
90
+ # would be noise in a log that is grepped, not queried.
91
+ fake = make_plpy()
92
+ aflog.logger(fake).info("started")
93
+
94
+ assert "source" not in json.loads(fake.logged[0])
95
+
96
+
97
+ def test_a_field_that_cannot_be_serialised_still_produces_a_line(make_plpy):
98
+ # THE most important test here. `codec.dumps` refuses unserialisable values by design,
99
+ # which is right for a response and fatal for a log: the logger is most needed in the
100
+ # error path, and one exotic field must not silence it.
101
+ class Unserialisable:
102
+ pass
103
+
104
+ fake = make_plpy()
105
+ aflog.logger(fake).error("failed", culprit=Unserialisable())
106
+
107
+ record = json.loads(fake.logged[0])
108
+ assert record["event"] == "failed"
109
+ assert "Unserialisable" in str(record["culprit"])
110
+
111
+
112
+ def test_a_decimal_field_is_written_as_a_string(make_plpy):
113
+ # Same rule as the response envelope: a money value keeps its exactness.
114
+ from decimal import Decimal
115
+
116
+ fake = make_plpy()
117
+ aflog.logger(fake).info("charged", amount=Decimal("1234.50"))
118
+
119
+ assert json.loads(fake.logged[0])["amount"] == "1234.50"
120
+
121
+
122
+ def test_a_field_may_not_overwrite_level_or_event(make_plpy):
123
+ # `level` and `event` are what makes a line findable. A caller passing either as a
124
+ # field would silently break every search that relies on them, so it fails loudly at
125
+ # the call site instead.
126
+ fake = make_plpy()
127
+ log = aflog.logger(fake)
128
+
129
+ with pytest.raises(ValueError) as exc:
130
+ log.info("started", level="debug")
131
+ assert "level" in str(exc.value)
132
+
133
+ with pytest.raises(ValueError):
134
+ log.info("started", event="something_else")
135
+
136
+
137
+ def test_a_circular_field_still_produces_a_line(make_plpy):
138
+ # `default=` is never consulted for a circular structure — `json.dumps` raises
139
+ # ValueError before reaching it. Without a guard the logger would raise from inside an
140
+ # error handler, which is the one thing it promises not to do.
141
+ circular = {}
142
+ circular["self"] = circular
143
+
144
+ fake = make_plpy()
145
+ aflog.logger(fake).error("failed", data=circular)
146
+
147
+ record = json.loads(fake.logged[0])
148
+ assert record["level"] == "error"
149
+ assert record["event"] == "failed"
150
+ # The fallback follows the same rule as the normal path: absent, not null.
151
+ assert "source" not in record
152
+
153
+
154
+ def test_a_line_that_cannot_be_encoded_says_so(make_plpy):
155
+ # Losing the fields is acceptable; losing the fact that something was logged, and
156
+ # silently, is not — that would make a failure look like nothing happened.
157
+ circular = {}
158
+ circular["self"] = circular
159
+
160
+ fake = make_plpy()
161
+ aflog.logger(fake).error("failed", data=circular)
162
+
163
+ assert "unencodable" in fake.logged[0]
164
+
165
+
166
+ def test_logging_nothing_but_an_event_is_valid(make_plpy):
167
+ fake = make_plpy()
168
+ aflog.logger(fake).info("started")
169
+
170
+ assert json.loads(fake.logged[0]) == {"level": "info", "event": "started"}
@@ -34,6 +34,19 @@ def test_exports_db_directly():
34
34
  assert aflib.Db is not None
35
35
 
36
36
 
37
+ def test_exports_the_failure_path():
38
+ # A body's error handler is the one thing that must never be awkward to reach: if
39
+ # `errors` or `logging` needed a longer import than the happy path, the safe route
40
+ # would be the inconvenient one — and an exception that escapes returns the function's
41
+ # own source line to the caller.
42
+ from aflib import errors, logging
43
+
44
+ assert errors.to_fail is not None
45
+ assert logging.logger is not None
46
+ assert "errors" in aflib.__all__
47
+ assert "logging" in aflib.__all__
48
+
49
+
37
50
  def test_exports_bad_parameter_directly():
38
51
  # A body catches this by name. Re-exported so `from aflib import BadParameter` works
39
52
  # without knowing which module defines it — it moves to an `errors` module in slice 2,
@@ -1,42 +0,0 @@
1
- """Helper library for business logic inside Airflows `plpython3u` function bodies.
2
-
3
- Nothing enters this package until it has run on a real Airflows instance. See
4
- `docs/02-roadmap.md` for what exists and what is still only a plan.
5
-
6
- `src/aflib/` imports the standard library, `plpy` and `airflows` — nothing else. See
7
- `docs/adr/0001-stdlib-only-no-third-party-dependencies.md`.
8
-
9
- A function body's whole import line, and the shape of a body:
10
-
11
- from aflib import Db, ok, fail, params
12
-
13
- db = Db(plpy)
14
- tid = params.as_int(p_id_receipt, name="p_id_receipt", required=True)
15
-
16
- row = db.query("SELECT * FROM t WHERE id = $1", [tid], ["integer"]).one()
17
- if row is None:
18
- return fail("not_found", "No receipt with that id")
19
- return ok(row)
20
- """
21
-
22
- from aflib import codec, db, params, result
23
- from aflib.db import Db
24
- from aflib.params import BadParameter
25
- from aflib.result import fail, ok
26
-
27
- # Kept in step with `pyproject.toml` by hand. A deployed body returns this so a run can
28
- # prove which aflib it actually imported — a stale package otherwise looks exactly like a
29
- # working one.
30
- __version__ = "0.0.1"
31
-
32
- __all__ = [
33
- "BadParameter",
34
- "Db",
35
- "codec",
36
- "db",
37
- "fail",
38
- "ok",
39
- "params",
40
- "result",
41
- "__version__",
42
- ]
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