From bdca68b1048b457802fe00621f92518ec2dae83c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 22 Oct 2026 14:28:51 -0700 Subject: [PATCH] Add database:install console command Publishes config/database.py from the installer's database stub and registers (DatabaseProvider, DatabaseConfig) in bootstrap/application.py. Re-running is idempotent; an existing config is kept unless --force. Co-Authored-By: Claude Opus 5.5 --- docs/adr/041-database-install-command.md | 29 +++ docs/adr/AGENTS.md | 1 + .../console/database_install_command.py | 155 ++++++++++++++ .../foundation/app_provider.py | 3 +- .../src/fastapi_startkit/installer/plan.py | 4 +- .../console/test_database_install_command.py | 189 ++++++++++++++++++ 6 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 docs/adr/041-database-install-command.md create mode 100644 fastapi_startkit/src/fastapi_startkit/console/database_install_command.py create mode 100644 fastapi_startkit/tests/console/test_database_install_command.py diff --git a/docs/adr/041-database-install-command.md b/docs/adr/041-database-install-command.md new file mode 100644 index 00000000..d75731cc --- /dev/null +++ b/docs/adr/041-database-install-command.md @@ -0,0 +1,29 @@ +# 041. database:install command + +Date: 2026-10-22 + +## Problem + +`fastapi-startkit init --database ...` sets up the database only for new projects. An existing application without a database has to copy a config file and edit `bootstrap/application.py` by hand: import `DatabaseProvider` and its config, and add `(DatabaseProvider, DatabaseConfig)` to `providers=[...]`. `provider:publish` cannot help, because it only knows about the resources of providers that are already registered and booted. + +## Alternatives + +1. Register the command from `DatabaseProvider`. It would only exist after the database is installed, which is too late. +2. Generate a provider list in a separate file such as `config/providers.py`. Apps do not use one. Every app built with the installer and every example lists providers inline in the `Application(...)` call in `bootstrap/application.py`. +3. Add a core command, registered by `AppProvider` next to `provider:publish`, that writes the config file and edits `bootstrap/application.py` in place. + +## Decision + +Option 3. `uv run artisan database: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. + +The command lives in `fastapi_startkit/console/` rather than `masoniteorm/commands/`, because importing `masoniteorm.commands` loads SQLAlchemy, which is an optional 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 in `tests/console/test_database_install_command.py` run the command against a temporary app directory. They cover a fresh install, a second run that changes nothing, single-line and missing `providers` lists, an existing config with and without `--force`, a missing `config/` directory, a missing bootstrap file, an unsupported driver, 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`. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 7b657eb1..ef68f0c2 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -36,3 +36,4 @@ Read this index first, then open the records relevant to the change. Before impl | [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. | | [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. | | [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 | database:install command | Core `database:install` command (registered by `AppProvider`, so it exists before the database is set up) renders `config/database.py` from the installer's database stub (`--driver`, defaults to sqlite) and skips an existing file unless `--force` is passed. It uses `ast` to find `Application(...)` in `bootstrap/application.py`, then inserts `(DatabaseProvider, DatabaseConfig)` and any missing imports at the reported positions, so formatting is kept. It does nothing when `DatabaseProvider` is already listed. If the bootstrap file is missing, it prints manual steps and exits 1. It lives in `console/` so the optional SQLAlchemy extra is not imported. | diff --git a/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py b/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py new file mode 100644 index 00000000..ad71492e --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py @@ -0,0 +1,155 @@ +import ast +from collections.abc import Sequence +from importlib.resources import files +from pathlib import Path +from string import Template + +from cleo.helpers import option + +from fastapi_startkit.console.command import Command +from fastapi_startkit.installer.plan import DRIVER_CLASSES + +CONFIG_PATH = "config/database.py" +BOOTSTRAP_PATH = "bootstrap/application.py" +PROVIDER = "(DatabaseProvider, DatabaseConfig)" +IMPORTS = { + "DatabaseProvider": "from fastapi_startkit.masoniteorm import DatabaseProvider", + "DatabaseConfig": "from config.database import DatabaseConfig", +} + +Insert = tuple[int, str] + + +class DatabaseInstallCommand(Command): + name = "database:install" + description = "Publish config/database.py and register DatabaseProvider in bootstrap/application.py." + + options = [ + option( + "driver", + "d", + description=f"Database driver: {', '.join(DRIVER_CLASSES)}.", + flag=False, + default="sqlite", + ), + option("force", "f", description=f"Overwrite an existing {CONFIG_PATH}."), + ] + + def handle(self) -> int: + driver = self.option("driver") + if driver not in DRIVER_CLASSES: + self.line_error(f"Unsupported driver '{driver}'. Use one of: {', '.join(DRIVER_CLASSES)}.") + return 1 + + base_path = Path(self.container.base_path) + self.write_config(base_path / CONFIG_PATH, driver) + return self.register_provider(base_path / BOOTSTRAP_PATH) + + def write_config(self, path: Path, driver: str) -> None: + if path.exists() and not self.option("force"): + self.line(f"{CONFIG_PATH} already exists, skipped. Use --force to overwrite it.") + return + + stub = files("fastapi_startkit.installer").joinpath("stubs/database/config.py.stub").read_text(encoding="utf-8") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(Template(stub).substitute(driver=driver, driver_class=DRIVER_CLASSES[driver]), encoding="utf-8") + self.line(f"Created {CONFIG_PATH}") + + def register_provider(self, path: Path) -> int: + source = path.read_text(encoding="utf-8") if path.exists() else None + updated = register_database_provider(source) if source is not None else None + + if updated is None: + self.line_error(f"Could not find Application(providers=[...]) in {BOOTSTRAP_PATH}.") + self.line_error("Register the provider manually:") + for statement in IMPORTS.values(): + self.line_error(f" {statement}") + self.line_error(f" providers=[..., {PROVIDER}]") + return 1 + + if updated == source: + self.line(f"DatabaseProvider is already registered in {BOOTSTRAP_PATH}.") + return 0 + + path.write_text(updated, encoding="utf-8") + self.line(f"Registered DatabaseProvider in {BOOTSTRAP_PATH}") + return 0 + + +def register_database_provider(source: str) -> str | None: + tree = ast.parse(source) + call = find_application_call(tree) + if call is None: + return None + + lines = source.splitlines(keepends=True) + keyword = next((keyword for keyword in call.keywords if keyword.arg == "providers"), None) + + if keyword is None: + inserts = append_item(source, lines, call, [*call.args, *call.keywords], f"providers=[{PROVIDER}]") + elif not isinstance(keyword.value, ast.List): + return None + elif any(isinstance(node, ast.Name) and node.id == "DatabaseProvider" for node in ast.walk(keyword.value)): + return source + else: + inserts = append_item(source, lines, keyword.value, keyword.value.elts, PROVIDER) + + inserts.extend(import_inserts(tree, lines)) + for position, text in sorted(inserts, key=lambda insert: insert[0], reverse=True): + source = source[:position] + text + source[position:] + return source + + +def find_application_call(tree: ast.Module) -> ast.Call | None: + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if (isinstance(func, ast.Name) and func.id == "Application") or ( + isinstance(func, ast.Attribute) and func.attr == "Application" + ): + return node + return None + + +def append_item( + source: str, lines: list[str], node: ast.expr, items: Sequence[ast.expr | ast.keyword], text: str +) -> list[Insert]: + assert node.end_lineno is not None and node.end_col_offset is not None + close = offset(lines, node.end_lineno, node.end_col_offset - 1) + if not items: + return [(close, text)] + + last = max(items, key=lambda item: (item.end_lineno or 0, item.end_col_offset or 0)) + assert last.end_lineno is not None and last.end_col_offset is not None + end = offset(lines, last.end_lineno, last.end_col_offset) + has_comma = source[end:close].lstrip().startswith(",") + + if last.end_lineno == node.end_lineno: + return [(close, (" " if has_comma else ", ") + text)] + + line = lines[last.end_lineno - 1] + line_end = offset(lines, last.end_lineno, 0) + len(line.rstrip("\r\n")) + indent = line[: len(line) - len(line.lstrip())] + inserts = [(line_end, f"\n{indent}{text},")] + if not has_comma: + inserts.append((end, ",")) + return inserts + + +def import_inserts(tree: ast.Module, lines: list[str]) -> list[Insert]: + imports = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))] + imported = {alias.asname or alias.name for node in imports for alias in node.names} + missing = [statement for name, statement in IMPORTS.items() if name not in imported] + if not missing: + return [] + if not imports: + return [(0, "\n".join(missing) + "\n\n")] + + last = imports[-1].end_lineno + assert last is not None + return [(offset(lines, last, 0) + len(lines[last - 1].rstrip("\r\n")), "\n" + "\n".join(missing))] + + +def offset(lines: list[str], lineno: int, col: int) -> int: + return sum(len(line) for line in lines[: lineno - 1]) + len(lines[lineno - 1].encode()[:col].decode()) diff --git a/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py b/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py index 2f43a87f..69e0ac98 100644 --- a/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py @@ -1,3 +1,4 @@ +from fastapi_startkit.console.database_install_command import DatabaseInstallCommand from fastapi_startkit.console.publish_command import PublishCommand from fastapi_startkit.support import Provider @@ -7,4 +8,4 @@ def register(self) -> None: pass def boot(self) -> None: - self.commands([PublishCommand]) + self.commands([PublishCommand, DatabaseInstallCommand]) diff --git a/fastapi_startkit/src/fastapi_startkit/installer/plan.py b/fastapi_startkit/src/fastapi_startkit/installer/plan.py index 00dcf3ec..711ecd3c 100644 --- a/fastapi_startkit/src/fastapi_startkit/installer/plan.py +++ b/fastapi_startkit/src/fastapi_startkit/installer/plan.py @@ -6,6 +6,8 @@ from pathlib import Path from string import Template +DRIVER_CLASSES = {"sqlite": "SQLiteConfig", "postgres": "PostgresConfig", "mysql": "MySQLConfig"} + @dataclass(frozen=True) class InstallOptions: @@ -117,7 +119,7 @@ def define_web() -> Section: def define_database(driver: str, project_name: str) -> Section: if driver == "none": return Section() - driver_class = {"sqlite": "SQLiteConfig", "postgres": "PostgresConfig", "mysql": "MySQLConfig"}[driver] + driver_class = DRIVER_CLASSES[driver] environment = {"DB_CONNECTION": driver} if driver == "sqlite": environment["DB_DATABASE"] = "databases/database.sqlite" diff --git a/fastapi_startkit/tests/console/test_database_install_command.py b/fastapi_startkit/tests/console/test_database_install_command.py new file mode 100644 index 00000000..72f80caa --- /dev/null +++ b/fastapi_startkit/tests/console/test_database_install_command.py @@ -0,0 +1,189 @@ +import ast +from pathlib import Path +from types import SimpleNamespace + +from cleo.testers.command_tester import CommandTester + +from fastapi_startkit.console.database_install_command import DatabaseInstallCommand, register_database_provider +from fastapi_startkit.foundation.app_provider import AppProvider + +MULTILINE_BOOTSTRAP = """from pathlib import Path + +from fastapi_startkit import Application +from fastapi_startkit.logging import LogProvider +from config.app import AppConfig +from config.logging import LoggingConfig + +app = Application( + base_path=Path(__file__).resolve().parent.parent, + config=AppConfig, + providers=[ + (LogProvider, LoggingConfig) # logging + ], +) +""" + + +def run(base_path: Path, args: str = "") -> CommandTester: + command = DatabaseInstallCommand() + command.set_container(SimpleNamespace(base_path=base_path)) # type: ignore[arg-type] + tester = CommandTester(command) + tester.execute(args) + return tester + + +def make_app(tmp_path: Path, bootstrap: str = MULTILINE_BOOTSTRAP) -> Path: + (tmp_path / "bootstrap").mkdir() + (tmp_path / "bootstrap/application.py").write_text(bootstrap) + return tmp_path + + +def providers_of(source: str) -> list[str]: + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.keyword) and node.arg == "providers": + assert isinstance(node.value, ast.List) + return [ast.unparse(item) for item in node.value.elts] + raise AssertionError("providers keyword not found") + + +def test_installs_config_and_registers_provider(tmp_path): + app = make_app(tmp_path) + + tester = run(app) + + assert tester.status_code == 0 + config = (app / "config/database.py").read_text() + assert 'env("DB_CONNECTION", "sqlite")' in config + assert "SQLiteConfig()" in config + bootstrap = (app / "bootstrap/application.py").read_text() + assert providers_of(bootstrap) == ["(LogProvider, LoggingConfig)", "(DatabaseProvider, DatabaseConfig)"] + assert ( + " (LogProvider, LoggingConfig), # logging\n (DatabaseProvider, DatabaseConfig),\n" in bootstrap + ) + assert ( + "from config.logging import LoggingConfig\n" + "from fastapi_startkit.masoniteorm import DatabaseProvider\n" + "from config.database import DatabaseConfig\n" + ) in bootstrap + output = tester.io.fetch_output() + assert "Created config/database.py" in output + assert "Registered DatabaseProvider in bootstrap/application.py" in output + + +def test_second_run_changes_nothing(tmp_path): + app = make_app(tmp_path) + run(app) + config = (app / "config/database.py").read_text() + bootstrap = (app / "bootstrap/application.py").read_text() + + tester = run(app, "--driver postgres") + + assert tester.status_code == 0 + assert (app / "config/database.py").read_text() == config + assert (app / "bootstrap/application.py").read_text() == bootstrap + output = tester.io.fetch_output() + assert "config/database.py already exists, skipped" in output + assert "DatabaseProvider is already registered" in output + + +def test_force_overwrites_existing_config(tmp_path): + app = make_app(tmp_path) + (app / "config").mkdir() + (app / "config/database.py").write_text("OLD = True\n") + + tester = run(app, "--driver mysql --force") + + assert tester.status_code == 0 + assert "MySQLConfig()" in (app / "config/database.py").read_text() + + +def test_rejects_unknown_driver(tmp_path): + app = make_app(tmp_path) + + tester = run(app, "--driver oracle") + + assert tester.status_code == 1 + assert "Unsupported driver 'oracle'" in tester.io.fetch_error() + assert not (app / "config").exists() + + +def test_missing_bootstrap_prints_manual_steps(tmp_path): + tester = run(tmp_path) + + assert tester.status_code == 1 + assert (tmp_path / "config/database.py").exists() + error = tester.io.fetch_error() + assert "Could not find Application(providers=[...]) in bootstrap/application.py" in error + assert "from fastapi_startkit.masoniteorm import DatabaseProvider" in error + + +def test_single_line_providers_list(): + source = "from fastapi_startkit import Application\n\napp = Application(providers=[LogProvider, Other])\n" + + updated = register_database_provider(source) + + assert updated is not None + assert "Application(providers=[LogProvider, Other, (DatabaseProvider, DatabaseConfig)])" in updated + + +def test_single_line_providers_list_with_trailing_comma_and_empty_list(): + assert "providers=[A, (DatabaseProvider, DatabaseConfig)]" in ( + register_database_provider("app = Application(providers=[A,])\n") or "" + ) + assert "providers=[(DatabaseProvider, DatabaseConfig)]" in ( + register_database_provider("app = Application(providers=[])\n") or "" + ) + + +def test_adds_providers_keyword_when_missing(): + source = "from fastapi_startkit import Application\n\napp = Application(\n base_path=None\n)\n" + + updated = register_database_provider(source) + + assert updated is not None + assert " base_path=None,\n providers=[(DatabaseProvider, DatabaseConfig)],\n)" in updated + assert providers_of(updated) == ["(DatabaseProvider, DatabaseConfig)"] + + +def test_adds_imports_to_file_without_imports(): + updated = register_database_provider("app = fastapi_startkit.Application()\n") + + assert updated is not None + assert updated.startswith("from fastapi_startkit.masoniteorm import DatabaseProvider\n") + assert "Application(providers=[(DatabaseProvider, DatabaseConfig)])" in updated + + +def test_keeps_existing_imports(): + source = ( + "from fastapi_startkit.masoniteorm.providers import DatabaseProvider\n" + "from config.database import DatabaseConfig\n" + "app = Application(providers=[])\n" + ) + + updated = register_database_provider(source) + + assert updated is not None + assert updated.count("import DatabaseProvider") == 1 + assert updated.count("import DatabaseConfig") == 1 + + +def test_unsupported_bootstrap_shapes_return_none(): + assert register_database_provider("app = object()\n") is None + assert register_database_provider("app = Application(providers=PROVIDERS)\n") is None + + +def test_edited_bootstrap_compiles(tmp_path): + app = make_app(tmp_path) + + run(app) + + compile((app / "bootstrap/application.py").read_text(), "application.py", "exec") + + +def test_app_provider_registers_command(): + application = SimpleNamespace(commands=[]) + application.add_commands = application.commands.extend + + AppProvider(application).boot() # type: ignore[arg-type] + + assert DatabaseInstallCommand in application.commands