Usage¶
This page covers day-to-day usage of the codegen CLI. Most of
the page is centred on the be target since it has the deepest
config surface; the be_root / fe / fe_root targets each
get a short section near the end. For a walkthrough of a brand-new
project, see Getting started. For the complete config schema,
see Reference.
Install¶
pip install codegen # or: uv add codegen
Installing codegen ships the generic codegen CLI and
all four targets it discovers at startup (be, be_root,
fe, fe_root). Run codegen targets list to verify.
The CLI¶
codegen generate¶
codegen generate --target NAME --config PATH [--prefix-path DIR] [--clean]
--target / -tWhich registered target to use. Required when more than one target is installed (the default with
codegen); may be omitted when only one target is on the path. Common values:be,be_root,fe,fe_root.--config / -c(required)Path to the
.jsonor.jsonnetconfig file.--prefix-path / -o(optional)Prefix path everything is generated under. Defaults to the current directory, so
beoutput lands under the config’spackage_prefix(e.g._generated/). codegen owns only the files it generates here – each carries a marker naming the target that produced it – so the tree may also hold hand-written files.--cleanBefore writing, delete files this target generated on an earlier run that this run no longer emits (see
codegen clean). Use it when you remove a resource from the config – without--cleanthe previously generated files for that resource stay on disk. Files still generated, including editedskipscaffolds, are kept.
Re-running codegen generate is always safe: every generated file is
overwritten. Never edit files under the prefix path – the next
run will discard your changes.
The generated-file marker¶
Every generated file whose type has a comment syntax carries a marker line in its header banner:
codegen:generated target=be version=0.9.9
It records the target that produced the file and the codegen version,
and is deterministic (no timestamp), so regenerating an unchanged
config yields byte-identical output. codegen clean reads it to
decide what it may delete: only files whose marker names the running
target are ever removed. Commentless files (e.g. JSON) carry no
marker and are therefore not managed by clean.
codegen diff (and --check) ignores the marker’s version
when deciding whether a file changed, so upgrading codegen – which
bumps version in every marker – is not reported as drift; only
real content changes are flagged. The new version is still written
the next time you run generate.
codegen clean¶
codegen clean --config PATH [--prefix-path DIR] [--target NAME]
Scans the prefix path and deletes every file carrying the target’s
marker – and only those, so
hand-written files and other targets’ output are left untouched.
Each deleted path is echoed, then directories left empty by the
deletion are removed; the prefix path itself is never deleted.
--config is parsed so config errors surface consistently, but its
contents do not influence what is removed.
Warning
A standalone clean removes every file bearing the target’s
marker regardless of write policy – including skip scaffolds
you have edited, since they still carry the marker. Prefer
generate --clean, which keeps files the config still emits, when
you want those edits preserved.
Config format¶
be accepts .json and .jsonnet files. Jsonnet is
recommended: imports, variables, and array concatenation make it much
more ergonomic for sharing common patterns across resources.
Minimal single-resource config¶
{
version: "1",
module: "myapp",
resources: [{
model: "myapp.models.Article",
operations: ["get", "list", "create", "update", "delete"],
}],
}
Full config with auth and multiple databases¶
{
version: "1",
module: "blog",
package_prefix: "_generated",
auth: {
type: "jwt",
secret_env: "JWT_SECRET",
verify_credentials_fn: "blog.auth.verify_credentials",
},
databases: [
{ key: "primary", url_env: "DATABASE_URL", default: true },
{ key: "analytics", url_env: "ANALYTICS_URL", echo: true },
],
operations: ["get", "list", "create", "update", "delete"],
resources: [
{
model: "blog.models.Article",
require_auth: true,
generate_tests: true,
},
{
model: "blog.models.ReadStat",
db_key: "analytics",
operations: ["get", "list"],
},
],
}
Notes on inheritance:
operationsat the root is the default applied to every resource that does not set its ownoperationslist.databasesis configured once at the root; resources choose one viadb_key(or fall back to the database withdefault: true).authis configured once at the root; each resource opts in viarequire_auth(defaults toTrue).
Background tasks (pgqueuer)¶
codegen-generated apps integrate with pgqueuer for background work. See Background tasks (pgqueuer) for the full runbook (schema setup, defining tasks, the worker factory, transactional-outbox enqueue from request handlers, and common pitfalls).
Operations¶
Each entry in a resource’s operations list is either:
A string, the operation name – invokes the operation with default options.
operations: ["get", "list", "create", "update", "delete"]
An object, carrying per-operation options:
{ name: "create", fields: [ { name: "title", type: "str" }, { name: "body", type: "str" }, ], }
Extra keys (
fields,max_items, …) are parsed by the operation’sOptionsPydantic model, so validation errors surface during config load rather than at generation time.An action, invoking a custom Python function as a POST endpoint:
{ name: "publish", fn: "blog.actions.publish", params: [{ name: "at", type: "datetime" }], }
codegen generates a
POST /{id}/publishhandler that callsblog.actions.publish.Pass
status_code: 202(or any integer) to override the response status. The default is 204 for-> Nonefunctions and 200 otherwise.
Built-in operations¶
Name |
Scope |
Output |
|---|---|---|
|
project |
|
|
resource |
GET /{pk} route handler + response schema |
|
resource |
GET / route handler + list/filter/sort/paginate schemas |
|
resource |
POST / route handler + request schema |
|
resource |
PATCH /{pk} route handler + request schema |
|
resource |
DELETE /{pk} route handler |
|
resource |
POST /{pk}/{slug} or POST /{slug} handler for a custom action |
|
resource |
Augments handlers with |
|
app |
App-level |
|
project |
Project-level |
Multi-app projects¶
Wrap each app’s config in an apps entry:
// project.jsonnet
{
version: "1",
auth: { ... },
databases: [ ... ],
apps: [
{ config: import "blog.jsonnet", prefix: "/blog" },
{ config: import "inventory.jsonnet", prefix: "/inventory" },
],
}
Top-level auth, databases, and operations are merged into
each app config during generation. Individual apps can still override
these by defining their own blocks.
Jsonnet stdlib¶
be bundles a small Jsonnet stdlib that is importable from any config
file without a path prefix (the be prefix resolves to the stdlib
directory shipped inside the package).
See Reference for the full stdlib list. The most common:
be/auth/jwt.libsonnet–auth.jwt(...)preset for JWT.be/db/databases.libsonnet–db.postgres(...)constructor.be/fields.libsonnet–fields.id(),fields.timestamps(), andfields.nested(name, model, fields, many=false, load="selectin").be/resources/presets.libsonnet–resource.action(...)andresource.files(...)for bundling action operations onto a resource. See File uploads for the file-upload flow.
File uploads¶
be supports a presigned-URL upload flow on top of the existing
action machinery – no new operation type, just a SQLAlchemy mixin
plus four ready-made action functions in ingot.files.
Install the files extra to bring in the boto3 runtime
dependency:
pip install 'codegen[files]'
# or: uv add 'codegen[files]'
Without the extra, importing ingot.files raises
ModuleNotFoundError – the gate is at the import boundary, not
deferred to first call, so failures surface immediately at app
startup rather than mid-request.
The flow:
Client
POST /files/uploadwith{filename, content_type, size_bytes}; server creates apendingrow and returns{id, upload_url}.Client
PUT``s the file bytes to ``upload_url(S3 directly – bytes never touch the app server).Client
POST /files/{id}/completeto flip the row out of pending state. SubsequentPOST /files/{id}/downloadcalls return short-lived presigned GET URLs.POST /files/{id}/delete-filecascades: deletes the S3 object, then the row. Returns 204 No Content.
The model¶
Subclass ingot.files.FileMixin on a pgcraft-mapped model
and let a PK plugin own the id column:
# myapp/models.py
from ingot.files import FileMixin
from pgcraft.factory import PGCraftSimple
from pgcraft.plugins.pk import UUIDV7PKPlugin
from myapp.db import Base
class File(Base, FileMixin):
__tablename__ = "files"
__table_args__ = {"schema": "public"}
__factory__ = PGCraftSimple
__plugins__ = [UUIDV7PKPlugin()]
The mixin contributes the storage columns – s3_key,
content_type, size_bytes, original_filename,
created_at, and uploaded_at (NULL until the upload is
confirmed) – and the UUIDV7PKPlugin plugin contributes the
id column. Keeping id plugin-owned (rather than declared
on the mixin) is what makes the mixin compose with pgcraft without
the duplicate-column error a mixin-declared id would trigger.
Multi-table apps just declare additional models with their own
__tablename__ and the same FileMixin + PK-plugin shape:
class ProfileImage(Base, FileMixin):
__tablename__ = "profile_images"
__table_args__ = {"schema": "public"}
__factory__ = PGCraftSimple
__plugins__ = [UUIDV7PKPlugin()]
The config¶
Point a resource at the bound model and call resource.files():
local resource = import "be/resources/presets.libsonnet";
{
model: "myapp.models.File",
pk: "id",
pk_type: "uuid",
operations: resource.files(),
}
Routes generated (relative to the resource prefix):
GET /{id}– get (FileMixin columns by default)POST /upload– request_upload (mints presigned PUT URL)POST /{id}/complete– complete_upload (204 No Content)POST /{id}/download– download (returns presigned GET URL)POST /{id}/delete-file– delete_file (cascades S3 + row delete; 204 No Content)
The download endpoint is POST rather than GET because the
underlying action operation only supports POST today; the
response carries the GET URL the client follows.
The action functions in ingot.files use the resource’s
mapped class via the introspector’s supertype match – object
actions take file: FileMixin (any concrete subclass matches
the instance), and request_upload takes
model_cls: type[FileMixin] so the handler can plug in the
class for the INSERT. No per-resource glue module is needed.
Customizing the get fields:
resource.files(
fields=[
{ name: "id", type: "uuid" },
{ name: "original_filename", type: "str" },
{ name: "content_type", type: "str" },
],
)
Pass include_get=false to skip the get entirely (e.g. when you
want to attach your own get op with extra non-mixin fields):
operations: [
{ name: "get", fields: [...own fields including custom columns...] },
] + resource.files(include_get=false)
S3 configuration¶
The action functions call ingot.files.default_storage()
which reads three env vars:
FSH_S3_BUCKET– bucket name (required).FSH_S3_REGION– AWS region; optional, falls back to the boto3 default chain.FSH_S3_ENDPOINT_URL– override for MinIO / localstack / other S3-compatible endpoints; optional.
For tests, monkey-patch ingot.files.default_storage to return
a mock ingot.files.S3Storage.
Filtering, search, and saved views¶
be ships an opt-in surface for table-style UIs: structured filter specs, BE-powered value providers, a project-wide search endpoint, and a serializer hook saved-view resources can use for ref hydration. The rest of this section is the user-facing surface.
Structured filter blocks¶
The filter modifier on a list op accepts a richer spec than
the bare-string field list. Each entry can declare its
operators, value source, and any source-specific metadata:
list.searchable(
fields=[
{ name: "id", type: "uuid" },
{ name: "sku", type: "str" },
{ name: "status", type: "str" },
{ name: "is_archived", type: "bool" },
],
filter={
fields: [
{ name: "status", operators: ["eq", "in"],
values: "enum", enum: "myapp.models.OrderStatus" },
{ name: "is_archived", values: "bool" },
{ name: "sku", values: "free_text" },
{ name: "customer_id", values: "ref",
ref_resource: "customer" },
"name", // shorthand: free_text + full operator vocab
],
},
order={ fields: ["sku", "name"], default: "sku" },
)
This generates two things:
The existing
POST /searchfilter execution (unchanged).For
searchableresources only,POST /_values– autocomplete results powered bypg_trgmover thesearch_columns(nofields) or a narrowed list of filter fields.boolandliteralfilters have no endpoint (the FE renders them natively);reffilters dispatch to the target resource’s_valuesendpoint.
The filter catalog itself (operators, value descriptors, enum
choices, ref targets) lives in the openapi spec under
x-fsh-list, baked in at build time – no runtime _filters
endpoint. Codegen-emitted FE hooks read the catalog from the
spec.
Operator vocabulary: eq, neq, gt, gte, lt,
lte, contains, starts_with, in, is_null –
matched 1:1 with ingot.filters.FilterOp. Defaults are
derived from the values kind so most fields don’t need an
explicit operators list.
Resource search¶
searchable: true on a resource generates POST /_values
returning items shaped by the resource’s link config. Powers ref filter inputs on other resources and
any FE “search this table” affordance. search.fields
overrides the default ILIKE target (link.name):
{
model: "myapp.models.Customer",
searchable: true,
link: { kind: "id_name", name: "name" },
search: { fields: ["name", "email"] }, // optional
// ...
}
Body: {q, cursor?, limit?}. Response:
{results: [{type, id, name}, ...], next_cursor}. Auth is
required (the link builder receives the session).
Link schemas¶
Resources that show up as cross-resource references — search
results, ref filter values, saved-view items — declare a
link: block describing how they serialize. The BE returns
structured fields, the FE assembles display strings:
// Shorthand: pulls fields straight off the model.
link: { kind: "id_name", name: "title" }
// Custom: any logic, returns the link schema instance.
link: { kind: "id_name",
builder: "myapp.labels.order_link" }
Each linked resource gets a per-resource {Model}Link
Pydantic class generated into its schemas file (e.g.
CustomerLink, ProductLink). type is a
Literal[<slug>] so the FE-side OpenAPI client narrows on
resource type:
name->{Model}Link{type, name}– label-only.id->{Model}Link{type, id}– id-only.id_name->{Model}Link{type, id, name}– the default for most resources.
A custom builder is an async (instance, session) ->
LinkSchema function (matching the can guard signature so
session is available for permission-aware redaction).
The codegen emits {app_module}/links.py per app with two
maps keyed by slug: LINKS (link builder per resource) and
REF_RESOLVERS (fetch-by-id-and-link per resource). The
saved-view hydration helper consumes REF_RESOLVERS.
Saved views¶
Saved views are not a special opt-in. The consumer subclasses
ingot.saved_views.SavedViewMixin on their own
DeclarativeBase and points a normal codegen resource at it,
using the resource.saved_views(...) jsonnet preset to wire
the standard CRUD ops:
# myapp/models.py
from ingot.saved_views import SavedViewMixin
from myapp.db import Base
class SavedView(Base, SavedViewMixin):
__tablename__ = "saved_views"
local resource = import "be/resources/presets.libsonnet";
{
model: "myapp.models.SavedView",
pk: "id", pk_type: "str",
require_auth: true,
operations: resource.saved_views(
serializer="myapp.serializers.dump_view",
owner_guard="myapp.guards.is_view_owner",
),
}
The user’s serializer wraps ingot.saved_views.hydrate_view():
# myapp/serializers.py
from _generated.myapp.links import REF_RESOLVERS
from ingot.saved_views import hydrate_view
async def dump_view(view, session, db):
return await hydrate_view(view, REF_RESOLVERS, db, session)
The mixin owns resource_type, owner_id, name,
payload (JSON), created_at, updated_at. Per-user
scoping is the is_view_owner guard
(async (resource, session) -> bool). Resource-type
filtering rides on the structured filter machinery. Stored
ref filter values keep raw ids; hydrate_view runs them
through the per-app REF_RESOLVERS at read time.
Custom serializers¶
The serializer: hook on read ops (get / list) is
generic. Set it to a dotted path to an
async (obj, session, db) -> Any function and the generated
route calls it instead of the auto-generated
to_<model>_resource / to_<model>_list_item.
response_model is dropped so the function may return any
JSON-serializable shape. Useful for joined-row flattening,
computed fields that need DB access, or anything else the
auto-dump path can’t express.
Testing the generated code¶
Setting generate_tests: true on a resource emits a pytest file
under _generated/.../tests/test_{name}.py. The file contains one
test per generated operation; run them with pytest as usual:
uv run pytest _generated/
API versioning¶
be has no built-in --version flag. To maintain multiple API
versions, run codegen generate against separate configs into separate
output trees and mount each at a different prefix:
codegen generate --config v1.jsonnet --prefix-path _generated_v1/
codegen generate --config v2.jsonnet --prefix-path _generated_v2/
from _generated_v1.myapp.routes import router as v1_router
from _generated_v2.myapp.routes import router as v2_router
app.include_router(v1_router, prefix="/v1")
app.include_router(v2_router, prefix="/v2")
Other targets¶
be_root – backend bootstrap¶
be_root is a one-shot scaffolder that emits the boilerplate a
be-driven project needs once: main.py, pyproject.toml
(carries the only Python-version pin via requires-python –
uv reads it from there), justfile, .gitignore, the
starter config/project.jsonnet, and (when auth: true) an
auth.py skeleton. Drive it with a bootstrap.jsonnet like:
{
name: "myapp",
module: "myapp",
description: "FastAPI backend.",
opentelemetry: false,
auth: true,
psycopg: true,
pgcraft: false,
pgqueuer: false,
editable: false,
}
Run:
codegen generate --target be_root --config bootstrap.jsonnet --prefix-path .
Every emitted file is if_exists="skip", so re-running after
editing the bootstrap config is non-destructive. --force
clobbers everything; --force-paths a,b,c clobbers only the
listed paths.
fe – React / TypeScript codegen¶
The fe target is a thin wrapper over
@hey-api/openapi-ts: it translates a
codegen-side config/fe.jsonnet into openapi-ts.config.ts, and
the openapi-ts CLI (yarn openapi-ts) then produces the actual
TypeScript SDK plus
@tanstack/react-query hooks. The
config is small:
{
openapi_spec: "../be/openapi.json",
output_dir: "src/_generated",
client: "@hey-api/client-fetch",
react_query: true,
}
Run:
codegen generate --target fe --config config/fe.jsonnet --prefix-path .
yarn openapi-ts
The fe_root-emitted justfile chains those two steps under
just generate.
fe_root – frontend bootstrap¶
fe_root is the fe counterpart to be_root – a one-shot
scaffolder that emits a yarn / Vite / TypeScript / React project
with React Query, openapi-ts, and (optionally) the
@fsh/components-library
component library wired up. Drive it with:
{
name: "myapp-fe",
description: "React frontend.",
fsh: true,
editable: false,
openapi_spec: "../be/openapi.json",
}
Run:
codegen generate --target fe_root --config bootstrap.jsonnet --prefix-path .
Output: package.json, justfile, tsconfig.json,
vite.config.ts, index.html, src/{main,App}.tsx,
src/index.css, .gitignore, .nvmrc, and the starter
config/fe.jsonnet. Same if_exists="skip" /
--force / --force-paths semantics as be_root.
Extending be¶
To add your own operations, swap renderers, or build an entirely new target, see Extending codegen. For the underlying architecture, see Architecture.