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 (e.g., make:model, migrate:upgrade).
Command Reference¶
| Command | Description |
|---|---|
serve |
Start the development server with hot reload |
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 migrations |
db:seed |
Run all registered database seeders |
queue:work |
Run the persistent job queue worker |
framework:update |
Update the Nori core from GitLab |
framework:version |
Show the current framework version |
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 |
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 = 'product'
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 GitLab 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).
What gets updated:
| Directory | Contents |
|---|---|
core/ |
Framework core (auth, cache, mail, queue, etc.) |
models/framework/ |
Framework models (AuditLog, Job, Permission, Role) |
migrations/framework/ |
Framework migration files |
Process:
1. Reads current version from core/version.py.
2. Queries the GitLab Releases API for the target version.
3. Creates a timestamped backup in rootsystem/.framework_backups/.
4. Downloads and extracts the release zip.
5. Replaces all three framework directories.
6. If new framework migrations are detected, prompts to run migrate:upgrade --app framework.
For private repositories, set GITLAB_TOKEN in your environment.
framework:version¶
Displays the current version of the Nori core installed in the project.
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 from your current models
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 # Both apps (framework + models)
python3 nori.py migrate:upgrade --app models # User models only
python3 nori.py migrate:upgrade --app framework # Framework models only
When --app is omitted, both framework and models are upgraded in order.
Migration Directory Structure¶
Nori uses two separate migration namespaces:
migrations/
├── framework/ ← Managed by Nori (ships with framework:update)
└── models/ ← Managed by the developer (your models)
Framework migrations are never mixed with your application migrations. When you run framework:update, new framework migrations are downloaded automatically and can be applied with migrate:upgrade --app framework.
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 is intentionally simple — a single nori.py file using Python's argparse. There is no plugin system by design (consistent with "Keep it Native").
Adding a Custom Command¶
To add your own command, edit nori.py:
Step 1 — Add a handler function:
def my_custom_command(arg1):
"""Description of what it does."""
print(f"Running custom command with {arg1}")
# Your logic here — import modules, run scripts, etc.
Step 2 — Register the subparser (in main()):
# Command: custom:task
parser_custom = subparsers.add_parser("custom:task", help="Run my custom task")
parser_custom.add_argument("arg1", type=str, help="First argument")
Step 3 — Add the dispatch condition (in main()):
Command Naming Convention¶
Follow the category:action pattern:
make:*— Code generation commandsmigrate:*— Database migration commandsdb:*— Database utility commandsqueue:*— Queue management (planned)cache:clear— Cache management (example)
Running Async Code in Custom Commands¶
If your command needs database access or async operations, use the same pattern as db:seed:
def my_async_command():
"""Run an async operation with database access."""
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"
" # Your async logic here\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=_APP_DIR)
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