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.
- {aflib-0.0.1 → aflib-0.0.2}/PKG-INFO +57 -13
- {aflib-0.0.1 → aflib-0.0.2}/README.md +56 -12
- {aflib-0.0.1 → aflib-0.0.2}/pyproject.toml +2 -1
- aflib-0.0.2/src/aflib/__init__.py +56 -0
- aflib-0.0.2/src/aflib/errors.py +111 -0
- aflib-0.0.2/src/aflib/logging.py +119 -0
- {aflib-0.0.1 → aflib-0.0.2}/tests/conftest.py +41 -0
- aflib-0.0.2/tests/test_errors.py +159 -0
- aflib-0.0.2/tests/test_logging.py +170 -0
- {aflib-0.0.1 → aflib-0.0.2}/tests/test_public_api.py +13 -0
- aflib-0.0.1/src/aflib/__init__.py +0 -42
- {aflib-0.0.1 → aflib-0.0.2}/.gitignore +0 -0
- {aflib-0.0.1 → aflib-0.0.2}/LICENSE +0 -0
- {aflib-0.0.1 → aflib-0.0.2}/src/aflib/codec.py +0 -0
- {aflib-0.0.1 → aflib-0.0.2}/src/aflib/db.py +0 -0
- {aflib-0.0.1 → aflib-0.0.2}/src/aflib/params.py +0 -0
- {aflib-0.0.1 → aflib-0.0.2}/src/aflib/result.py +0 -0
- {aflib-0.0.1 → aflib-0.0.2}/tests/test_codec.py +0 -0
- {aflib-0.0.1 → aflib-0.0.2}/tests/test_db.py +0 -0
- {aflib-0.0.1 → aflib-0.0.2}/tests/test_params.py +0 -0
- {aflib-0.0.1 → aflib-0.0.2}/tests/test_result.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aflib
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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.
|
|
37
|
+
## What 0.0.2 gives you
|
|
38
38
|
|
|
39
39
|
```python
|
|
40
|
-
from aflib import
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
28
|
+
## What 0.0.2 gives you
|
|
29
29
|
|
|
30
30
|
```python
|
|
31
|
-
from aflib import
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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.
|
|
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
|