Skip to content

Commit a8c98c1

Browse files
authored
Merge pull request #9 from constructive-io/feat/python-surface-templates
feat: python surface templates, gql and sql
2 parents 15de6c4 + 7a0244a commit a8c98c1

22 files changed

Lines changed: 877 additions & 16 deletions

README.md

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,36 @@ The templates `fun init` scaffolds from: a **feature** in
77

88
```bash
99
fun init billing --surface gql # a GraphQL/HTTP feature — no database
10-
fun init billing --surface sql # a db-connected feature, owning its SQL
10+
fun init billing --surface sql # a db-connected feature — statements, not schema
1111
fun init billing --surface sql --kind sync --route /billing/export
12+
fun init billing --surface sql --lang python # the same feature, in python
1213
fun init report --type python # a platform handler
1314
```
1415

1516
## Structure
1617

1718
```
18-
typescript/ handler/
19-
├── gql/ ├── node-multi-method/
20-
└── sql/ └── python/
19+
typescript/ python/ handler/
20+
├── gql/ ├── gql/ ├── node-multi-method/
21+
└── sql/ └── sql/ └── python/
2122
```
2223

2324
Two axes, and nothing else: the **surface** is what the function is connected to,
24-
the **language** is what you write it in. Python features wait on the python
25-
runtime mirror (constructive-planning#1455, phase 0.3) — until it lands, a
26-
feature is TypeScript and `handler/python` is how python code ships.
25+
the **language** is what you write it in. The four cells are the same feature
26+
shape twice, each at home in its own language — `handler.json` beside a
27+
`package.json` for node, beside a `requirements.txt` for python — over one
28+
runtime, mirrored in both (`functions/runtime` and `functions/runtime-py` in
29+
constructive-db), never two implementations free to disagree. `handler/*` is the
30+
separate thing: a *platform* handler in constructive-db's own tree.
31+
32+
A **page** is a node image (`type: "node-page"`), so `--kind page` is a node
33+
feature; a python feature that serves a browser route puts those methods in a
34+
node manifest nested beside its own.
2735

2836
| surface | what it reaches | database |
2937
|---|---|---|
3038
| `gql` | the tenant's API through `ctx.client`, its buckets, secrets and models | **none**`ctx.db` is not on the context type |
31-
| `sql` | the same, plus a transaction through `ctx.db(fn)` | yes |
39+
| `sql` | the same, plus a transaction through `ctx.db(fn)` (`await ctx.db(fn)` in python) | yes |
3240

3341
`ctx.db(fn)` runs the callback in one transaction that has assumed a
3442
low-privilege role and stamped the invocation's identity claims, so the tenant's
@@ -125,9 +133,29 @@ a container can touch, every route can. `methods[]` carries what makes a functio
125133
a *different* function — its task, its typed `inputs`/`outputs`/`props`.
126134

127135
Dependencies are **not** in `handler.json`. A node feature declares them in the
128-
`package.json` beside it and a python handler in its `requirements.txt`, because
129-
those are the files node and pip actually read; image and system packages belong
130-
to the Dockerfile.
136+
`package.json` beside it and a python feature in its
137+
`handlers/requirements.txt`, because those are the files node and pip actually
138+
read; image and system packages belong to the Dockerfile.
139+
140+
## What a python feature looks like
141+
142+
The same four files, in python's shape. The image is `constructive_runtime` plus
143+
a FastAPI entry point that imports `handlers/handler.py` and serves every public
144+
coroutine in it as `POST /<name>` — so a method is an `async def`, discovered
145+
rather than registered, and `handlers/index.ts` has no python counterpart:
146+
147+
```python
148+
async def export(params: Params, ctx: SqlContext) -> Result:
149+
async def read(db: DbSession):
150+
return await db.fetch("SELECT …")
151+
152+
return {"rows": await ctx.db(read)}
153+
```
154+
155+
Its suite is TypeScript, because the platform it drives is: it registers the
156+
feature from `handler.json`, stages and starts the real python image
157+
(`startPythonImage`), and invokes it through the real queue. The first run builds
158+
the image's venv under `.image/`; later runs reuse it.
131159

132160
## Verifying a template
133161

python/gql/.boilerplate.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"type": "module",
3+
"requiresWorkspace": true,
4+
"questions": [
5+
{
6+
"name": "____name____",
7+
"message": "Feature name (directory, image and the task's category)",
8+
"required": true
9+
},
10+
{
11+
"name": "____method____",
12+
"message": "Function name (the task is <feature>:<function>)",
13+
"required": true
14+
},
15+
{
16+
"name": "____version____",
17+
"message": "Initial version",
18+
"default": "0.0.1"
19+
},
20+
{
21+
"name": "____description____",
22+
"message": "Short description"
23+
}
24+
]
25+
}

