Skip to content

Project Structure

Every directory in a Sillo project, what belongs in it, and the reasoning behind the boundaries: app, database, routes, templates, static, storage, scripts, tests.

myapp/
app/
main.py ASGI entrypoint — `uvicorn app.main:app`
bootstrap.py Application assembly. Start reading here
config.py Typed settings, loaded from the environment
admin.py Admin panel registration
templating.py Jinja setup
jobs/ Queue jobs
tasks/ Scheduled tasks
database/
config.py How this project connects — app and migrations share it
models/ Your models. `user.py` is provided
migrations/ Generated migrations — commit these
routes/
web.py Server-rendered pages
auth.py JSON auth endpoints
api.py Everything else under /api
templates/ Jinja templates
static/ CSS, images, anything served as-is
storage/ Runtime data — the SQLite file, logs, uploads
scripts/
smoke.py Boots the app and hits every route
tests/
pyproject.toml
.env.example

Four top-level packages, and the split between them is the point of this page.

app/ is the application. How it is assembled, how it is configured, what runs in the background. Nothing in app/ is about a URL or a table.

database/ is the data layer. The connection, the models, the migrations, one directory holding everything that describes persistence.

routes/ is the HTTP surface. What paths exist and what they return.

sillo is the operator’s entry point. Migrations, accounts, processes. The project ships no console file: sillo finds the application and derives its commands from what the application set up.

That boundary is load-bearing in one direction: routes/ imports from app/ and database/, app/ imports from database/, and database/ imports from neither. Follow it and a model can be used from a script, a test or a migration without dragging the HTTP layer in behind it.


The ASGI entrypoint, and deliberately trivial:

from app.bootstrap import create_app
app = create_app()

uvicorn app.main:app is what your process manager runs. Keeping it a one-liner is what lets tests build their own instance:

from app.bootstrap import create_app
app = create_app() # a fresh one, with no shared state

A module that builds the app and configures logging and reads arguments cannot be imported twice safely. This one can.

The single place where the application is put together. Read it first.

def create_app() -> SilloApp:
application = SilloApp(debug=config.debug, title=config.app_name, version="0.1.0")
_register_admin(application)
_register_templating()
_register_middleware(application)
_register_database(application)
# _register_work(application)
_register_static(application)
_register_routes(application)
return application

Every step is a named function with its reasoning in the docstring. The order is not cosmetic, and one part of it is genuinely surprising:

The same ordering has a second consequence, and it catches people writing framework code rather than application code: the admin’s startup hook is registered before the database’s, so the admin’s hook runs while the ORM is still uninitialised. Anything that asks the database a question at that moment gets the wrong answer.

Typed settings, read from the environment once at import.

class Settings:
app_name: str = "Myapp"
app_env: Literal["local", "testing", "staging", "production"] = "local"
debug: bool = True
host: str = "127.0.0.1"
port: int = 8000
secret_key: str = "change-me"
database_url: str = "sqlite://storage/myapp.db"
db_pool_size: int = 5
db_echo: bool = False
db_generate_schemas: bool = False
session_cookie_name: str = "session_id"
session_lifetime: int = 86400
admin_enabled: bool = True
admin_prefix: str = "/admin"
cors_allow_origins: str = "http://localhost:5173"
log_level: Literal["debug", "info", "warning", "error"] = "info"

Read values through config, never os.getenv:

from app.config import config
config.database_url

A typo in a variable name then fails at startup with a clear message, instead of becoming None at request time and failing three layers down in something that looks unrelated.

Where admin models are registered. One function, called from bootstrap:

def register_admin(application: SilloApp) -> AdminSite:
admin = AdminSite(title="Myapp Admin", prefix=config.admin_prefix, user_model=User)
@admin.register(User)
class UserAdmin(ModelAdmin):
verbose_name = "Users"
list_display = ["id", "email", "username", "is_active", "is_staff", "last_login"]
search_fields = ["email", "username"]
admin.mount(application)
return admin

Register your models before admin.mount(). Mounting registers the user model with a default presentation if nothing has claimed it yet, so registering yours first is what lets your columns take effect.

See The Admin Panel.

