Every directory in a Sillo project, what belongs in it, and the reasoning behind the boundaries: app, database, routes, static, storage, scripts, tests.
Project Structure
Section titled “Project Structure”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 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 The welcome page auth.py JSON auth endpoints api.py Everything else under /api 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.exampleFour top-level packages, and the split between them is the point of this page.
The shape of it
Section titled “The shape of it”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.
app/main.py
Section titled “app/main.py”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 stateA module that builds the app and configures logging and reads arguments cannot be imported twice safely. This one can.
app/bootstrap.py
Section titled “app/bootstrap.py”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_middleware(application) _register_database(application) # _register_work(application) _register_static(application) _register_routes(application)
return applicationEvery 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: a startup hook registered before the database’s runs while the ORM is still uninitialised. Anything that asks the database a question at that moment gets the wrong answer.
app/config.py
Section titled “app/config.py”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
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_urlA 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.
app/jobs/
Section titled “app/jobs/”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.
app/tasks/
Section titled “app/tasks/”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.
database/
Section titled “database/”Everything about persistence, in one directory: how you connect, what the shapes are, and how they got that way.
database/config.py
Section titled “database/config.py”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"]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 managerThere 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()database/models/
Section titled “database/models/”Your models, one per file, imported in __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.
database/migrations/
Section titled “database/migrations/”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.
routes/
Section titled “routes/”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.py | Server-rendered pages |
auth.py | JSON auth endpoints: register, login, logout, me |
api.py | Everything else under /api |
static/
Section titled “static/”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.
storage/
Section titled “storage/”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/*.dbstorage/*.db-shmstorage/*.db-walManagement commands
Section titled “Management commands”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.
uv run sillo db:migrateuv run sillo user:admin ada@example.com adauv run sillo queue:workCommands of your own go on the application with app.add_command, which is
what puts them in the same listing.
See The Console.
scripts/ and tests/
Section titled “scripts/ and tests/”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 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.
Adding your own
Section titled “Adding your own”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.
Related
Section titled “Related”- Creating a Project: how the files got here
- The Console: every command in full
- Database & Migrations: models and schema changes
- Middleware: the ordering rules in general
- Routers & Sub-Apps: mounting and prefixes