python/gql/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# The staged python image and its venv: rebuilt from the template, the runtime
2+
# and this feature's requirements whenever any of them change.
3+
.image/
4+
__pycache__/

python/gql/README.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# ____name____
2+
3+
____description____
4+
5+
```
6+
handlers/handler.json what this feature is, and what it may reach
7+
handlers/handler.py its functions — the image serves every public coroutine
8+
handlers/requirements.txt its pip dependencies, like any other python project
9+
__tests__/ the manifest, the image and the queue, end to end
10+
```
11+
12+
## The surface
13+
14+
This is a **gql** feature, written in python: it is served over HTTP/GraphQL and
15+
**has no database connection**. It reaches the tenant's data through the
16+
tenant's own API, which applies the caller's permissions, and its declared
17+
buckets, secrets and models through the rest of the context — never Postgres
18+
directly. The handler's `ctx` is typed as a protocol that has no `db`, so a
19+
query is a type error rather than a habit. A feature that reads the tenant's
20+
database directly is a **sql** feature
21+
(`fun init <name> --surface sql --lang python`).
22+
23+
## The python image
24+
25+
The image is `constructive_runtime` plus a FastAPI entry point that imports
26+
`handlers/handler.py` and serves **every public coroutine in it** as
27+
`POST /<name>` — the route the platform addresses `____name____:<name>` through.
28+
So a second function is a second `async def` and a second entry in `methods[]`;
29+
nothing else changes. A helper that must not become a route is either a plain
30+
`def` or lives in another module.
31+
32+
The context is the same surface a TypeScript handler gets — `ctx.secrets`,
33+
`ctx.storage`, `ctx.agent`, `ctx.log`, `ctx.job` — because it is the same
34+
runtime, mirrored in python rather than reimplemented. The declared inputs are
35+
compiled once, at generation, into the JSON Schema both languages enforce, so a
36+
payload the node runtime would refuse is refused here too.
37+
38+
The **kind** (`job`, `sync`, `page`) is not a different template, only a
39+
different way in: a job is enqueued and run by the worker, a sync is invoked
40+
through the gateway on the caller's connection. It is expressed in `handler.json`
41+
as `accessChannels` plus `route`, and `fun init --kind` fills it. A page is a
42+
node image (`type: "node-page"`), so a python feature serving one puts those
43+
methods in a node manifest beside this one.
44+
45+
## What it may reach
46+
47+
`handlers/handler.json` is this feature's declaration and the only place its
48+
identity and capabilities are written down — the platform reads it at deploy
49+
time, and the test reads the same file, so the two cannot drift.
50+
51+
Anything undeclared is unreachable: `ctx.storage` and `ctx.secrets` raise on a
52+
key this file never declared rather than answering `None`. Declare what you use,
53+
as you write it:
54+
55+
```json
56+
"requires": {
57+
"buckets": ["exports"],
58+
"secrets": [{ "name": "STRIPE_KEY", "required": true }],
59+
"configs": [{ "name": "EXPORT_ROW_LIMIT", "required": false }],
60+
"modules": ["notifications_module"],
61+
"models": ["gpt-4o"]
62+
}
63+
```
64+
65+
Always the **logical** key, never a physical name:
66+
`ctx.storage.write('exports', …)` resolves to this tenant's bucket per
67+
invocation, and `ctx.secrets.get('STRIPE_KEY')` reads from this tenant's own
68+
store. Secret *values* never travel in the manifest, the capability bundle, the
69+
payload, the logs, or the pod's environment.
70+
71+
pip dependencies go in `handlers/requirements.txt`, where python already keeps
72+
them — the image installs it on top of the runtime's own. `handler.json` carries
73+
only what the platform reads, and system packages belong to the Dockerfile.
74+
75+
## Running it
76+
77+
```bash
78+
pgpm docker start --image docker.io/constructiveio/postgres-plus:18
79+
eval "$(pgpm env)"
80+
pnpm --filter "@constructive-functions/feature-____name____" test
81+
```
82+
83+
The suite is TypeScript because the platform it drives is: it registers this
84+
feature from `handler.json`, stages and starts the real python image, and
85+
invokes it through the real queue. The first run builds the image's venv under
86+
`.image/` (git-ignored) and later runs reuse it, so only the first is slow.
87+
88+
## Next
89+
90+
The test above is the loop: it clones a seeded template database, needs no
91+
cluster, and is the only thing you need while writing the handler. When you want
92+
this feature on a real stack, from the root of the checkout it lives in:
93+
94+
```bash
95+
pnpm fun up --k8s # brings the platform up and registers every feature here
96+
```
97+
98+
Registration reads `handlers/handler.json` — the same file the test reads — so a
99+
manifest-only change needs no rebuild:
100+
101+
```bash
102+
pnpm fun register --apply # write the declaration; --dry-run prints the SQL
103+
```
104+
105+
A registration failure aborts the bring-up rather than being reported as
106+
skipped, which it once was: an unregistered method has no symptom of its own
107+
until something calls it and gets
108+
`No service URL for "____name____:____method____"`.