Configures the Jinja environment. create_app calls it before any page renders, without that, sillo.templating.render raises NotImplementedError. Not optional for a project serving HTML.

Queue jobs. An empty package in a new project.

Import each job class in __init__.py. The worker resolves a queued payload by importing the module the payload names, so a job in a module nobody imports would still be found, but one place to look is how you find them later, and it is what lets payloads queued by older releases, which recorded only a class name, still resolve.

A job must be a module-level class. One defined inside a function, or in a script run as __main__, cannot be imported by a separate worker process. See Background Work.

Scheduled tasks, registered in one function:

def register_tasks(scheduler) -> None:
from sillo.work.scheduler import CronTrigger
from app.tasks.cleanup import cleanup
scheduler.schedule(cleanup, trigger=CronTrigger("0 3 * * *"), name="cleanup")

Both the application and a standalone scheduler call it, so both see the same schedule. Add a task in two places and they will drift.


Everything about persistence, in one directory: how you connect, what the shapes are, and how they got that way.

One definition of how the project connects, shared by the running application, the migration commands, and any script that opens the database.

MODEL_MODULES = ["database.models", "sillo.admin.models"]
MIGRATIONS_MODULE = "database.migrations"
def database_config() -> DatabaseConfig:
return DatabaseConfig(
url=config.database_url,
pool_size=config.db_pool_size,
echo=config.db_echo,
generate_schemas=config.db_generate_schemas,
)
def database() -> DatabaseManager:
manager = DatabaseManager(database_config())
manager.register_models(*MODEL_MODULES).set_migrations(MIGRATIONS_MODULE)
return manager

There is no separate migration configuration. Change the connection here and migrations follow, with nothing to keep in step by hand.

It is also how a script of your own opens the database:

from database.config import database
async with database():
await User.all()

Your models, one per file, imported in __init__.py.

database/models/__init__.py
from database.models.user import User
__all__ = ["User"]

The ORM only sees what is imported there. A model it cannot see fails on first query with default_connection cannot be None, which points at the database rather than at the missing import, and is the single most confusing error in a new project.

Generated migrations. Commit them. They are the schema’s source of truth, and the database file is gitignored precisely so that they have to be.

They are excluded from linting: they are engine output, and formatting generated code makes ruff check . fail the first time you add a model.


Three modules, split by what they return rather than by feature. A project small enough to have one routes/ directory is better served by “HTML here, JSON there” than by a package per noun.

Module
web.pyServer-rendered pages
auth.pyJSON auth endpoints: register, login, logout, me
api.pyEverything else under /api

Jinja templates and files served as-is.

/static is mounted in bootstrap.py for development and small deployments. With a proxy in front it never sees traffic. See Deployment.


Runtime data: the SQLite file, logs, uploads, caches. Tracked as a directory, ignored as contents.

storage/logs/*
storage/cache/*
storage/temp/*
storage/app/*
!storage/**/.gitkeep
storage/*.db
storage/*.db-shm
storage/*.db-wal

There is no console file. sillo finds the application (app/main.py here) and offers what it set up: setup_record brings the migration and account commands, setup_scheduler brings the schedule commands.

Terminal window
uv run sillo db:migrate
uv run sillo user:admin ada@example.com ada
uv run sillo queue:work

Commands of your own go on the application with app.add_command, which is what puts them in the same listing.

See The Console.


scripts/smoke.py boots the application and calls every route. It is not a unit test and it is not a replacement for one. It exists because a project can import cleanly, render every template and still fail on the first real request.

tests/ holds the pytest suite. conftest.py gives every test its own temporary database.

Both are covered in Testing.


The layout is a starting point, not a rule. Two boundaries are worth keeping as you grow:

Keep database/ importable on its own. If a model starts importing from routes/, a migration that touches that model now needs the HTTP layer to import cleanly. Push shared logic down, not up.

Keep bootstrap.py a list of steps. When assembly grows, add a _register_* function; do not inline it. The value of that file is that you can read the whole application’s shape in twenty lines.

For anything larger (a package per domain, with its own models, routes and services) move routes/ and database/models/ into those packages and keep bootstrap.py and database/config.py where they are. Those two are the project’s spine.