Nori CLI¶
The Nori CLI (nori.py) is the primary tool for development workflows — scaffolding code, managing database migrations, seeding data, and running the dev server.
Commands use colon-separated naming (make:model, migrate:upgrade) because they group naturally in your head. Type make: and you know what's coming. Type migrate: and the scope is clear. It's namespace-like without being verbose.
Command Reference¶
| Command | Description |
|---|---|
serve |
Start the development server with hot reload |
shell |
Async REPL with Tortoise + registered models loaded |
make:controller <Name> |
Generate a controller skeleton in modules/ |
make:model <Name> |
Generate a Tortoise ORM model in models/ |
make:seeder <Name> |
Generate a database seeder in seeders/ |
migrate:init |
Initialize the Aerich migration system |
migrate:make <name> |
Create a new migration from model changes |
migrate:upgrade |
Apply all pending migrations (both apps) |
migrate:downgrade |
Roll back last migration |
migrate:fix |
Fix migration files to current Aerich format |
migrate:fresh |
Drop DB + delete migrations + re-init (dev only) |
db:seed |
Run all registered database seeders |
queue:work |
Run the persistent job queue worker |
framework:update |
Update the Nori core from GitHub |
framework:check-config |
Compare project's pyproject.toml against the current Nori release (read-only) |
framework:version |
Show the current framework version |
routes:list |
List all registered routes |
check:deps |
Probe DB, cache, and throttle reachability (pre-deploy check) |
audit:purge |
Purge old audit log entries |
Development Server¶
Starts Uvicorn with hot reload enabled. Watches both Python files and the rootsystem/templates/ directory for changes — editing a template triggers a reload automatically.
| Flag | Default | Description |
|---|---|---|
--host |
0.0.0.0 |
Bind address |
--port |
8000 |
Port number |
Interactive Shell¶
Opens an async-aware Python REPL (python -m asyncio) with Tortoise pre-initialized against your settings.TORTOISE_ORM and every model in core.registry bound as a top-level name. You can await directly at the prompt:
>>> users = await User.all()
>>> len(users)
42
>>> me = await User.get(email='ada@example.com')
>>> me.name = 'Ada Lovelace'
>>> await me.save()
On startup the shell prints which models are in scope:
Nori shell — async REPL with Tortoise + models loaded.
Use `await` at the top level. Press Ctrl-D or type exit() to quit.
Models in scope: Article, Category, Tag, User
No imports, no manual Tortoise.init(), no event-loop boilerplate. Useful for one-off queries, debugging, prodding fixtures, or sanity-checking a model change.
Route Inspection¶
Prints a table of all registered routes from routes.py, including path, HTTP methods, and route name:
Path Methods Name
--------- ------- ------------
/health GET health.check
/ GET page.home
/ws/echo WS ws.echo
3 route(s) registered.
Mount groups are expanded — nested routes show their full prefix. WebSocket routes display WS in the methods column.
Code Generators¶
All generators follow the same pattern: they create a single file with working boilerplate that you customize. If the target file already exists, the command exits with an error to prevent overwriting your work.
make:controller¶
Creates rootsystem/application/modules/product.py:
from starlette.requests import Request
from starlette.responses import JSONResponse
from core.jinja import templates
class ProductController:
async def list(self, request: Request):
return JSONResponse({"message": "Product List"})
async def create(self, request: Request):
pass
After generating, you must:
- Add your business logic to the controller methods.
- Register routes in
rootsystem/application/routes.py:
from modules.product import ProductController
product = ProductController()
routes = [
# ...existing routes...
Mount('/products', routes=[
Route('/', product.list, methods=['GET'], name='products.index'),
Route('/', product.create, methods=['POST'], name='products.store'),
]),
]
Controllers are plain Python classes — no base class, no magic. Methods are async callables that receive (self, request) and return a Starlette Response.
make:model¶
Creates rootsystem/application/models/product.py:
from tortoise.models import Model
from tortoise import fields
from core.mixins.model import NoriModelMixin
class Product(NoriModelMixin, Model):
id = fields.IntField(primary_key=True)
name = fields.CharField(max_length=100)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = 'products'
After generating, you must:
- Edit the model to add your fields (see Tortoise ORM field reference).
- Register the model in
rootsystem/application/models/__init__.py:
Without this registration, Tortoise ORM will not discover the model, and migrations will fail silently.
- Create and run a migration:
Tip: If a model has sensitive fields, add protected_fields to prevent leaks via to_dict():
class User(NoriModelMixin, Model):
protected_fields = ['password_hash', 'remember_token']
# ...fields...
make:seeder¶
Creates rootsystem/application/seeders/product_seeder.py:
"""Seeder for Product."""
# from models.product import Product
async def run() -> None:
"""Seed Product data."""
# await Product.create(name='Example')
pass
After generating, you must:
- Uncomment the model import and add your seed data.
- Register the seeder in
rootsystem/application/seeders/database_seeder.py:
- Run it:
Framework Management¶
Nori 1.2+ includes commands to manage the framework core independently of your application code.
framework:update¶
Updates all framework-owned directories by downloading the latest release from the official GitHub repository.
python3 nori.py framework:update
python3 nori.py framework:update --version 1.3.0
python3 nori.py framework:update --no-backup
Options:
- --version <v>: Update to a specific version (e.g. 1.3.0). Defaults to latest release.
- --no-backup: Skip the automatic backup (useful for CI/Docker).
- --force: Re-install even if already on the target version.
What gets updated:
| Path | Contents |
|---|---|
core/ |
Framework core (auth, cache, mail, queue, etc.) |
models/framework/ |
Framework models (AuditLog, Job, Permission, Role) |
requirements.nori.txt |
Framework Python dependencies |
What is NOT touched (user-owned):
- migrations/framework/ — engine-specific SQL, regenerated locally on migrate:init or migrate:make
- requirements.txt, asgi.py, routes.py, settings.py, models/*.py, commands/, etc.
Process:
1. Reads current version from core/version.py.
2. Queries the GitHub Releases API for the target version.
3. Creates a timestamped backup in rootsystem/.framework_backups/.
4. Downloads and extracts the release zip.
5. Replaces the framework-owned directories and files.
6. Applies idempotent patches (e.g. injects the bootstrap hook into asgi.py if missing).
7. Reminds you to regenerate framework migrations with migrate:make ... --app framework if framework models changed.
For private repositories, set GITHUB_TOKEN in your environment.
framework:check-config¶
Compares your project's pyproject.toml against the current Nori release's. Read-only — does not modify anything. The comparison answers: "what changed in the framework's tooling config since I installed, and what did I customize on top?"
framework:update refreshes the framework's code but does not refresh pyproject.toml (it would clobber your customizations). Over many releases the framework may have introduced new ruff rules, new mypy strict modules, or bumped the coverage threshold — and your project never sees those changes silently. framework:check-config surfaces the drift.
Options:
- --version <v>: Compare against a specific Nori version. Defaults to the latest release.
Output is categorized into three sections:
- Added upstream — keys/tables present in the release's
pyproject.tomlbut missing from yours. These are likely additions worth porting (e.g. a new[[tool.mypy.overrides]]block adding strict typing to a new module). - Changed upstream — keys present in both with different values. Each entry shows your value and upstream's. Example:
tool.coverage.report.fail_underbumped from82to86. - Local-only — keys present in your
pyproject.tomlbut not upstream. These are your customizations — informational, not a flag. The command prints them so you can review the full diff in one pass.
If everything matches, the command prints No drift detected. and exits.
The command does not modify pyproject.toml. You decide what to port. The output ends with a link to the upstream source for direct comparison.
framework:version¶
Displays the current version of the Nori core installed in the project.
check:deps¶
Probes the project's runtime dependencies — database, cache backend, and throttle backend — and exits non-zero if any of them are unreachable. Designed as a pre-deploy / CI gate so misconfiguration surfaces before the app boots, not after.
What it probes, in order:
- Database: opens a connection per
settings.TORTOISE_ORMand runsSELECT 1. IfDB_ENABLED=False, the database row reports asdisabled(still a pass). - Cache: calls
verify()on the configuredCACHE_BACKEND(memoryalways passes;redisissues aPING). - Throttle: calls
verify()on the configuredTHROTTLE_BACKEND(same backend kinds as cache).
Each probe runs independently — one failure does not short-circuit the others, so a single run reports all unhealthy dependencies at once.
Exit codes:
0— every probe passed1— at least one probe failed
Use it as the last step of your deploy script, or as a CI job that reads production-shaped settings, to catch wrong DATABASE_URL / REDIS_URL configuration before traffic hits the new release.
Audit Log¶
Purge old entries from the audit_logs table. Useful for keeping the database lean in production.
# Preview how many entries would be purged
python3 nori.py audit:purge --days 90 --dry-run
# Export to CSV and purge
python3 nori.py audit:purge --days 90 --export
# Purge directly (no export)
python3 nori.py audit:purge --days 90
| Flag | Default | Description |
|---|---|---|
--days |
90 |
Delete entries older than N days |
--export |
off | Export matching entries to CSV before deleting |
--dry-run |
off | Show count without deleting |
Recommended cron for production:
# Every Sunday at 3am, purge entries older than 90 days
0 3 * * 0 cd /path/to/app && python3 nori.py audit:purge --days 90
Database Seeding¶
The seeder system lets you populate your database with test/initial data in a repeatable way.
How It Works¶
python3 nori.py db:seedinitializes a Tortoise ORM connection and callsseeders/database_seeder.py:run().database_seeder.pyiterates through theSEEDERSlist and dynamically imports each module.- Each seeder module must define an
async def run() -> Nonefunction. - Seeders execute in order — put dependencies first (e.g.
user_seederbeforearticle_seeder).
Writing a Seeder¶
A seeder is a Python module with a single async function:
"""Seeder for User."""
from models.user import User
from core.auth.security import Security
async def run() -> None:
"""Create initial users."""
# Check if data already exists (idempotent seeding)
if await User.filter(email='admin@example.com').exists():
return
await User.create(
name='Admin',
email='admin@example.com',
password=Security.hash_password('password'),
role='admin',
)
await User.create(
name='Editor',
email='editor@example.com',
password=Security.hash_password('password'),
role='editor',
)
Seeder Registration¶
All seeders must be registered in seeders/database_seeder.py using dot-notation module paths:
SEEDERS: list[str] = [
'seeders.role_seeder', # Create roles first
'seeders.user_seeder', # Then users (may reference roles)
'seeders.category_seeder', # Then categories
'seeders.article_seeder', # Then articles (may reference categories + users)
]
Order matters — if article_seeder creates articles that belong to a user, user_seeder must run first.
Error Handling¶
If a seeder raises an exception, the error is logged with full traceback and execution stops. Seeders that ran before the failure are not rolled back — use idempotent checks (if await Model.exists()) to make seeders safe to re-run.
Migrations¶
Nori uses Aerich (async migration tool for Tortoise ORM) wrapped in convenient CLI commands.
First-Time Setup¶
This does two things:
1. Initializes Aerich configuration (reads settings.TORTOISE_ORM)
2. Creates the initial database schema for every app declared in settings.TORTOISE_ORM['apps'] — typically framework and models, plus any extra apps you've wired in (e.g. an analytics schema with its own models). Each app gets its own migrations/<app>/ directory and initial migration file. The loop is idempotent: apps that already have migration files are skipped.
Creating Migrations¶
After modifying a model (adding fields, changing types, etc.):
python3 nori.py migrate:make add_price_to_products
python3 nori.py migrate:make add_price_to_products --app models # same (default)
python3 nori.py migrate:make add_audit_field --app framework # framework models only
This compares your models against the last migration state and generates a migration file in migrations/models/ (or migrations/framework/ for framework models).
Naming convention: use descriptive names like add_price_to_products, create_orders_table, remove_legacy_status_field.
Applying Migrations¶
python3 nori.py migrate:upgrade # All apps in settings.TORTOISE_ORM (declaration order)
python3 nori.py migrate:upgrade --app models # User models only
python3 nori.py migrate:upgrade --app framework # Framework models only
When --app is omitted, every app declared in settings.TORTOISE_ORM['apps'] is upgraded in declaration order — framework first, then models, then any extras.
Migration Directory Structure¶
Nori uses two separate migration namespaces, both owned by your site:
migrations/
├── framework/ ← Generated locally against your DB engine (Nori models: AuditLog, Job, etc.)
└── models/ ← Your application models
Both directories are populated on migrate:init and grow with migrate:make. They are never overwritten by framework:update — Aerich emits engine-specific SQL, so a single migration cannot work across MySQL, PostgreSQL, and SQLite. Each site keeps its own.
When framework:update brings new framework models (e.g. a new core table in a future release), regenerate the migration locally:
Aerich diffs the new models against your saved snapshot and generates SQL adapted to your engine.
Rolling Back¶
python3 nori.py migrate:downgrade # Roll back 1 (user models)
python3 nori.py migrate:downgrade --steps 3 # Roll back 3
python3 nori.py migrate:downgrade --app framework # Roll back framework
python3 nori.py migrate:downgrade --delete # Roll back and delete the file
Extending the CLI¶
The CLI uses Python's argparse with a plugin system based on the commands/ directory. The entry point nori.py is a thin bootstrap — all framework command logic lives in core/cli.py, which updates automatically with framework:update. Your custom commands live in commands/ and are never touched by updates.
Adding a Custom Command¶
Create a Python file in rootsystem/application/commands/. Each file must export two functions:
register(subparsers)— adds one or more argparse subparsershandle(args)— executes the command when invoked
Example — commands/stats.py:
"""Show application statistics."""
from __future__ import annotations
import subprocess
import sys
def register(subparsers) -> None:
parser = subparsers.add_parser('app:stats', help='Show application statistics')
parser.add_argument('--verbose', action='store_true', help='Show detailed stats')
def handle(args) -> None:
print("Application Stats")
print("=" * 40)
if args.verbose:
print(" Verbose mode enabled")
# Your logic here
Then run it:
An example file is provided at commands/_example.py — rename it (remove the _ prefix) to activate.
How It Works¶
- The CLI scans
commands/*.pyat startup (files starting with_are skipped) - Each module's
register(subparsers)is called to add its command(s) - When the command is invoked, the module's
handle(args)receives the parsed args - If a module fails to load, a warning is printed and the CLI continues
Command Naming Convention¶
Follow the category:action pattern:
make:*— Code generation commandsmigrate:*— Database migration commandsdb:*— Database utility commandsqueue:*— Queue managementapp:*— Application-specific commands (recommended for user commands)
Running Async Code in Custom Commands¶
If your command needs database access or async operations, use the subprocess pattern to get a dedicated Tortoise connection:
import subprocess
import sys
def register(subparsers) -> None:
subparsers.add_parser('app:count-users', help='Count all users')
def handle(args) -> None:
script = (
"import asyncio, sys\n"
"sys.path.insert(0, '.')\n"
"import settings\n"
"from tortoise import Tortoise\n"
"async def _run():\n"
" await Tortoise.init(config=settings.TORTOISE_ORM)\n"
" from models.user import User\n"
" count = await User.all().count()\n"
" print(f'Total users: {count}')\n"
" await Tortoise.close_connections()\n"
"asyncio.run(_run())\n"
)
subprocess.run([sys.executable, '-c', script], cwd='.')
This spawns a subprocess with its own Tortoise connection, keeping the CLI process lightweight.
Complete Workflow Example¶
Creating a blog feature from scratch:
# 1. Generate model and controller
python3 nori.py make:model Article
python3 nori.py make:controller Article
# 2. Edit the model — add fields
# rootsystem/application/models/article.py
# 3. Register the model
# rootsystem/application/models/__init__.py
# Add: from models.article import Article
# 4. Create and run migration
python3 nori.py migrate:make create_articles_table
python3 nori.py migrate:upgrade
# 5. Edit the controller — add logic
# rootsystem/application/modules/article.py
# 6. Register routes
# rootsystem/application/routes.py
# 7. Create seeder for test data
python3 nori.py make:seeder Article
# Edit rootsystem/application/seeders/article_seeder.py
# Register in seeders/database_seeder.py
# 8. Seed the database
python3 nori.py db:seed
# 9. Start developing
python3 nori.py serve