Modules¶
A pyvelm module is a Python package on disk that ships some combination of models, views, and seed data. Modules are the unit of install, upgrade, and uninstall — they're how you ship your code as a self-contained piece other apps can depend on or extend.
When to read this page¶
Use this guide after Getting started and your first generated module. It explains the structure you will maintain long-term:
- Manifest and dependency metadata
- Data files (
views_data) and menus - Install/upgrade lifecycle, migrations, and seeders
Shape on disk¶
mymodule/
├── __init__.py # can be empty
├── __pyvelm__.py # manifest = Manifest.make("mymodule")…
├── models/
│ ├── __init__.py # imports every model file
│ └── partner.py
├── views/
│ ├── __init__.py
│ ├── partner.py # views_data = ViewsData.make()…
│ └── menu.py # menus on views_data or Menus builder
├── commands/ # optional Artisan CLI commands
└── migrations/ # optional
├── __init__.py
└── 0_1_to_0_2.py
pyvelm make:module / pyvelm new create an empty shell (no .data(...) yet).
Use make:model, make:view, and make:menu to add each layer — see
Console commands.
The manifest¶
__pyvelm__.py is plain Python. Assign a fluent builder to
manifest (velmphp-style):
from pyvelm.manifest import Manifest
manifest = (
Manifest.make("partners")
.version(0, 1, 0)
.depends("base")
.data("views/partner.py", "views/tag.py")
.install_hook("partners.hooks:install")
.summary("Companies, contacts, and the partner directory.")
.category("Business")
)
Legacy module-level constants remain supported for older addons:
NAME: str = "partners"
VERSION: tuple[int, ...] = (0, 1, 0)
DEPENDS: list[str] = ["base"]
DATA: list[str] = ["views/partner.py", "views/tag.py"]
INSTALL_HOOK: str = "partners.hooks:install"
.depends(...) / DEPENDS is what the loader uses to topologically order installs;
.data(...) / DATA lists Python files whose views_data builder (or legacy
VIEWS, VIEW_INHERITS, MENUS) feed the declarative-data sync; INSTALL_HOOK is a
pkg.mod:fn reference to a function called once on first install (it
gets the Environment).
Optional .web_routes("pkg.web:register_routes") registers
module-owned FastAPI endpoints when create_app() starts — no edits to
serve.py. See Custom HTTP routes below.
pyvelm.Manifest is the fluent builder class; pyvelm.types.Manifest
is a TypedDict documenting every recognised key for type checkers.
For model and view technical names (env["res.partner"],
view="lead.list"), run pyvelm make:stubs to
generate Literal unions and checker config — see
IDE typing stubs.
Catalog metadata¶
These optional keys drive the Apps catalog UI (more below). Modules without them appear as "Uncategorised" with a blank summary — purely informational, no impact on install behaviour.
SUMMARY: str = "Sales pipeline, leads, and opportunity tracking."
DESCRIPTION: str = "Longer prose. Markdown-ish."
CATEGORY: str = "Business" # groups cards on /web/apps
AUTHOR: str = "Your Team"
ICON: str = "<svg ...>...</svg>" # raw inline SVG; rendered as-is
Models¶
Each module's models live in a Python package the manifest points
at — models/ by default, override via MODELS_PACKAGE.
# partners/models/partner.py
from pyvelm import Char, Integer, Many2one, models
class Partner(models.Model):
_name = "res.partner"
name = Char(required=True)
age = Integer()
country_id = Many2one("res.country", ondelete="SET NULL")
The framework creates the table on first install and ALTER TABLE
ADD COLUMNs any new field declarations on subsequent upgrades.
See Declaring models for the field reference.
Data files¶
Anything declarative — views, view extensions, sidebar menu
entries — lives in Python files referenced by DATA:
# partners/__pyvelm__.py
manifest = Manifest.make("partners").data("views/partner.py", "views/menu.py")
The loader executes each file and harvests declarative data from a
fluent views_data builder (preferred) or legacy module-level lists
(VIEWS, VIEW_INHERITS, MENUS).
# partners/views/partner.py
from pyvelm.builders import Field, FormView, ListView, ViewsData
views_data = (
ViewsData.make()
.views(
ListView.make("partner.list")
.model("res.partner")
.columns(["name", "code", "country_id"])
.form_view("partner.form"),
FormView.make("partner.form")
.model("res.partner")
.section("identity", "Identity", ["name", "code"])
.section("location", "Location", ["country_id"]),
)
)
Legacy function helpers (list_view, form_view, field, …) remain
available and produce the same dicts the loader always consumed.
Files that don't define any of those lists are still imported — use
this for side-effects like registering custom widgets via
@pyvelm.render.widget.
Sidebar menus¶
A MENUS list contributes entries to ir.ui.menu, which drives the
signed-in shell. By default the UI uses the apps layout (applications
in the left rail, pages in the top bar); set PYVELM_MENU_LAYOUT=sidebar
for a three-level nested sidebar. See Navigation
for layout modes, depth, and template context.
Structure menus in three levels when using the default apps layout:
| Level | Role | Example |
|---|---|---|
| 1 | Application (sidebar rail) | Settings, CRM |
| 2 | Subsection (top bar tab or dropdown) | Users & access, Pipeline |
| 3 | Page (link or dropdown item) | Users, All Leads |
Keep level 2 sparse (a handful of subsections per app); put list/form routes at level 3. See Navigation for layout detail.
Top-level groups have an optional icon and no href. Nested
groups use parent=; leaf items link to a view or URL.
Use the Menus builder so you only pass names you already know from
VIEWS — not hand-built /web/views/... paths:
# partners/views/menu.py — module name in __pyvelm__.py is "partners"
from pyvelm.builders import Menus, ViewsData
m = Menus("partners")
views_data = ViewsData.make().menus(
m.group("business", "Business", icon="square-3-stack-3d", sequence=50).children([
m.group("business.directory", "Directory", sequence=10).children([
m.item("business.partners", "Partners")
.view("partner.list")
.sequence(10),
]),
m.item("business.tags", "Tags")
.parent(("admin", "settings.reference"))
.view("tag.list")
.sequence(20),
]),
)
| What you write | What gets stored |
|---|---|
m.group("business", …) |
Menu name = business in module partners |
m.group("…", parent="business") |
Nested group under partners.business |
parent="business.directory" |
parent = partners.business.directory |
parent=("admin", "settings.reference") |
parent = admin.settings.reference |
view="partner.list" |
href = /web/views/partners/partner.list |
href="/web/apps" |
Use for non-view routes (Dashboard, Apps) |
icon="home" |
Heroicons name (outline by default) |
Menu icons use the heroicons package.
Pass a kebab-case name on groups and root-level items, e.g. icon="chart-bar".
Variants: icon="solid:shield-check", icon="mini:bell", icon="micro:bell".
Legacy inline SVG strings still work if they start with <svg.
Nested groups can use m.group(...).children([...]) so parent= is set
automatically, or pass parent= explicitly on each entry. Menu names may
contain dots — use a short parent="settings.organization", not a
hand-written admin.* string; see
Navigation → Parent references.
Low-level menu_item still works with full href and tuple parents if
you prefer raw dicts.
The framework upserts these into the ir.ui.menu table on every
install pass (parents before children), so re-declaring an entry
overwrites the previous one. After changing menus in a running app, use
Apps → Sync on that module.
Custom HTTP routes¶
Declarative views and menus cover list/form/kanban pages under
/web/views/…. For bespoke pages (public forms, webhooks, custom dashboards),
declare WEB_ROUTES in the manifest:
# mymodule/web.py
def register_routes(app) -> None:
from fastapi import Request
from fastapi.responses import HTMLResponse
registry = app.state.registry
pool = app.state.pool
@app.get("/web/mymodule/hello", response_class=HTMLResponse)
def hello():
return "<p>Hello</p>"
create_app() discovers modules from module_roots, walks them in
DEPENDS order, and calls each registrar after core routes are mounted.
You do not wire module routes manually in serve.py.
Reads as: same discovery roots as install — if the module is on disk
under your roots and declares WEB_ROUTES, its URLs are live on boot and
after Apps → Install (no server restart required).
Request scope and CSRF¶
Routes that use the ORM should build an Environment per request and call
pyvelm.request_env.apply_request_scope so session uid, company
cookie, and record rules match the admin UI. Full example:
examples/modules/feedback_signals/web.py.
Browser POST/PUT/DELETE must satisfy the same CSRF rules as core routes
(pyvelm_csrf cookie + X-CSRF-Token or _csrf form field).
Loading a module¶
The loader is the entry point your app uses to bring modules online:
from pyvelm import BUILTIN_MODULE_ROOTS, Environment, Registry, loader
reg = Registry()
env = Environment(conn, registry=reg)
loader.load_and_install(
BUILTIN_MODULE_ROOTS + ["/path/to/my/addons"],
env,
)
load_and_install(roots, env) discovers every module under roots but
only installs a subset by default:
| Database state | Modules installed on boot |
|---|---|
Fresh (empty ir_module) |
Every module under pyvelm/modules/ (e.g. base, admin, reports, …) |
| Already has installed rows | Only modules already in ir_module (sync/upgrade) |
Other discovered addons (outside the bundled tree) appear in Apps for
manual install. To install those too (demo scripts, CI), pass
install_all=True to load_and_install or run pyvelm migrate --all.
Steps:
- Discover every directory containing a
__pyvelm__.pyunder the given roots. - Resolve order by topologically sorting on
DEPENDS. Cycles and missing deps raise. - Load + install/sync the subset from the table above (schema, hooks, views, menus).
Each step is callable on its own (loader.discover,
loader.resolve_order, loader.install) for finer control.
BUILTIN_MODULE_ROOTS¶
The pyvelm wheel ships built-in modules under pyvelm/modules/ —
base (framework primitives), admin (management UI), reports
(report builder), document_layout (company PDF layouts + print routes),
file_manager (attachment library), console (generators), and others.
They are exposed as
pyvelm.BUILTIN_MODULE_ROOTS — a single-entry list you prepend to
your own discovery roots. Apps that boot the framework should
always include it. pyvelm-cron prepends it automatically.
Bumping versions and writing migrations¶
End-to-end workflow (diff → autogen → migrate → deploy): Migrations.
When you change a model's fields (or want to seed new data), bump
VERSION in the manifest:
Add a Python file under migrations/ named after the transition:
The filename is load-bearing. It must match
<from>_to_<to>.py with _-separated version parts. The loader
parses it and only runs scripts whose target version is greater
than the recorded version and less than or equal to the manifest
version — not on Apps Sync when versions already match.
For idempotent backfills (NULL rows before SET NOT NULL, orphan
column cleanup), use SYNC_HOOK instead. It runs before schema
apply on every upgrade, Sync, and db migrate. Migration scripts are
for version-gapped, one-time work (ALTER TYPE … USING, renames).
# partners/hooks.py — runs on every Sync / migrate
def sync(env):
Partner = env["res.partner"]
for partner in Partner.search([("code", "=", None)]):
prefix = (partner.name or "?")[:3].upper()
partner.code = f"{prefix}-{partner.id}"
Seeders (Laravel-style)¶
Register seeders under <module>/seeders/ — the loader reads
seeders/__init__.py automatically (no manifest entry required):
# myapp/seeders/database.py
from pyvelm.seeding import Seeder
class DatabaseSeeder(Seeder):
def run_instance(self, env, **context):
self.call(env, CountrySeeder)
# myapp/seeders/__init__.py
from .database import DatabaseSeeder
SEEDERS = [DatabaseSeeder]
Runs on install, upgrade, and Sync (after hooks and schema apply). Write seeders to be idempotent — match existing rows on natural keys and only insert or patch what is missing.
Override discovery with an explicit SEEDERS list in __pyvelm__.py
when needed. Re-run manually with pyvelm db seed or pyvelm db seed myapp.
Override should_run / enabled on the seeder class to skip optional work.
Heavy reference data (e.g. geo_data) uses batched inserts — see
Geo data.
Optional migration file for the same version bump (runs once on upgrade):
from pyvelm.migrations import Blueprint, Schema, Table
def upgrade(env):
schema = Schema(env)
def _alter(t: Table) -> None:
t.string("code", nullable=True)
schema.table("res_partner", _alter)
# Laravel-style FK: t.foreign("currency_id").constrained("res_currency")
# Shorthand: t.foreign_id("currency_id", "res_currency", ondelete="SET NULL")
# Column builders: Blueprint.supported_columns()
# Backfill is in SYNC_HOOK — see partners/hooks.py
Idempotency is your responsibility¶
The loader's safety net is version-based: once ir_module records
the new version, the migration won't re-run. Inside the migration:
- Use
ADD COLUMN IF NOT EXISTSso an accidental replay survives. - Filter the backfill (
("code", "=", None)) so it only touches rows that still need it. - For changes that aren't naturally idempotent (renames, type changes), the version filter is the safety net.
Raw SQL and the cache¶
A bulk UPDATE that bypasses the ORM is fine for performance, but
the cache won't know about the change. Call
env.cache.invalidate(model_name=…, fields=[…]) afterward if any
subsequent code in the migration reads the affected fields.
Why hand-written migrations
Auto-generated diffs are nice but committing to a diff engine pressures the project toward SQLAlchemy Core or an Alembic-equivalent — both heavier than pyvelm's current shape. The intent of a hand-written migration is more useful than the average generated one. When the migration count grows large enough that it hurts, auto-diff goes on the table.
The Apps catalog¶
/web/apps is the visual addon-management page. It walks the
configured module roots on every load (so newly-dropped manifests
appear without restarting the server) and joins what it finds with
the ir_module table. Each module renders as a card with:
- Name, summary, author, optional icon.
- State badge — Installed / Upgrade / Sync / Not installed.
- Version line (or
installed → availablewhen an upgrade is pending). - Dependency list. Names go red when a declared dep isn't installed yet; the install button stays disabled.
The toolbar above the cards lets you search by name/summary, filter by state, and group by category.
Install / upgrade / uninstall¶
Three buttons sit on every card; the framework gates all three behind uid=1 (superuser) because they execute install hooks and run DDL.
| Action | What happens |
|---|---|
| Install | Topologically installs the target and any uninstalled prerequisites. Models are imported into the live registry; the standard install pass runs (schema, hook, view/menu sync). Primary button on the card. |
| Upgrade | Shown only when the manifest version is ahead of the recorded ir_module version. Runs version-gap migration scripts and bumps the installed version. |
| Sync | Always available on installed modules. Re-applies schema diff and reloads views/menus from disk — highlighted when model/schema changes are pending without a version bump. |
| Uninstall | Drops tables owned by the module, deletes its ir.ui.view and ir.ui.menu rows, removes the ir_module entry. All inside one transaction. |
POST endpoints respond with HX-Redirect: /web/apps so the catalog
refreshes in place (with a pv_flash summary when applicable).
Installed modules that cannot be uninstalled show a disabled
Protected button with the blocker reason in its tooltip.
Uninstall safety¶
Uninstall is the one with sharp edges. Before removing anything,
the framework runs uninstall_preview and returns a list of
blockers if it spots a problem:
baseis the system module — always blocked.- Bundled bootstrap modules — every module under
pyvelm/modules/(BOOTSTRAP_MODULES) is protected the same way asbase. - Reverse dependencies — any installed module whose manifest
still lists this one in
DEPENDSblocks the uninstall. Remove the dependent first. _inheritbase owners — uninstalling a module that owns models is blocked while other installed modules still extend those models via_inherit. Uninstall the extensions first. Pure extension modules (they only_inheritsomeone else's model) remain uninstallable.
The UI disables the uninstall button when blockers exist (tooltip shows why). The confirm dialog still re-checks blockers before any destructive action runs.
ir.model.access and ir.rule entries seeded by install hooks
aren't tagged with the owning module, so they linger after
uninstall. Clean them up manually if you care.
Transactions¶
env.transaction() is the explicit unit-of-work boundary. The
outer call opens a real transaction; nested calls become savepoints
so partial work can roll back.
with env.transaction():
alice.write({"name": "Alicia"})
with env.transaction():
carol.unlink()
raise RuntimeError("boom") # rolls back only the savepoint
# alice's rename is still pending here
Outside any transaction, the connection runs in autocommit mode — each ORM statement persists immediately. The transaction context flips the connection out of autocommit for its duration.
The cache doesn't roll back
env.cache does not undo writes on rollback. If a transaction
fails after you read or wrote field values, the cache holds
optimistic values that no longer match SQL. Drop the relevant
entries with env.cache.invalidate(model_name=…, fields=[…])
after a rollback if subsequent code in the same env cares about
absolute truth. A proper savepoint-aware cache is on the list.