aflib 0.0.1__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/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ .pytest_cache/
aflib-0.0.1/LICENSE ADDED
@@ -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.
aflib-0.0.1/PKG-INFO ADDED
@@ -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`.
aflib-0.0.1/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # aflib
2
+
3
+ Helper library for business logic inside [Airflows](https://flows.ninja) `plpython3u`
4
+ function bodies.
5
+
6
+ An Airflows function body is a bare Python snippet with a `plpy` global. It cannot import
7
+ another function body, cannot see the HTTP request, and cannot shape the HTTP response — so
8
+ every body ends up re-implementing the same parameter parsing, the same row reading and the
9
+ same result envelope. aflib is that plumbing, written once.
10
+
11
+ **Status: early, and deliberately so.** Nothing enters this library until it has run on a
12
+ real Airflows instance — written first as a plain function body, deployed, called, and
13
+ observed. One version corresponds to one proven capability, which is why the numbers start
14
+ so low.
15
+
16
+ ## Install
17
+
18
+ Airflows resolves Python packages from public PyPI by name and version only; there is no
19
+ local-import, git or private-index route. Declare it in
20
+ `models/pythonPackages/pythonPackages.airflows`:
21
+
22
+ ```
23
+ pythonPackage aflib==0.0.1
24
+ ```
25
+
26
+ Then import it from a function body.
27
+
28
+ ## What 0.0.1 gives you
29
+
30
+ ```python
31
+ from aflib import BadParameter, Db, fail, ok, params
32
+
33
+ db = Db(plpy)
34
+
35
+ try:
36
+ 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
+
40
+ row = db.query(
41
+ 'SELECT id, amount FROM "Economic"."Receipt" WHERE id = $1',
42
+ [receipt_id], ["integer"],
43
+ ).one()
44
+
45
+ if row is None:
46
+ return fail("not_found", "No receipt with that id")
47
+
48
+ return ok(row)
49
+ ```
50
+
51
+ - **`Db(plpy)`** — `plpy` is injected, never imported, because the function body is the only
52
+ composition root available. `db.query(...)` returns a `Rows` you narrow with `.one()`
53
+ (`None` for no row, and it refuses more than one rather than silently taking the first),
54
+ `.rows()` for all of them, or `.scalar(column)` for a single named value.
55
+ - **`params.as_int` / `as_bool` / `as_decimal` / `as_date` / `as_json`** — every HTTP
56
+ parameter arrives as text and must be *declared* `text`, so every body converts. These
57
+ raise `BadParameter`, which is the caller's fault and should become a 4xx-shaped failure
58
+ rather than an uncaught exception.
59
+ - **`ok(data)` / `fail(code, message)`** — a body's only output is its return value, which
60
+ the platform wraps as `[{"result": …}]`. There is no status code and no header, so success
61
+ and failure both have to be expressed *inside* the value.
62
+ - **`codec.dumps` / `loads`** — JSON that raises on anything it cannot serialize instead of
63
+ stringifying it. `numeric` columns arrive as `decimal.Decimal` and are encoded as strings,
64
+ never floats, so exactness survives the round trip.
65
+
66
+ ## Platform constraints worth knowing before you build on it
67
+
68
+ These are observed on a live instance, not inferred from documentation. They are the reason
69
+ the library has the shape it does.
70
+
71
+ - **Every HTTP-facing parameter must be declared `text`.** Declare `integer` and the platform
72
+ binds `varchar` at call time, no candidate signature matches, and the endpoint fails with
73
+ *function does not exist* — it cannot be invoked at all. This is not a casting
74
+ inconvenience; the endpoint is simply unreachable. Convert inside the body instead.
75
+ - **Function bodies compose only through SQL** — `SELECT "Schema"."fn"(…)`. There is no
76
+ shared module and no package-local import, so a helper another body needs is a database
77
+ function, not a Python one.
78
+ - **A body cannot see the request or shape the response.** Its globals are exactly `GD`,
79
+ `SD`, `args`, its declared parameters and `plpy`: no request object, no undeclared query
80
+ parameters, no body, no headers. It cannot set a status code, a header or a redirect.
81
+ - **`jsonb` is never decoded** — it always arrives as `str`, even through `plpy.prepare`.
82
+ `numeric` arrives as `decimal.Decimal` and `bytea` as `bytes`; neither is JSON-serializable
83
+ by default, and `numeric` is every money column.
84
+ - **Never cache request-scoped state in `GD` or `SD`.** Consecutive calls land on different
85
+ pooled backends, and the connection role varies per caller.
86
+
87
+ ## Tests
88
+
89
+ ```
90
+ uv run pytest
91
+ ```
92
+
93
+ The unit tests run against a fake `plpy`, which verifies control flow and nothing
94
+ transactional. Anything involving a savepoint is proven on the platform, not here.
95
+
96
+ ## License
97
+
98
+ MIT — see `LICENSE`.
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "aflib"
3
+ version = "0.0.1"
4
+ description = "Helper library for business logic inside Airflows plpython function bodies"
5
+ readme = "README.md"
6
+ # The platform runs Python 3.11.2 (observed, not assumed — see docs/platform-constraints.md).
7
+ # There is no reason to support anything older: nothing else will ever import this.
8
+ requires-python = ">=3.11"
9
+ license = { text = "MIT" }
10
+
11
+ [dependency-groups]
12
+ dev = ["pytest>=8"]
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/aflib"]
20
+
21
+ # An ALLOWLIST, deliberately — not an exclude list. The default sdist ships every tracked
22
+ # file, which swept up internal working notes that were never meant for distribution. A
23
+ # PyPI release cannot be withdrawn — a deleted release stays downloadable — so what gets
24
+ # published must not depend on anyone remembering to exclude something. With an allowlist,
25
+ # a new file is absent from the sdist until someone adds it here on purpose.
26
+ #
27
+ # Note this file is itself published: nothing in these comments should be anything you
28
+ # would not put on a public page.
29
+ [tool.hatch.build.targets.sdist]
30
+ include = [
31
+ "src/aflib",
32
+ "tests",
33
+ "README.md",
34
+ "LICENSE",
35
+ ]
@@ -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
+ ]
@@ -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)
@@ -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))
@@ -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
@@ -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}})