Metadata-Version: 2.5
Name: aflib
Version: 0.0.6
Summary: Helper library for business logic inside Airflows plpython function bodies
License: MIT
License-File: LICENSE
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# aflib

Helper library for business logic inside [Airflows](https://flows.ninja) `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. One version corresponds to one proven capability, 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 it in
`models/pythonPackages/pythonPackages.airflows` by pinning **name and version in
separate fields** — never as a single `aflib==0.0.6` string (that form produced
malformed `name==ver==ver` entries in a real instance's package list):

```
name:    aflib
version: 0.0.6
```

Then import it from a function body.

## 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_bool`** accepts a genuine Python `bool` as well as the
  `true`/`false`/`1`/`0`/`t`/`f` text vocabulary. A `boolean` customParameter
  on the non-HTTP path binds as `True`/`False`; converting that through
  `as_bool` no longer raises.
- **`logging`** treats `source` as reserved, the same way it already treats
  `level` and `event`. Passing `source=` as a field raises rather than
  silently re-labelling the emitting function.
- **`authz.can(plpy, "Schema.Entity", "UPDATE")`** and **`authz.snapshot(plpy)`** —
  table GRANTs via `has_table_privilege`. Not RLS. Snapshot before `SET ROLE`.
- **`trigger.changed(TD)` / `event` / `when`** — a plpython trigger can
  write `TD["new"]` and return `"MODIFY"`. Compare OLD/NEW; ignore `search`.
- **`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.

Pin **name and version in separate fields** (`aflib` / `0.0.6`), not
`aflib==0.0.6` as one string.

## Core API (0.0.1–0.0.3, still in 0.0.6)

```python
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](#never-let-an-exception-escape) below — both are load-bearing.

- **`Db(plpy)`** — `plpy` is injected, never imported, because the function body is the only
  composition root available. `db.query(...)` returns a `Rows` you narrow with `.one()`
  (`None` for 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 *declared* `text`, so every body converts. These
  raise `BadParameter`, 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. `numeric` columns arrive as `decimal.Decimal` and 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. A `BadParameter` is
  described to the caller in full, because it is about their own input; anything else becomes
  a generic `internal_error` whose 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, `level` and `event` always
  present, written through `plpy.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](#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`.** Declare `integer` and the platform
  binds `varchar` at call time, no candidate signature matches, and the endpoint fails with
  *function does not exist*. A `boolean` parameter binds `true`/`false`, but `?p_flag=maybe`
  is HTTP 500 `Cannot cast to boolean` before the body runs. Convert with `params` inside
  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 and `plpy`: no request object, no undeclared query
  parameters, no body, no headers. It cannot set a status code, a header or a redirect.
- **`jsonb` is never decoded** — it always arrives as `str`, even through `plpy.prepare`.
  `numeric` arrives as `decimal.Decimal` and `bytea` as `bytes`; neither is JSON-serializable
  by default, and `numeric` is every money column.
- **Never cache request-scoped state in `GD` or `SD`.** 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 `log` and `warning` are written.** `debug`,
  `info` and `notice` return successfully and are discarded, because `log_min_messages`
  defaults to `WARNING` and those three rank below it while `log` ranks above. A level that
  vanishes is worse than one that does not exist, which is why `aflib.logging` puts severity
  in the payload and writes everything through `plpy.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:

```python
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:

```python
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`.
