Console¶
Not everything happens over HTTP. You need to run migrations, scaffold a model, kick off a one-off
backfill, tick a scheduler, or give an operator a safe button to push. arvel ships a CLI — the
arvel command, built on Typer — for exactly that: it scaffolds code,
runs migrations and seeders, drives the scheduler, and hosts your own commands, all with typed
options and generated --help.
This page covers running commands, the built-in set, the code generators, scheduling and seeding, and writing your own command. The console is part of the core — nothing to install.
Running commands¶
arvel --help # the full command tree
arvel route:list # every registered route
arvel migrate # apply outstanding migrations
Commands are lazily loaded: invoking one imports only that command's module, so cold-start
stays fast and the bare arvel / --version paths pull in no framework or DB code.
One binary, two modes¶
The same arvel works before a project exists and inside one — it tells which by looking for
bootstrap/app.py:
| Mode | Where | Shows |
|---|---|---|
| Installer | anywhere, no project | arvel new … (+ about, extras) |
| Project | inside a project | migrate, queue:work, make:*, tinker, route:list, … |
So arvel new blog works from an empty directory, then inside blog/ the full command set
appears. (No separate installer binary — one tool, like git.)
arvel new names the package/app from the target path's basename, not the whole path, so an
absolute (or relative, or trailing-slash) path all work the same way:
arvel new /abs/path/my-app scaffolds at /abs/path/my-app with the package named my-app
(pyproject name, the welcome page title, --package's arvel-my-app) — not the full path.
The built-in commands¶
| Command | What it does |
|---|---|
make:model <Name> |
scaffold a model |
make:controller <Name> |
scaffold a controller |
make:middleware <Name> |
scaffold a middleware class |
make:request <Name> |
scaffold a Form Request |
make:job <Name> |
scaffold a queued job |
make:policy <Name> |
scaffold an authorization policy |
make:notification <Name> |
scaffold a notification |
make:mail <Name> |
scaffold a mailable |
make:rule <Name> |
scaffold a validation rule |
make:seeder <Name> |
scaffold a database seeder |
make:factory <Name> |
scaffold a model factory |
make:provider <Name> |
scaffold a service provider |
make:migration <name> |
scaffold a timestamped migration (create_x_table → create stub) |
make:command <Name> |
scaffold a console command (register it in a provider's commands()) |
make:event <Name> |
scaffold a domain event (app/events/) |
make:listener <Name> |
scaffold an event listener (app/listeners/; register via Event.listen) |
make:cast <Name> |
scaffold a custom attribute cast (app/casts/; use in a model's __casts__) |
make:observer <Name> |
scaffold a model observer (app/observers/; declare via __observers__ on the model) |
make:enum <Name> |
scaffold a string-backed enum (app/enums/) |
make:exception <Name> |
scaffold an exception class (app/exceptions/) |
make:test <Name> |
scaffold a pytest test (tests/test_<name>.py) |
route:list |
tabulate the app's routes (methods, path, name) |
migrate / migrate:rollback |
apply / revert migrations |
migrate:fresh |
drop all tables, then re-run every migration |
migrate:refresh |
roll back all migrations, then re-run them |
db:wipe |
drop all tables without re-migrating |
db:seed |
run the app's database seeder |
cache:clear |
flush the default cache store |
key:generate |
generate an app key and write it to .env as APP_KEY |
storage:link |
symlink public/storage → storage/app/public |
schedule:run |
run scheduled tasks that are due now |
lang:list |
list available locales |
queue:work |
run a worker that processes queued jobs |
package:discover |
cache the ecosystem-package manifest to bootstrap/cache/packages.py |
tinker |
an interactive REPL with the app booted, your models autoloaded, and top-level await |
The REPL (tinker)¶
Needs the [console] extra for top-level await
tinker runs on the stdlib REPL out of the box. Installing IPython adds top-level await and
autocomplete: uv add 'arvel[console]' (also bundled in arvel[standard]).
tinker drops you into an interactive shell with the app booted. Inside a project it preloads:
- arvel's public surface (
Model,config,Collection, …), - the running
app, - every model by its short name (autoloaded from
app/models/).
arvel tinker
>>> await User.find(1) # top-level await — no asyncio.run needed
>>> Post.where(...) # models reachable by name, no import
Generators¶
make:* commands write a typed stub into the right app/ package:
Each generates a version-matched file you then fill in — no boilerplate copied by hand.
Scheduling & seeding¶
Two things you drive from the console have their own pages:
- Task Scheduling — define recurring work in
routes/console.pywithScheduleand fire it witharvel schedule:runfrom a once-a-minute cron entry. - Seeding — populate the database with a
Seederandarvel db:seed.
Writing your own command¶
A command is a small Typer app the framework discovers and mounts under arvel:
import typer
from arvel.database import Model
publish_app = typer.Typer()
@publish_app.command()
def publish_posts(limit: int = 10) -> None:
"""Publish up to LIMIT scheduled posts."""
import asyncio
asyncio.run(_publish(limit))
typer.echo(f"published up to {limit} post(s)")
Typer generates --help and parses --limit as an int automatically. Class commands are
resolved through the container, so their constructor dependencies are injected.
The Command base class (I/O, prompts, argument()/option())¶
make:command scaffolds a class command subclassing arvel.console.Command — it gets typed
stdout/stderr I/O, interactive prompts, and accessors for its own parsed CLI tokens:
from typing import Any
from arvel.console import Command
class SendReports(Command):
signature = "report:send {user} {--force}"
description = "Send the weekly report to a user"
async def handle(self, *deps: Any) -> None:
user = self.argument("user")
force = self.option("force")
if not force and not self.confirm(f"Send the report to {user}?"):
self.warn("cancelled")
return
self.info(f"sending to {user}...")
for _ in self.with_progress_bar(range(10)):
... # do the work
self.table(["user", "sent"], [[user, "yes"]])
Output (info/line/comment/question → stdout; error/warn → stderr; new_line(n);
table(headers, rows); with_progress_bar(iterable)) is built on click's own echo/style/
progressbar (typer's backing library — the console layer may not import rich directly; see
"How it works" below) — no new dependency, and every method is stream-testable: Command(output=
ConsoleOutput(out=buf, err=buf)) (or construct a bare ConsoleOutput(out, err) directly) routes
output to injected streams instead of the real terminal.
Prompts: ask(label, default=None),
secret(label) (no echo), confirm(label, default=False), choice(label, options, default=None)
(re-prompts on an invalid pick), anticipate(label, suggestions, default=None) (free text, with
suggestions shown as a hint). Available as free functions or as Command methods
(self.ask(...), …); both take an injectable Prompter so tests seed answers instead of reading
real stdin:
from arvel.console.prompts import Prompter, confirm
confirm("Proceed?", prompter=Prompter(["y"])) # -> True, no terminal touched
Command(prompter=Prompter(["Ada", "y"])) # a whole command's prompts, pre-seeded
argument()/option() read the values the kernel parsed for this invocation — the kernel
stashes them on the instance (via bind_parsed) right before handle() runs, split by the
command's own signature grammar (see below).
Signature grammar¶
Both class commands (Command.signature) and closures (Console.command(signature, handler), see
below) share one grammar, parsed by arvel.console.closure.parse_signature:
| Token | Meaning |
|---|---|
{name} |
required positional argument |
{name?} |
optional positional argument |
{name=default} |
positional argument with a default |
{name*} |
variadic positional (collects into a list) |
{--flag} |
boolean option (--flag present → True) |
{--opt=} |
value option (--opt value) |
{--opt=*} |
multi-value option (repeatable: --opt a --opt b → ["a", "b"]) |
{--Q\|queue} |
a shortcut (-Q/--queue) — combine with =/=* for a value/multi shortcut |
An optional/defaulted/variadic positional must come last (a required one after it is a signature error, raised at registration).
Invoking a command programmatically (Cli.call)¶
arvel.console.kernel.Cli.call(name, args=None) -> int runs a command in-process and returns
its exit code (call_silently additionally swallows its stdout/stderr):
from arvel.console.kernel import Cli
code = Cli.call("migrate") # a built-in — argv-shaped args
code = Cli.call("report:send", {"user": "ada", "--force": True}) # app-registered
Cli.call_silently("cache:clear")
Built-in framework commands (migrate, make:*, …) dispatch through the same click command the
CLI uses (args is a dict of CLI-shaped values or a raw list[str] of argv tokens). Commands
registered by your app (a routes/console.py closure, or a provider's commands()) dispatch
directly against the currently active application — call Cli from inside a booted app (a
request, another command, a scheduled task, or a test that set one up), not as a bare script;
there's no project to boot here.
Closures (routes/console.py)¶
from arvel import Console
async def greet(name: str, force: bool = False) -> None: ...
Console.command("greet {name} {--force}", greet)
Same signature grammar as class commands; the parsed tokens are passed to the handler by name (the rest is autowired from the container).
stub:publish¶
The make:* generators write from a built-in template per kind. arvel stub:publish copies every
one of those templates into ./stubs/<kind>.stub (plus migration.create.stub /
migration.generic.stub / test.stub); once a stub is published, the matching generator reads
that file instead of its built-in template — edit it to change what gets scaffolded:
arvel stub:publish # writes ./stubs/*.stub
arvel stub:publish --force # overwrite already-published stubs
$EDITOR stubs/model.stub # customize — make:model now uses this
Published stubs from older versions
Stubs published before the literal-token renderer may contain {{ }} escape artifacts
(e.g. return {{}}), which are now preserved verbatim into generated files. After
upgrading, rerun arvel stub:publish --force (or hand-edit the escapes away) so your
published stubs are plain Python again.
Maintenance mode¶
arvel down / arvel up flip the app into/out of maintenance (503) via
PreventRequestsDuringMaintenance, a global middleware. The flag lives in the app's default cache
driver, so its reach follows your cache (array = this process only; Redis = every instance).
--secret=S— a request to?secret=Sbypasses the 503 (and sets a cookie so later requests from the same visitor bypass without repeating the query param).--allow=IP— repeatable; that IP always bypasses.--render=NAME— readsresources/views/NAME.htmlonce, atdowntime (a raw file read, not the Jinja view engine — maintenance mode must not depend on services that might themselves be why the app is down) and serves it verbatim as the 503 body instead of the default JSON message.--retry=N— theRetry-Afterheader hint (seconds).
Common mistakes & gotchas¶
- Importing heavy modules at the top of a command file. The CLI stays fast because command
modules are lazy — keep DB/HTTP imports inside the command function so
arvel --versionandmake:*don't pull them in (the import-linter enforces this for the framework). schedule:runwith nothing bound. It exits non-zero with a message if noscheduleis registered — wire one inroutes/console.py.- Expecting
migrateto discover migrations magically. It runs themigrationsthe app has bound; register them so the command can find them.
How it works¶
The root arvel app is a Typer with a LazyGroup: a manifest maps command names
(route:list, migrate:rollback, …) to module:typer_app targets, and the target module is
imported only when that command is invoked. Commands resolve the booted application from the
container (route:list → the router, migrate → the bound migrator), so the pure formatters
stay unit-testable while the command wires in the live app.
See also¶
- Queues & Jobs — the scheduler dispatches jobs.
- Database & ORM — what
migrate/db:seedoperate on.