python/gql/__tests__/queue.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import path from 'node:path';
2+
3+
import type { FunctionsTestResult, RunningImage } from '@constructive-functions/test-utils';
4+
import {
5+
addJob,
6+
getConnections,
7+
registerFeature,
8+
resolveDatabaseId,
9+
runQueuedJobs,
10+
startPythonImage
11+
} from '@constructive-functions/test-utils';
12+
13+
/**
14+
* The platform test: this feature registered from the very file the platform
15+
* reads (`handlers/handler.json`), served by the real python image, invoked
16+
* through the real queue.
17+
*
18+
* A python feature has no `handlers/index.ts` to import — the image discovers
19+
* its methods from `handler.py`, so nothing here reaches into the handler's
20+
* code. What runs is `main.py` importing `handler.py` through
21+
* `constructive_runtime`, with only the container removed; the first time it
22+
* runs, the harness builds the image's venv, which is why the timeout is
23+
* generous.
24+
*
25+
* This is what makes `handler.json` authoritative rather than decorative — a
26+
* capability the manifest forgot to declare fails here, before deploy, and a
27+
* task identifier that disagrees with `<category>:<name>` fails at registration.
28+
*/
29+
const featureDir = path.resolve(__dirname, '..');
30+
31+
const IMAGE = 'features/____name____';
32+
const TASK = '____name____:____method____';
33+
34+
let conn: FunctionsTestResult;
35+
// The worker claims jobs and pins a connection per dispatch, so it runs on the
36+
// harness-owned pool rather than the suite's transaction-bound client.
37+
let pool: ReturnType<FunctionsTestResult['getPool']>;
38+
let image: RunningImage;
39+
let databaseId: string;
40+
41+
beforeAll(async () => {
42+
conn = await getConnections();
43+
pool = conn.getPool();
44+
databaseId = await resolveDatabaseId(pool);
45+
46+
// No pool is handed to the image: this feature has no database connection,
47+
// and one given here would be one `ctx.db` could use.
48+
image = await startPythonImage({ name: IMAGE, featureDir });
49+
await registerFeature(pool, databaseId, featureDir, { image: IMAGE });
50+
}, 600_000);
51+
52+
// A job that failed is left queued for its retry — that is the queue working.
53+
// Each test starts from an empty one so a deliberate failure here is not the job
54+
// the next test's run picks up.
55+
afterEach(async () => {
56+
await pool.query('DELETE FROM app_jobs.jobs WHERE database_id = $1', [databaseId]);
57+
});
58+
59+
afterAll(async () => {
60+
await image?.close();
61+
await conn?.teardown();
62+
});
63+
64+
describe('____name____:____method____ through the platform', () => {
65+
it('runs the job the platform enqueues', async () => {
66+
await addJob(pool, databaseId, TASK, { subject: 'a subject' }, { entity_type: 'platform' });
67+
68+
const { jobs, log } = await runQueuedJobs({ pool, databaseId, images: [image] });
69+
70+
expect(jobs).toHaveLength(1);
71+
expect(log.entries).toEqual([
72+
expect.objectContaining({ task_identifier: TASK, status: 'completed' })
73+
]);
74+
});
75+
76+
// The declaration is the check: the handler asks nothing about its payload,
77+
// because a payload the manifest does not allow never reaches it.
78+
it('refuses a payload the manifest does not allow', async () => {
79+
await addJob(pool, databaseId, TASK, {}, { entity_type: 'platform' });
80+
81+
await expect(runQueuedJobs({ pool, databaseId, images: [image] })).rejects.toThrow(
82+
/subject is required/
83+
);
84+
});
85+
});

