aflib
Helper library for business logic inside Airflows plpython3u
function bodies.
An Airflows function body is a bare Python snippet with a plpy global. It cannot import
another function body, cannot see the HTTP request, and cannot shape the HTTP response — so
every body ends up re-implementing the same parameter parsing, the same row reading and the
same result envelope. aflib is that plumbing, written once.
Status: early, and deliberately so. Nothing enters this library until it has run on a real Airflows instance — written first as a plain function body, deployed, called, and observed. A version ships only capabilities that have each run there, which is why the numbers start so low.
Install
Airflows resolves Python packages from public PyPI by name and version only; there is no
local-import, git or private-index route. Declare aflib in the instance's Python packages
pinned to an exact version (0.0.9). The exported package list may show the pin as
aflib==0.0.9==0.0.9: that is how Airflows stores a pinned version, not an error.
Then import it from a function body. To check which version a body really imports, return
aflib.__version__ from it.
What 0.0.9 gives you
config.get(plpy, name, required=False, secret=False)— a platform variable, read with the name bound as a query parameter. The platform's ownget_env_variablepastes the name into the SQL text, so a name containing'breaks or changes the query. A missing variable isNone;required=Trueraisesconfig.MissingConfig, whicherrors.to_failreports as a plain internal error (the variable's name goes to the log, not to the caller).secret=Truereturns aconfig.Secret: itsstr,reprandformatsaySecret('***'),jsonrefuses it, andreveal()is the only way to the value.http.request(method, url, *, timeout, headers=None, json_body=None, data=None)— urllib with a mandatory timeout: nothing else can stop a body blocked on a socket, and the socket has none by default. A 4xx/5xx comes back as aResponse(status,ok,body,text,json());raise_for_status()and every network failure raisehttp.HttpError, whose text names the method, host and status but never the path or query, so a token in the URL cannot reach a log. Onlyhttp://andhttps://URLs. Not built on therequeststhe platform bundles: its exceptions copy the full URL.identity.unscoped(plpy, role="modelsadmin")— run a block as another role andSET ROLEback to the caller infinally, also when the block raises. NeverRESET ROLE: the session user is the connection pool's superuser login. Take authorization decisions before the block.trigger.changedignores the recursion flag under both spellings,update_by_triggerandupdated_by_trigger.
All three ran on a real instance on 2026-10-20 before release, exact source, called by a non-admin user.
from aflib import config, http, identity
base = config.get(plpy, "IAM_URL", required=True)
password = config.get(plpy, "IAM_ADMIN_PASSWORD", required=True, secret=True)
r = http.request("POST", base + "/token", timeout=10, json_body={"password": password.reveal()})
r.raise_for_status()
with identity.unscoped(plpy):
plpy.execute("...") # as modelsadmin; back to the caller afterwards
config queries as the calling user, so the variables must be readable by that user's role.
What 0.0.8 fixes
Batch.run accepts a predicate whose column name contains the letters _t.
id_tuition IS NOT NULL, start_time < now() and is_test = false are
predicates. 0.0.7 refused them, because the guard searched for _t as a
substring. _t.col and "_t".col are still refused: that alias exists only on
the outer UPDATE.
What 0.0.7 gives you
Everything in 0.0.6, plus three capabilities that ran on a real instance on 2026-10-01 before they were extracted:
Batch(plpy).run(...)— one committed chunk. The pending predicate is placed in the outerWHEREand in the subquery, and the result carriesdone,remainingandseconds.workflow.start/status/stuck— insert aPENDINGworkflow instance and its variables, read the newest instance for a business key, and list instances that are stillPENDINGorIN_PROGRESSafter an interval.Outbox(plpy, outbox=..., delivery=..., subscription=...).emit/pump— one event plus a pending delivery per active subscriber, then a chunk of deliveries. Each subscriber runs insideplpy.subtransaction(). Failures retry withattempts,next_attempt_atandlast_error, and dead-letter after 3 attempts.
from aflib import workflow
from aflib.events import Outbox
workflow.start(plpy, "WFP_cadena", "case-58", {
"SomeSchema.some_function.p_id": ("INTEGER", 42),
})
box = Outbox(plpy, outbox="app.outbox", delivery="app.delivery", subscription="app.sub")
box.emit("order.placed", {"id": 42}, 42)
box.pump(deliver, limit=50)
deliver(subscriber, outbox_id, attempt) does the work and raises to fail the
attempt. Table names are identifiers you wrote, not request data.
What 0.0.6 gives you
0.0.1–0.0.3 (read/result, errors/logging, transaction.atomic) plus what
the 2026-09 platform probes showed, plus two verified fixes:
params.as_boolaccepts a genuine Pythonboolas well as thetrue/false/1/0/t/ftext vocabulary. AbooleancustomParameter on the non-HTTP path binds asTrue/False; converting that throughas_boolno longer raises.loggingtreatssourceas reserved, the same way it already treatslevelandevent. Passingsource=as a field raises rather than silently re-labelling the emitting function.authz.can(plpy, "Schema.Entity", "UPDATE")andauthz.snapshot(plpy)— table GRANTs viahas_table_privilege. Not RLS. Snapshot beforeSET ROLE.trigger.changed(TD)/event/when— a plpython trigger can writeTD["new"]and return"MODIFY". Compare OLD/NEW; ignoresearch.functions.call(plpy, "Schema.fn", args, argtypes)— compose through SQL with Postgres bind types. Text binds to a typed signature do not resolve.identity.current_user(plpy)—SELECT current_user. Not a User id.
Core API (0.0.1–0.0.3, still in 0.0.9)
from aflib import Db, errors, fail, logging, ok, params
db = Db(plpy)
log = logging.logger(plpy, source="Economic.receipt_cancel")
try:
receipt_id = params.as_int(p_id_receipt, name="p_id_receipt", required=True)
row = db.query(
'SELECT id, amount FROM "Economic"."Receipt" WHERE id = $1',
[receipt_id], ["integer"],
).one()
if row is None:
return fail("not_found", "No receipt with that id")
return ok(row)
except Exception as exc:
return errors.to_fail(exc, log=log)
The try is not optional, and except Exception is not laziness. See
Never let an exception escape below — both are load-bearing.
Db(plpy)—plpyis injected, never imported, because the function body is the only composition root available.db.query(...)returns aRowsyou narrow with.one()(Nonefor no row, and it refuses more than one rather than silently taking the first),.rows()for all of them, or.scalar(column)for a single named value.params.as_int/as_bool/as_decimal/as_date/as_json— every HTTP parameter arrives as text and must be declaredtext, so every body converts. These raiseBadParameter, which is the caller's fault and should become a 4xx-shaped failure rather than an uncaught exception.ok(data)/fail(code, message)— a body's only output is its return value, which the platform wraps as[{"result": …}]. There is no status code and no header, so success and failure both have to be expressed inside the value.codec.dumps/loads— JSON that raises on anything it cannot serialize instead of stringifying it.numericcolumns arrive asdecimal.Decimaland are encoded as strings, never floats, so exactness survives the round trip.errors.to_fail(exc, log=log)— converts any exception into a failure envelope, keeping the caller's message and the log's detail as two separate strings. ABadParameteris described to the caller in full, because it is about their own input; anything else becomes a genericinternal_errorwhose detail goes only to the log. It returns the envelope even if logging itself fails, because a broken log sink must not turn a handled failure back into an escaping one.logging.logger(plpy, source=…)— one JSON object per line,levelandeventalways present, written throughplpy.log. It never raises, even when handed a value that cannot be encoded: the line is most needed in the error path, so a single exotic field must not be able to silence it.transaction.atomic(plpy)— a savepoint that also rolls back a returned failure. See A returned failure does not roll back — this is a narrow helper for one specific trap, not a unit of work.
Platform constraints worth knowing before you build on it
These are observed on a live instance, not inferred from documentation. They are the reason the library has the shape it does.
- Every HTTP-facing parameter must be declared
text. Declareintegerand the platform bindsvarcharat call time, no candidate signature matches, and the endpoint fails with function does not exist. Abooleanparameter bindstrue/false, but?p_flag=maybeis HTTP 500Cannot cast to booleanbefore the body runs. Convert withparamsinside the body. - Function bodies compose only through SQL —
SELECT "Schema"."fn"(…). There is no shared module and no package-local import, so a helper another body needs is a database function, not a Python one. - A body cannot see the request or shape the response. Its globals are exactly
GD,SD,args, its declared parameters andplpy: no request object, no undeclared query parameters, no body, no headers. It cannot set a status code, a header or a redirect. jsonbis never decoded — it always arrives asstr, even throughplpy.prepare.numericarrives asdecimal.Decimalandbyteaasbytes; neither is JSON-serializable by default, andnumericis every money column.- Never cache request-scoped state in
GDorSD. Consecutive calls land on different pooled backends, and the connection role varies per caller.
Never let an exception escape
An unhandled exception's message is returned to the caller, and it includes internal detail
that the caller should not see. A function body cannot influence that response: it is built
outside the database from the driver's error message, and there is no setting that trims it.
The only way to control what a caller receives is to not raise — catch everything and return
a failure envelope instead. That is what errors.to_fail is for, and it is why every example
here wraps its work in a try.
except Exception is required rather than merely convenient. plpy.Error does not inherit
from plpy.SPIError, so the narrower except plpy.SPIError catches neither plpy.error()
nor an ordinary Python exception — only their common ancestor covers all three.
Two related facts worth knowing before you design around logging:
-
Of plpython's five informational sinks, only
logandwarningare written.debug,infoandnoticereturn successfully and are discarded, becauselog_min_messagesdefaults toWARNINGand those three rank below it whilelogranks above. A level that vanishes is worse than one that does not exist, which is whyaflib.loggingputs severity in the payload and writes everything throughplpy.log. -
The platform logs every call's parameter values in plaintext before the body runs. So never pass a secret as a function parameter — it is recorded no matter how careful the body is — and do not log parameter values yourself, since that cannot reduce the exposure and only makes it look deliberate.
-
A callee's error message reaches its caller unredacted. Function bodies compose through SQL, and when a callee raises, its message crosses to the caller intact while its exception type does not. So a business outcome has to travel as a returned value —
ok/fail— and never as an exception, and nothing sensitive may ever go into an exception message.
A returned failure does not roll back
A function body is one transaction it does not control. plpy.commit() fails with invalid
transaction termination, because a function invoked inside a query cannot terminate the
transaction it is running in. What a body can do is subdivide that transaction with savepoints
via plpy.subtransaction(), and those work exactly as you would hope: they roll back on any
exception, Python or SQL alike, the original exception continues unchanged, nesting rolls back
only the inner block, and a trigger's writes roll back together with the row that fired them.
Two further rules complete the picture: an uncaught exception discards the whole call, and a normal return commits the whole call.
That last one is the trap, because it applies to a returned failure too:
with plpy.subtransaction():
db.query("INSERT …")
return fail("not_allowed", "…") # the INSERT is COMMITTED
plpy.subtransaction() reacts to exceptions, and a business failure is not an exception — it
cannot be, since an exception's type does not survive crossing a function boundary. So the most
natural code silently half-applies the change. transaction.atomic exists for exactly this:
from aflib import errors, transaction
try:
with transaction.atomic(plpy) as tx:
db.query("INSERT …")
if not allowed:
tx.reject("not_allowed", "You may not do that") # rolls back
tx.succeed({"id": 7})
return tx.outcome
except Exception as exc:
return errors.to_fail(exc, log=log)
reject() ends the block and rolls back; outcome is read afterwards. A scope that ends
without succeed() or reject() raises rather than returning an empty success for work that
may have half happened.
It is deliberately not a unit of work and cannot commit anything — the name describes the guarantee it provides (the block is all-or-nothing), not machinery it does not have.
Tests
uv run pytest
The unit tests run against a fake plpy, which verifies control flow and nothing
transactional. Anything involving a savepoint is proven on the platform, not here.
License
MIT — see LICENSE.
Metadata
Release files for aflib 0.0.9
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| aflib-0.0.9.tar.gz | 58.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aflib-0.0.9-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 98.7 kB
Release files / aflib-0.0.9.tar.gz
| Download URL | aflib-0.0.9.tar.gz |
|---|---|
| Size | 58.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7ae30c55e05e850004d61537f7fb3324a917e0f59327c6858c0c3ef9f7cf179b
|
|
BLAKE2b-256 checksum How to use checksums |
eaa57267d3eb6ecce92b2b14a09bad676f260be2e8bda3e1febf37b30c7be64b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / aflib-0.0.9-py3-none-any.whl
| Download URL | aflib-0.0.9-py3-none-any.whl |
|---|---|
| Size | 40.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
551eeecaebac4d1fdee897d77f685e1dc3c13a4adfac0bd466d515d7d1c79399
|
|
BLAKE2b-256 checksum How to use checksums |
746784e6a749fe596d8e6bbeddd459fb882e38ba5ff16edd48994a61d52a505d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for aflib 0.0.9
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.