Repository navigation
Add database:install console command #323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # 041. InstallCommand base class and database:install | ||
|
|
||
| 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. | ||
|
|
||
| Other packages (cache, broadcasting, storage, ...) need the same steps: publish a config file and register their provider in `bootstrap/application.py`. If the AST editing lives inside the database command, each package would have to copy it. | ||
|
|
||
| ## 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. | ||
| 4. Option 3, with the bootstrap edit as a module-level function that each command calls directly. This shares the logic, but every command would repeat the `--force` option, the publish-or-skip handling, and the error/manual-steps output. | ||
| 5. Option 3, with a reusable `InstallCommand` base class. | ||
|
|
||
| ## Decision | ||
|
|
||
| Option 5. `fastapi_startkit/console/install_command.py` defines `InstallCommand(Command)` with a fluent class builder: | ||
|
|
||
| ```python | ||
| CacheInstallCommand = ( | ||
| InstallCommand.register("cache:install", "Install the cache.") | ||
| .copy(files("my_package").joinpath("stubs/cache.py"), "config/cache.py") | ||
| .add_provider(CacheProvider, "config.cache.CacheConfig") | ||
| ) | ||
| ``` | ||
|
|
||
| - `register(name, description)`, `copy(stub, destination)` and `add_provider(provider, config=None)` are classmethods. Each one returns a new subclass with the added attributes and leaves the receiver unchanged. The result is a command class, which is what `Provider.commands([...])` expects. | ||
| - `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. | ||
|
|
||
| `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]`: | ||
|
|
||
| - 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. `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. | ||
| - The full suite passes with coverage of at least 80%. | ||
| - Manual run, twice, against an app generated by `fastapi-startkit init --database none`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| from importlib.resources import files | ||
| 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 | ||
|
|
||
|
|
||
| class DatabaseInstallCommand(InstallCommand): | ||
| name = "database:install" | ||
| description = "Publish config/database.py and register DatabaseProvider in bootstrap/application.py." | ||
|
|
||
| providers = (ProviderEntry.of("fastapi_startkit.masoniteorm.DatabaseProvider", "config.database.DatabaseConfig"),) | ||
|
|
||
| options: ClassVar[list[Option]] = [ | ||
| *InstallCommand.options, | ||
| option( | ||
| "driver", | ||
| "d", | ||
| description=f"Database driver: {', '.join(DRIVER_CLASSES)}.", | ||
| flag=False, | ||
| default="sqlite", | ||
| ), | ||
| ] | ||
|
|
||
| def publish(self) -> int: | ||
| driver = self.option("driver") | ||
| if driver not in DRIVER_CLASSES: | ||
| self.line_error(f"<error>Unsupported driver '{driver}'. Use one of: {', '.join(DRIVER_CLASSES)}.</error>") | ||
| return 1 | ||
|
|
||
| stub = files("fastapi_startkit.installer").joinpath("stubs/database/config.py.stub").read_text(encoding="utf-8") | ||
| self.publish_file( | ||
| "config/database.py", | ||
| Template(stub).substitute(driver=driver, driver_class=DRIVER_CLASSES[driver]), | ||
| ) | ||
| return 0 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| import ast | ||
| from collections.abc import Mapping, Sequence | ||
| from dataclasses import dataclass | ||
| from importlib.resources.abc import Traversable | ||
| from os import PathLike | ||
| from pathlib import Path | ||
| from typing import ClassVar, Self, cast | ||
|
|
||
| from cleo.helpers import option | ||
| from cleo.io.inputs.option import Option | ||
|
|
||
| from fastapi_startkit.console.command import Command | ||
|
|
||
| BOOTSTRAP_PATH = "bootstrap/application.py" | ||
|
|
||
| Insert = tuple[int, str] | ||
| Stub = str | PathLike[str] | Traversable | ||
|
|
||
|
|
||
| def dotted_path(reference: type | str) -> str: | ||
| return reference if isinstance(reference, str) else f"{reference.__module__}.{reference.__qualname__}" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class ProviderEntry: | ||
| provider: str | ||
| config: str | None = None | ||
|
|
||
| @classmethod | ||
| def of(cls, provider: type | str, config: type | str | None = None) -> "ProviderEntry": | ||
| return cls(dotted_path(provider), dotted_path(config) if config is not None else None) | ||
|
|
||
| @property | ||
| def name(self) -> str: | ||
| return self.provider.rpartition(".")[2] | ||
|
|
||
| @property | ||
| def entry(self) -> str: | ||
| if self.config is None: | ||
| return self.name | ||
| return f"({self.name}, {self.config.rpartition('.')[2]})" | ||
|
|
||
| @property | ||
| def imports(self) -> dict[str, str]: | ||
| references = [self.provider] if self.config is None else [self.provider, self.config] | ||
| return { | ||
| name: f"from {module} import {name}" | ||
| for module, _, name in (reference.rpartition(".") for reference in references) | ||
| } | ||
|
|
||
|
|
||
| class InstallCommand(Command): | ||
| stubs: ClassVar[tuple[tuple[Stub, str], ...]] = () | ||
| providers: ClassVar[tuple[ProviderEntry, ...]] = () | ||
|
|
||
| options: ClassVar[list[Option]] = [option("force", "f", description="Overwrite files that already exist.")] | ||
|
|
||
| @classmethod | ||
| def register(cls, name: str, description: str = "") -> type[Self]: | ||
| return cls.extend(name=name, description=description) | ||
|
|
||
| @classmethod | ||
| def copy(cls, stub: Stub, destination: str) -> type[Self]: | ||
| return cls.extend(stubs=(*cls.stubs, (stub, destination))) | ||
|
|
||
| @classmethod | ||
| def add_provider(cls, provider: type | str, config: type | str | None = None) -> type[Self]: | ||
| return cls.extend(providers=(*cls.providers, ProviderEntry.of(provider, config))) | ||
|
|
||
| @classmethod | ||
| def extend(cls, **attributes: object) -> type[Self]: | ||
| return cast(type[Self], type(cls.__name__, (cls,), attributes)) | ||
|
|
||
| def handle(self) -> int: | ||
| return self.publish() or self.register_providers() | ||
|
|
||
| def publish(self) -> int: | ||
| for stub, destination in self.stubs: | ||
| source = stub if isinstance(stub, Traversable) else Path(stub) | ||
| self.publish_file(destination, source.read_text(encoding="utf-8")) | ||
| return 0 | ||
|
|
||
| @property | ||
| def base_path(self) -> Path: | ||
| return Path(self.container.base_path) | ||
|
|
||
| def publish_file(self, relative_path: str, content: str) -> None: | ||
| path = self.base_path / relative_path | ||
| if path.exists() and not self.option("force"): | ||
| self.line(f"<comment>{relative_path} already exists, skipped. Use --force to overwrite it.</comment>") | ||
| return | ||
|
|
||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| path.write_text(content, encoding="utf-8") | ||
| self.line(f"Created <info>{relative_path}</info>") | ||
|
|
||
| def register_providers(self) -> int: | ||
| return max((self.register_provider(provider) for provider in self.providers), default=0) | ||
|
|
||
| def register_provider(self, provider: ProviderEntry) -> int: | ||
| path = self.base_path / BOOTSTRAP_PATH | ||
| source = path.read_text(encoding="utf-8") if path.exists() else None | ||
| updated = ( | ||
| register_provider(source, provider.name, provider.entry, provider.imports) if source is not None else None | ||
| ) | ||
|
|
||
| if updated is None: | ||
| self.line_error(f"<error>Could not find Application(providers=[...]) in {BOOTSTRAP_PATH}.</error>") | ||
| self.line_error(f"Register {provider.name} manually:") | ||
| for statement in provider.imports.values(): | ||
| self.line_error(f" {statement}") | ||
| self.line_error(f" providers=[..., {provider.entry}]") | ||
| return 1 | ||
|
|
||
| if updated == source: | ||
| self.line(f"<comment>{provider.name} is already registered in {BOOTSTRAP_PATH}.</comment>") | ||
| return 0 | ||
|
|
||
| path.write_text(updated, encoding="utf-8") | ||
| self.line(f"Registered <info>{provider.name}</info> in <info>{BOOTSTRAP_PATH}</info>") | ||
| return 0 | ||
|
|
||
|
|
||
| def register_provider(source: str, provider: str, entry: str, imports: Mapping[str, 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: | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking:
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in #324. Instead of falling back to manual steps, a positional providers list (the third positional argument) is now edited in place. |
||
| inserts = append_item(source, lines, call, [*call.args, *call.keywords], f"providers=[{entry}]") | ||
| elif not isinstance(keyword.value, ast.List): | ||
| return None | ||
| elif any(isinstance(node, ast.Name) and node.id == provider for node in ast.walk(keyword.value)): | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: only bare
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in #324. |
||
| return source | ||
| else: | ||
| inserts = append_item(source, lines, keyword.value, keyword.value.elts, entry) | ||
|
|
||
| inserts.extend(import_inserts(tree, lines, imports)) | ||
| 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], imports: Mapping[str, str]) -> list[Insert]: | ||
| existing = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))] | ||
| imported = {alias.asname or alias.name for node in existing for alias in node.names} | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: imports are deduplicated by bound name only. If
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in #324. An existing import is now compared by its full path, or by object identity when both modules are already loaded, so re-exports still match. If the name is bound to a different module (for example the base masoniteorm |
||
| missing = [statement for name, statement in imports.items() if name not in imported] | ||
| if not missing: | ||
| return [] | ||
| if not existing: | ||
| return [(0, "\n".join(missing) + "\n\n")] | ||
|
|
||
| last = existing[-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()) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking: if the app does not have the
fastapi-startkit[database]extra, the rendered config andDatabaseProviderimport SQLAlchemy at boot, so everyartisaninvocation fails afterwards. Suggest afind_spec("sqlalchemy")check with a hint to runuv add 'fastapi-startkit[database]', plus a short next-steps message (DB_* env vars,databases/migrations/), sinceinit --databasecreates those and this command does not.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in #324, where the command is also renamed to
db:install. After a successful run it prints next steps. Whenfind_spec("sqlalchemy")finds nothing, these start withuv add "fastapi-startkit[database,<driver>]". They also list theDB_*.env entries, taken from the installer'sdefine_databasesection,mkdir -p databases/migrationswhen the directory is missing, anddb:make:migration/db:migrate. Regression tests:test_hints_to_install_database_extra_when_sqlalchemy_is_missing,test_prints_next_steps_after_install,test_next_steps_use_driver_environmentandtest_next_steps_skip_existing_migrations_directory.