python/gql/handlers/handler.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "____name____",
3+
"version": "____version____",
4+
"description": "____description____",
5+
"type": "python",
6+
"port": 8080,
7+
"scope": "platform",
8+
"image": "features/____name____",
9+
"runtime": "http",
10+
"accessChannels": [],
11+
"requires": {
12+
"buckets": [],
13+
"modules": [],
14+
"models": [],
15+
"secrets": [],
16+
"configs": []
17+
},
18+
"methods": [
19+
{
20+
"taskIdentifier": "____name____:____method____",
21+
"description": "____description____",
22+
"icon": "code",
23+
"category": "____name____",
24+
"inputs": [
25+
{ "name": "subject", "type": "string", "description": "What the call is about" }
26+
],
27+
"outputs": [
28+
{ "name": "ok", "type": "boolean", "description": "Whether the call succeeded" }
29+
]
30+
}
31+
]
32+
}

python/gql/handlers/handler.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
____name____:____method____ — the gql surface, in python.
3+
4+
The image's entry point imports this module and serves every public coroutine in
5+
it as `POST /<name>`, which is how the platform addresses `____name____:<name>`.
6+
A second function is a second coroutine here and a second entry in `methods[]`;
7+
nothing else changes. A helper that must not become a route is either a plain
8+
`def` or lives in another module.
9+
"""
10+
11+
from typing import Any, Protocol, TypedDict
12+
13+
14+
class Params(TypedDict):
15+
"""
16+
The inputs `handler.json` declares, as types.
17+
18+
`subject` is a required string there, so it is a `str` here: the runtime
19+
compiles the declaration to JSON Schema and answers a payload that violates
20+
it with a 400 before this coroutine is entered. That is why there is no
21+
check for it below — a presence check here would be re-asking a question the
22+
platform has already refused the request over, and an optional port is
23+
`optional: true` in the manifest rather than an `if` in the body.
24+
"""
25+
26+
subject: str
27+
28+
29+
class Result(TypedDict):
30+
ok: bool
31+
32+
33+
class GqlContext(Protocol):
34+
"""
35+
What this function is handed as `ctx`: the platform's context, **without the
36+
database**.
37+
38+
A GraphQL/HTTP-served function reaches the tenant's API and its declared
39+
buckets, secrets and models, never Postgres directly. The runtime passes its
40+
own `FunctionContext`, which carries a `db`; naming the surface here is what
41+
keeps it out of this function — a type checker rejects `ctx.db`, exactly as
42+
the node template's `Omit<FunctionContext, 'db'>` does. A feature that reads
43+
the tenant's database is scaffolded with `--surface sql` instead.
44+
"""
45+
46+
job: dict
47+
agent: Any
48+
secrets: Any
49+
storage: Any
50+
log: Any
51+
env: dict
52+
capabilities: Any
53+
54+
55+
async def ____method____(params: Params, ctx: GqlContext) -> Result:
56+
"""
57+
Raising is how this function reports failure — the platform records it and
58+
retries. Returning an `{"ok": False}` of your own invention hides the
59+
failure from both.
60+
"""
61+
ctx.log.info("____name____:____method____", {"subject": params["subject"]})
62+
return {"ok": True}

0 commit comments

Comments
 (0)