Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions docs/adr/041-database-install-command.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 041. InstallCommand base class and database:install
# 041. InstallCommand base class and db:install

Date: 2026-10-22

Expand Down Expand Up @@ -32,19 +32,22 @@ CacheInstallCommand = (
- `add_provider` takes classes or dotted strings. A class is converted to `module.QualName`. A string is needed when the class lives in the user's app (`config.database.DatabaseConfig` does not exist when the framework loads), or when importing it would load an optional extra. Each `ProviderEntry` derives the inserted entry (`(Provider, Config)`, or a bare `Provider` when there is no config) and the `from module import Name` statements.
- `copy` accepts a path or an `importlib.resources` `Traversable` and copies the stub verbatim.
- `handle()` runs `publish()` and then registers every provider. The default `publish()` copies each stub. A subclass that needs options or templating overrides `publish()` and calls `publish_file(relative_path, content)`. `publish_file` creates parent directories and skips an existing file unless `--force` is passed. `--force` is declared on the base class, and subclasses add their own options with `options = [*InstallCommand.options, ...]`.
- `register_provider(source, provider, entry, imports)` is a pure function that returns the edited source, the unchanged source if the provider is already registered, or `None` if there is no `Application(providers=[...])` to edit.
- `register_provider(source, provider, entry, imports)` is a pure function. `imports` maps each name to its module. It returns the edited source, or the unchanged source if the provider is already registered. It raises `RegistrationError` with a reason when it cannot edit the file safely. The command prints that reason and the manual steps, and returns 1.

`DatabaseInstallCommand` is a regular subclass, because it has a `--driver` option and renders the stub template. It declares `providers = (ProviderEntry.of("fastapi_startkit.masoniteorm.DatabaseProvider", "config.database.DatabaseConfig"),)`. `uv run artisan database:install [--driver sqlite|postgres|mysql] [--force]`:
`DbInstallCommand` is a regular subclass, because it has a `--driver` option and renders the stub template. It declares `providers = (ProviderEntry.of("fastapi_startkit.masoniteorm.DatabaseProvider", "config.database.DatabaseConfig"),)`. The command is named `db:install` to match the other ORM commands (`db:migrate`, `db:seed`, `db:make:*`). `uv run artisan db:install [--driver sqlite|postgres|mysql] [--force]`:

- Renders `config/database.py` from the installer's `database/config.py.stub`, so it matches what `fastapi-startkit init` generates. `config/` is created if missing. If the file already exists it is skipped with a message, unless `--force` is passed.
- Parses `bootstrap/application.py` with `ast` and finds the `Application(...)` call. If its `providers` list already names `DatabaseProvider`, nothing is changed. Otherwise `(DatabaseProvider, DatabaseConfig)` is appended to the list, following its layout: a new line for multi-line lists, inline for single-line lists. If there is no `providers=` keyword, one is added. Missing `from fastapi_startkit.masoniteorm import DatabaseProvider` and `from config.database import DatabaseConfig` imports are inserted after the last top-level import.
- If `bootstrap/application.py` or the `Application(...)` call is missing, it prints the lines to add by hand and returns 1. The config file is still written.
- Renders `config/database.py` from the installer's `database/config.py.stub`, so it matches the config file that `fastapi-startkit init` generates. `config/` is created if missing. If the file already exists it is skipped with a message, unless `--force` is passed.
- Parses `bootstrap/application.py` with `ast` and finds the `Application(...)` call. The providers list is the `providers=` keyword or, because `Application(base_path, env, providers)` also accepts it positionally, the third positional argument. Appending `providers=` next to a positional list would raise `TypeError` at boot, so a positional list is edited in place. If the call uses `*args`, or the providers value is not a list literal, the command stops.
- The provider counts as already registered when the list names it as a bare name (`DatabaseProvider`) or as an attribute (`orm.DatabaseProvider`, `fastapi_startkit.masoniteorm.DatabaseProvider`). Nothing is changed in that case. Otherwise `(DatabaseProvider, DatabaseConfig)` is appended to the list, following its layout: a new line for multi-line lists, inline for single-line lists. If there is no providers argument, `providers=` is added.
- Missing `from fastapi_startkit.masoniteorm import DatabaseProvider` and `from config.database import DatabaseConfig` imports are inserted after the last top-level import. A name that is already imported is compared by where it comes from, not only by name. The import is reused when its full path matches, or when both paths resolve to the same object in modules that are already loaded (for example a re-export). Nothing new is imported to check this. If the name is bound to something else, such as the base `fastapi_startkit.masoniteorm.config.database.DatabaseConfig`, the command stops with `DatabaseConfig is already imported as <path>, not config.database.DatabaseConfig.` and prints the manual steps. Relative imports count as a different path. We considered importing under an alias (`DatabaseConfig as AppDatabaseConfig`), but rejected it: it adds a second name for the same concept to the user's bootstrap file and hides a likely mistake. Stopping is explicit, and the fix is a one-line edit.
- If `bootstrap/application.py` or the `Application(...)` call is missing, or the file cannot be edited safely, it prints the reason and the lines to add by hand, and returns 1. The config file is still written.
- After a successful run it prints next steps. The `DB_*` `.env` entries and the extras come from the installer's `define_database(driver, project_name)` section, so they stay the same as `init --database`. If `importlib.util.find_spec("sqlalchemy")` finds nothing, it first prints `uv add "fastapi-startkit[database,<driver>]"`, because the generated config and `DatabaseProvider` import SQLAlchemy and every `artisan` call fails without it. It then prints `mkdir -p databases/migrations` if that directory is missing, because `db:make:migration` does not create it, and points to `db:make:migration` and `db:migrate`. The command does not create directories or seeders, and it does not edit `.env`, so that it only writes the config file and the bootstrap edit.

The command lives in `fastapi_startkit/console/` rather than `masoniteorm/commands/`, because importing `masoniteorm.commands` loads SQLAlchemy, which is an optional extra. `install_command.py` imports only `ast`, cleo and `Command`, so packages with optional extras can subclass it without loading anything extra. Text is inserted at AST-reported positions rather than regenerated with `ast.unparse`, so the user's formatting and comments are kept.

## Validation

- `tests/console/test_install_command.py` tests commands built with the fluent API: a copied stub with a tuple entry, a provider-only command with a bare entry, several providers with a `Traversable` stub, class references converted to imports, and chain calls that do not mutate the base class. It also tests `register_provider` directly on single-line, multi-line, empty and missing `providers` lists, files with no imports, existing imports, already-registered providers and unsupported shapes.
- `tests/console/test_database_install_command.py` runs `database:install` against a temporary app directory. It covers a fresh install, a second run that changes nothing, an existing config with and without `--force`, a missing `config/` directory, a missing bootstrap file, an unsupported driver, and loading the edited bootstrap file.
- `tests/console/test_install_command.py` tests commands built with the fluent API: a copied stub with a tuple entry, a provider-only command with a bare entry, several providers with a `Traversable` stub, class references converted to imports, and chain calls that do not mutate the base class. It also tests `register_provider` directly on single-line, multi-line, empty, missing and positional `providers` lists, files with no imports, existing imports, already-registered providers (bare names and attributes), names imported from another module, aliased or relative imports, re-exported classes and unsupported shapes.
- `tests/console/test_db_install_command.py` runs `db:install` against a temporary app directory. It covers a fresh install, a second run that changes nothing, an existing config with and without `--force`, a missing `config/` directory, a missing bootstrap file, an unsupported driver, a conflicting `DatabaseConfig` import, the next steps for each driver and an existing migrations directory, the hint when SQLAlchemy is missing, and loading the edited bootstrap file.
- The full suite passes with coverage of at least 80%.
- Manual run, twice, against an app generated by `fastapi-startkit init --database none`.
2 changes: 1 addition & 1 deletion docs/adr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ Read this index first, then open the records relevant to the change. Before impl
| [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. |
| [037](037-http-exception-status-and-fastapi-title.md) | 2026-10-25 | HTTP exceptions keep their status; FastAPI title from config | `HTTPExceptionHandler` rendered every `HTTPException` and `ModelNotFoundException` as 500. Starlette `HTTPException` now goes through FastAPI's `http_exception_handler`, which keeps the status, the `detail` and the headers. A new dependency-free `fastapi_startkit.exceptions.HttpException` base (`get_status`/`get_response`/`get_headers`) is subclassed by `ModelNotFoundException`, `RouteNotFoundException`, `MethodNotAllowedException`, `AuthorizationException` and `ThrottleRequestsException` (429 with `Retry-After`), and is registered as its own FastAPI handler so it never reaches the re-raising `ServerErrorMiddleware`. `HTTPExceptionHandler.report` skips 4xx HTTP exceptions and still reports 5xx. `FastAPIConfig.title` reads `APP_NAME` (default `FastAPI Startkit`). `Application.__init__` no longer discards the FastAPI instance a provider creates in `register`. |
| [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. |
| [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and database:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It does nothing if the provider is already listed, and prints manual steps and exits 1 if the bootstrap file is missing. `database:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. Both live in `console/` so the optional SQLAlchemy extra is not imported. |
| [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. |
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
from importlib.resources import files
from importlib.util import find_spec
from string import Template
from typing import ClassVar

from cleo.helpers import option
from cleo.io.inputs.option import Option

from fastapi_startkit.console.install_command import InstallCommand, ProviderEntry
from fastapi_startkit.installer.plan import DRIVER_CLASSES
from fastapi_startkit.installer.plan import DRIVER_CLASSES, define_database, project_name_for

MIGRATIONS_PATH = "databases/migrations"

class DatabaseInstallCommand(InstallCommand):
name = "database:install"

class DbInstallCommand(InstallCommand):
name = "db:install"
description = "Publish config/database.py and register DatabaseProvider in bootstrap/application.py."

providers = (ProviderEntry.of("fastapi_startkit.masoniteorm.DatabaseProvider", "config.database.DatabaseConfig"),)
Expand All @@ -26,6 +29,12 @@ class DatabaseInstallCommand(InstallCommand):
),
]

def handle(self) -> int:
status = super().handle()
if status == 0:
self.next_steps()
return status

def publish(self) -> int:
driver = self.option("driver")
if driver not in DRIVER_CLASSES:
Expand All @@ -38,3 +47,19 @@ def publish(self) -> int:
Template(stub).substitute(driver=driver, driver_class=DRIVER_CLASSES[driver]),
)
return 0

def next_steps(self) -> None:
section = define_database(self.option("driver"), project_name_for(self.base_path))
self.line("")
self.line("Next steps:")
if find_spec("sqlalchemy") is None:
self.line(
f' Install the database extra: <info>uv add "fastapi-startkit[{",".join(section.extras)}]"</info>'
)
self.line(" Add to .env:")
for key, value in section.environment.items():
self.line(f" {key}={value}")
if not (self.base_path / MIGRATIONS_PATH).is_dir():
self.line(f" Create the migrations directory: <info>mkdir -p {MIGRATIONS_PATH}</info>")
self.line(" Create a migration: <info>uv run python artisan db:make:migration create_users_table</info>")
self.line(" Run migrations: <info>uv run python artisan db:migrate</info>")
Loading
Loading