aflib 0.0.1__py3-none-any.whl
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/__init__.py +42 -0
- aflib/codec.py +36 -0
- aflib/db.py +126 -0
- aflib/params.py +123 -0
- aflib/result.py +34 -0
- aflib-0.0.1.dist-info/METADATA +107 -0
- aflib-0.0.1.dist-info/RECORD +9 -0
- aflib-0.0.1.dist-info/WHEEL +4 -0
- aflib-0.0.1.dist-info/licenses/LICENSE +21 -0
aflib/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
]
|
aflib/codec.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""JSON encoding for values that came out of Postgres.
|
|
2
|
+
|
|
3
|
+
The standard library cannot serialise everything plpython hands you. `numeric` columns
|
|
4
|
+
arrive as `decimal.Decimal`, and `json.dumps` raises `TypeError` on them — which, since
|
|
5
|
+
`numeric` is every money column, means the first real amount crashes the response.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _default(value):
|
|
13
|
+
"""Called by `json.dumps` only for values it cannot serialise itself."""
|
|
14
|
+
if isinstance(value, Decimal):
|
|
15
|
+
# A string, not a float. `float(Decimal("1.50"))` is not exactly 1.50, and it
|
|
16
|
+
# drops the trailing zero that makes a price a price.
|
|
17
|
+
return str(value)
|
|
18
|
+
# Refuse rather than guess. A `default` that falls off the end returns None, which
|
|
19
|
+
# `json.dumps` happily writes as `null` — so a `bytea` column would vanish from the
|
|
20
|
+
# response with no error anywhere. Naming the type is the whole value of the message:
|
|
21
|
+
# it surfaces inside a response envelope, where nothing else identifies the culprit.
|
|
22
|
+
raise TypeError("aflib.codec cannot serialise " + type(value).__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dumps(obj):
|
|
26
|
+
"""Serialise `obj` to a JSON string."""
|
|
27
|
+
return json.dumps(obj, default=_default)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def loads(text):
|
|
31
|
+
"""Decode a JSON string — typically the contents of a `jsonb` column.
|
|
32
|
+
|
|
33
|
+
Raises `ValueError` on malformed input rather than returning None, so a corrupt
|
|
34
|
+
column cannot be mistaken for an empty one.
|
|
35
|
+
"""
|
|
36
|
+
return json.loads(text)
|
aflib/db.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Reads over `plpy`.
|
|
2
|
+
|
|
3
|
+
`plpy` is **injected**, never imported at module scope. That is what makes this testable
|
|
4
|
+
off-platform, and it matches the constraint that the function body is the composition
|
|
5
|
+
root: bodies cannot import each other or any shared local module, so there is nowhere
|
|
6
|
+
else for wiring to live.
|
|
7
|
+
|
|
8
|
+
from aflib.db import Db
|
|
9
|
+
|
|
10
|
+
db = Db(plpy)
|
|
11
|
+
|
|
12
|
+
row = db.query("SELECT * FROM t WHERE id = $1", [tid], ["integer"]).one()
|
|
13
|
+
total = db.query("SELECT count(*) AS c FROM t").scalar("c")
|
|
14
|
+
all_t = db.query("SELECT * FROM t").rows()
|
|
15
|
+
|
|
16
|
+
`query()` runs the statement and hands back a `Rows`; the method you call on it chooses
|
|
17
|
+
how to read what came back. The SQL and its bindings are stated once, and `.scalar()` can
|
|
18
|
+
name its column without wedging it in among the query's own arguments.
|
|
19
|
+
|
|
20
|
+
**This layer does not transform values.** `numeric` arrives as `decimal.Decimal` and
|
|
21
|
+
`jsonb` as an undecoded `str`, and both are handed on exactly as they came. Decoding a
|
|
22
|
+
jsonb column is the caller's decision — a helpful decode here would make it impossible to
|
|
23
|
+
pass one straight through — and `Decimal` belongs to `codec` at the serialisation edge.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Rows(object):
|
|
28
|
+
"""The answers one executed query can give.
|
|
29
|
+
|
|
30
|
+
Wraps the result object `plpy` returned. This is the single place where that object's
|
|
31
|
+
API is used, so the fake in `tests/conftest.py` has exactly one thing to be faithful
|
|
32
|
+
about.
|
|
33
|
+
|
|
34
|
+
Read it through the named methods and no other way — it is deliberately not
|
|
35
|
+
list-like. See `TestRowsIsTerminalOnly` for why.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, result):
|
|
39
|
+
self._result = result
|
|
40
|
+
|
|
41
|
+
def rows(self):
|
|
42
|
+
"""Every matching row, as a list of dicts. `[]` when nothing matched."""
|
|
43
|
+
return [dict(row) for row in self._result]
|
|
44
|
+
|
|
45
|
+
def one(self):
|
|
46
|
+
"""The single matching row, or `None`. Raises if more than one matched.
|
|
47
|
+
|
|
48
|
+
The row count is checked against `len()`, because indexing an empty result raises
|
|
49
|
+
`IndexError` rather than returning `None`. Two rows where one was expected is a
|
|
50
|
+
broken query, not a data outcome, so it raises rather than quietly returning the
|
|
51
|
+
first — which would turn a bug into plausible-looking data.
|
|
52
|
+
"""
|
|
53
|
+
rows = self.rows()
|
|
54
|
+
if not rows:
|
|
55
|
+
return None
|
|
56
|
+
self._refuse_more_than_one(len(rows))
|
|
57
|
+
return rows[0]
|
|
58
|
+
|
|
59
|
+
def scalar(self, column):
|
|
60
|
+
"""One **named** column of the single matching row, or `None` if nothing matched.
|
|
61
|
+
|
|
62
|
+
The column is named rather than taken positionally. The obvious
|
|
63
|
+
`next(iter(row.values()))` bets that the first value is the wanted one; naming it
|
|
64
|
+
removes the bet, and makes the call read as what it selects.
|
|
65
|
+
|
|
66
|
+
A column the result does not have raises, and — because the result reports its
|
|
67
|
+
column list even with zero rows — that check works on the empty path too.
|
|
68
|
+
Without it, one typo would return `None` for every query that matched nothing:
|
|
69
|
+
indistinguishable from "not found", and breaking only once data exists.
|
|
70
|
+
"""
|
|
71
|
+
# Checked before the row count, deliberately. A misnamed column is a programmer
|
|
72
|
+
# error and should be reported whatever the query happened to return.
|
|
73
|
+
names = self._result.colnames()
|
|
74
|
+
if column not in names:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
"no column {!r} in this result; it has: {}".format(
|
|
77
|
+
column, ", ".join(repr(name) for name in names) or "<none>"
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self._refuse_more_than_one(len(self._result))
|
|
82
|
+
if len(self._result) == 0:
|
|
83
|
+
return None
|
|
84
|
+
# A row holding NULL also gives None. A caller who must tell that apart from
|
|
85
|
+
# "no row at all" uses one().
|
|
86
|
+
return self._result[0][column]
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _refuse_more_than_one(count):
|
|
90
|
+
if count > 1:
|
|
91
|
+
raise ValueError("expected at most one row, got {} rows".format(count))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Db(object):
|
|
95
|
+
def __init__(self, plpy):
|
|
96
|
+
self._plpy = plpy
|
|
97
|
+
|
|
98
|
+
def query(self, sql, args=None, argtypes=None):
|
|
99
|
+
"""Run `sql` and return a `Rows`.
|
|
100
|
+
|
|
101
|
+
Executes **now**, not when a method is called on the result. A handle that only
|
|
102
|
+
ran once someone remembered to read it would make an unread statement a silent
|
|
103
|
+
no-op, and running first is what makes `one()` an assertion about the query rather
|
|
104
|
+
than something that could have rewritten it.
|
|
105
|
+
"""
|
|
106
|
+
if not args:
|
|
107
|
+
# None and [] both mean "no parameters". Preparing a plan for zero arguments
|
|
108
|
+
# would cost a round trip and buy nothing.
|
|
109
|
+
return Rows(self._plpy.execute(sql))
|
|
110
|
+
|
|
111
|
+
if not argtypes:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"args were given without argtypes: every bind type must be stated. "
|
|
114
|
+
"Binding the wrong Postgres type does not raise — it makes the call fail "
|
|
115
|
+
"to resolve, and inferring types from Python values is how that trap "
|
|
116
|
+
"gets set."
|
|
117
|
+
)
|
|
118
|
+
if len(args) != len(argtypes):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"a query with {} args needs {} argtypes, got {}".format(
|
|
121
|
+
len(args), len(args), len(argtypes)
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
plan = self._plpy.prepare(sql, argtypes)
|
|
126
|
+
return Rows(self._plpy.execute(plan, args))
|
aflib/params.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Convert the text an HTTP caller sent into the value a body needs.
|
|
2
|
+
|
|
3
|
+
Every parameter of an `httpEnabled` function must be declared `text`: a typed declaration
|
|
4
|
+
makes the endpoint uncallable, because the platform binds every query-string argument as
|
|
5
|
+
`character varying` and Postgres cannot match that to an `integer` or `jsonb` parameter. So
|
|
6
|
+
every value arriving from HTTP is a `str`, and every body has to convert it.
|
|
7
|
+
|
|
8
|
+
Hand-rolled, `int(p_id_receipt)` raises `ValueError`, which escapes as a 500 — telling the
|
|
9
|
+
caller the server broke when in fact they sent a bad id. These converters raise
|
|
10
|
+
`BadParameter` instead, which the edge can turn into a 400.
|
|
11
|
+
|
|
12
|
+
This is an edge concern only. A function called from SQL by another function receives
|
|
13
|
+
properly typed arguments, because there the caller chooses the bind types.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from datetime import date
|
|
17
|
+
from decimal import Decimal, InvalidOperation
|
|
18
|
+
|
|
19
|
+
from aflib import codec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BadParameter(Exception):
|
|
23
|
+
"""A caller-supplied parameter is missing when required, or cannot be converted."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _blank(value):
|
|
27
|
+
"""Is this "the user left it empty"?
|
|
28
|
+
|
|
29
|
+
Exactly `None` (never sent) and `''` (sent with no value) — those are different at the
|
|
30
|
+
platform level, but neither converts to anything, so both mean blank here. A
|
|
31
|
+
whitespace-only string is NOT blank: trimming first would let `?p_id=%20` masquerade as
|
|
32
|
+
an absent parameter and hide the caller's mistake.
|
|
33
|
+
"""
|
|
34
|
+
return value is None or value == ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fail(name, message):
|
|
38
|
+
raise BadParameter(name + ": " + message if name else message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def as_int(value, name=None, required=False):
|
|
42
|
+
"""Convert `value` to an `int`, or `None` when it was left blank."""
|
|
43
|
+
if _blank(value):
|
|
44
|
+
if required:
|
|
45
|
+
_fail(name, "a value is required")
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
return int(value)
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
_fail(name, "expected a whole number, got " + repr(value))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Postgres renders booleans as 't'/'f', so a value that made a round trip through the
|
|
54
|
+
# database converts back without surprise. 'yes'/'on'/'sí' are left out on purpose: one
|
|
55
|
+
# vocabulary, and a rejected value tells the caller exactly what to send.
|
|
56
|
+
_TRUE = frozenset(("true", "1", "t"))
|
|
57
|
+
_FALSE = frozenset(("false", "0", "f"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def as_bool(value, name=None, required=False):
|
|
61
|
+
"""Convert `value` to a `bool`, or `None` when it was left blank."""
|
|
62
|
+
if _blank(value):
|
|
63
|
+
if required:
|
|
64
|
+
_fail(name, "a value is required")
|
|
65
|
+
return None
|
|
66
|
+
folded = value.strip().lower() if isinstance(value, str) else value
|
|
67
|
+
if folded in _TRUE:
|
|
68
|
+
return True
|
|
69
|
+
if folded in _FALSE:
|
|
70
|
+
return False
|
|
71
|
+
_fail(name, "expected true/false, 1/0 or t/f, got " + repr(value))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def as_decimal(value, name=None, required=False):
|
|
75
|
+
"""Convert `value` to a `Decimal`, or `None` when it was left blank."""
|
|
76
|
+
if _blank(value):
|
|
77
|
+
if required:
|
|
78
|
+
_fail(name, "a value is required")
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
number = Decimal(value)
|
|
82
|
+
except (TypeError, ValueError, InvalidOperation):
|
|
83
|
+
_fail(name, "expected a number, got " + repr(value))
|
|
84
|
+
# Decimal accepts "nan" and "Infinity". A NaN is not equal to itself, so letting one
|
|
85
|
+
# through would quietly corrupt every comparison and total downstream.
|
|
86
|
+
if not number.is_finite():
|
|
87
|
+
_fail(name, "expected a finite number, got " + repr(value))
|
|
88
|
+
return number
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def as_date(value, name=None, required=False):
|
|
92
|
+
"""Convert `value` to a `date`, or `None` when it was left blank.
|
|
93
|
+
|
|
94
|
+
ISO `YYYY-MM-DD` only. `01/02/2026` is 1 February in Spain and 2 January in the United
|
|
95
|
+
States, so a permissive parser would silently produce the wrong day for half of all
|
|
96
|
+
ambiguous dates. Postgres emits dates in ISO form, so round trips are exact.
|
|
97
|
+
"""
|
|
98
|
+
if _blank(value):
|
|
99
|
+
if required:
|
|
100
|
+
_fail(name, "a value is required")
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
return date.fromisoformat(value)
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
_fail(name, "expected a date as YYYY-MM-DD, got " + repr(value))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def as_json(value, name=None, required=False):
|
|
109
|
+
"""Decode `value` as JSON, or `None` when it was left blank."""
|
|
110
|
+
if _blank(value):
|
|
111
|
+
if required:
|
|
112
|
+
_fail(name, "a value is required")
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
decoded = codec.loads(value)
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
_fail(name, "expected valid JSON, got " + repr(value))
|
|
118
|
+
# 'null' is valid JSON and decodes to None, so a non-blank input can still yield
|
|
119
|
+
# nothing. `required` promises a non-None RESULT, so it has to be checked here too —
|
|
120
|
+
# this is the only converter where that can happen.
|
|
121
|
+
if decoded is None and required:
|
|
122
|
+
_fail(name, "a value is required")
|
|
123
|
+
return decoded
|
aflib/result.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The envelope a function body returns.
|
|
2
|
+
|
|
3
|
+
A plpython body's only output is its return value, which the platform wraps as
|
|
4
|
+
`[{"result": <value>}]`. It cannot set a status code and cannot set a response header —
|
|
5
|
+
both are JavaScript-only — so **success and failure are expressed inside the value**.
|
|
6
|
+
An HTTP caller sees 200 either way; `ok` is what tells them which happened.
|
|
7
|
+
|
|
8
|
+
from aflib import ok, fail
|
|
9
|
+
|
|
10
|
+
row = db.query("SELECT * FROM t WHERE id = $1", [tid], ["integer"]).one()
|
|
11
|
+
if row is None:
|
|
12
|
+
return fail("not_found", "No receipt with that id")
|
|
13
|
+
return ok(row)
|
|
14
|
+
|
|
15
|
+
Both go through `codec`, so a `Decimal` money column renders exactly and an
|
|
16
|
+
unserialisable value raises instead of being quietly stringified — an envelope that
|
|
17
|
+
mangled its payload would ship a wrong answer under `ok: true`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from aflib import codec
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ok(data=None):
|
|
24
|
+
"""Success. `data` may be any JSON-serialisable value, including a falsy one."""
|
|
25
|
+
return codec.dumps({"ok": True, "data": data})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fail(code, message):
|
|
29
|
+
"""Failure, with a short stable `code` and a human-readable `message`.
|
|
30
|
+
|
|
31
|
+
No `data` key: a caller reading data on a failure path is a bug waiting to happen,
|
|
32
|
+
and emitting `"data": null` alongside the error invites exactly that.
|
|
33
|
+
"""
|
|
34
|
+
return codec.dumps({"ok": False, "error": {"code": code, "message": message}})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aflib
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Helper library for business logic inside Airflows plpython function bodies
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# aflib
|
|
11
|
+
|
|
12
|
+
Helper library for business logic inside [Airflows](https://flows.ninja) `plpython3u`
|
|
13
|
+
function bodies.
|
|
14
|
+
|
|
15
|
+
An Airflows function body is a bare Python snippet with a `plpy` global. It cannot import
|
|
16
|
+
another function body, cannot see the HTTP request, and cannot shape the HTTP response — so
|
|
17
|
+
every body ends up re-implementing the same parameter parsing, the same row reading and the
|
|
18
|
+
same result envelope. aflib is that plumbing, written once.
|
|
19
|
+
|
|
20
|
+
**Status: early, and deliberately so.** Nothing enters this library until it has run on a
|
|
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
|
|
23
|
+
so low.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
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`:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pythonPackage aflib==0.0.1
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then import it from a function body.
|
|
36
|
+
|
|
37
|
+
## What 0.0.1 gives you
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from aflib import BadParameter, Db, fail, ok, params
|
|
41
|
+
|
|
42
|
+
db = Db(plpy)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
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
|
+
|
|
49
|
+
row = db.query(
|
|
50
|
+
'SELECT id, amount FROM "Economic"."Receipt" WHERE id = $1',
|
|
51
|
+
[receipt_id], ["integer"],
|
|
52
|
+
).one()
|
|
53
|
+
|
|
54
|
+
if row is None:
|
|
55
|
+
return fail("not_found", "No receipt with that id")
|
|
56
|
+
|
|
57
|
+
return ok(row)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **`Db(plpy)`** — `plpy` is injected, never imported, because the function body is the only
|
|
61
|
+
composition root available. `db.query(...)` returns a `Rows` you narrow with `.one()`
|
|
62
|
+
(`None` for no row, and it refuses more than one rather than silently taking the first),
|
|
63
|
+
`.rows()` for all of them, or `.scalar(column)` for a single named value.
|
|
64
|
+
- **`params.as_int` / `as_bool` / `as_decimal` / `as_date` / `as_json`** — every HTTP
|
|
65
|
+
parameter arrives as text and must be *declared* `text`, so every body converts. These
|
|
66
|
+
raise `BadParameter`, which is the caller's fault and should become a 4xx-shaped failure
|
|
67
|
+
rather than an uncaught exception.
|
|
68
|
+
- **`ok(data)` / `fail(code, message)`** — a body's only output is its return value, which
|
|
69
|
+
the platform wraps as `[{"result": …}]`. There is no status code and no header, so success
|
|
70
|
+
and failure both have to be expressed *inside* the value.
|
|
71
|
+
- **`codec.dumps` / `loads`** — JSON that raises on anything it cannot serialize instead of
|
|
72
|
+
stringifying it. `numeric` columns arrive as `decimal.Decimal` and are encoded as strings,
|
|
73
|
+
never floats, so exactness survives the round trip.
|
|
74
|
+
|
|
75
|
+
## Platform constraints worth knowing before you build on it
|
|
76
|
+
|
|
77
|
+
These are observed on a live instance, not inferred from documentation. They are the reason
|
|
78
|
+
the library has the shape it does.
|
|
79
|
+
|
|
80
|
+
- **Every HTTP-facing parameter must be declared `text`.** Declare `integer` and the platform
|
|
81
|
+
binds `varchar` at call time, no candidate signature matches, and the endpoint fails with
|
|
82
|
+
*function does not exist* — it cannot be invoked at all. This is not a casting
|
|
83
|
+
inconvenience; the endpoint is simply unreachable. Convert inside the body instead.
|
|
84
|
+
- **Function bodies compose only through SQL** — `SELECT "Schema"."fn"(…)`. There is no
|
|
85
|
+
shared module and no package-local import, so a helper another body needs is a database
|
|
86
|
+
function, not a Python one.
|
|
87
|
+
- **A body cannot see the request or shape the response.** Its globals are exactly `GD`,
|
|
88
|
+
`SD`, `args`, its declared parameters and `plpy`: no request object, no undeclared query
|
|
89
|
+
parameters, no body, no headers. It cannot set a status code, a header or a redirect.
|
|
90
|
+
- **`jsonb` is never decoded** — it always arrives as `str`, even through `plpy.prepare`.
|
|
91
|
+
`numeric` arrives as `decimal.Decimal` and `bytea` as `bytes`; neither is JSON-serializable
|
|
92
|
+
by default, and `numeric` is every money column.
|
|
93
|
+
- **Never cache request-scoped state in `GD` or `SD`.** Consecutive calls land on different
|
|
94
|
+
pooled backends, and the connection role varies per caller.
|
|
95
|
+
|
|
96
|
+
## Tests
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
uv run pytest
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The unit tests run against a fake `plpy`, which verifies control flow and nothing
|
|
103
|
+
transactional. Anything involving a savepoint is proven on the platform, not here.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT — see `LICENSE`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
aflib/__init__.py,sha256=6klh-cxQ1FB5BSv5RohGrAGXrp7gn_AdtU7pvrtid7M,1256
|
|
2
|
+
aflib/codec.py,sha256=3y8K7lE6oOBMSE1yuX17N5nfTfdl1vwkCSCOJsx1_KY,1473
|
|
3
|
+
aflib/db.py,sha256=hrM4PGRuEdZIUGgprsia8bzG8wzUrrbePxRoWACtQzM,5375
|
|
4
|
+
aflib/params.py,sha256=XvqkxuG61T98_iePK0n0rEpqFxw8Gjxk6eYZdvJKx24,4762
|
|
5
|
+
aflib/result.py,sha256=beUIazouQGMYaES_PQ45uwZTLFo_VZZk9MmMUJTihJ8,1353
|
|
6
|
+
aflib-0.0.1.dist-info/METADATA,sha256=kyHxaTggxRUOcWz7swNn5YGifLX7DYiK1Nbb_cqznV4,4515
|
|
7
|
+
aflib-0.0.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
aflib-0.0.1.dist-info/licenses/LICENSE,sha256=tB-lgJu4QGzvH2bhR6rwthGObWOeTSZ7lZH3Dbc7XUE,1075
|
|
9
|
+
aflib-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 English Connection
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|