From bdca68b1048b457802fe00621f92518ec2dae83c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 22 Oct 2026 14:28:51 -0700 Subject: [PATCH 1/2] 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 From 9119d3211608188ab5ea7951baa0c13b17dc7112 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 27 Oct 2026 03:06:00 -0700 Subject: [PATCH 2/2] Extract InstallCommand base with fluent builder InstallCommand.register(name).copy(stub, dest).add_provider(Provider, Config) builds install command classes for any package. Providers and configs accept classes or dotted strings; the AST bootstrap editing, --force and publish-or-skip handling are shared. database:install now subclasses it. Co-Authored-By: Claude Opus 5.5 --- docs/adr/041-database-install-command.md | 29 ++- docs/adr/AGENTS.md | 2 +- .../console/database_install_command.py | 141 +--------- .../console/install_command.py | 200 +++++++++++++++ .../console/test_database_install_command.py | 86 +------ .../tests/console/test_install_command.py | 241 ++++++++++++++++++ 6 files changed, 493 insertions(+), 206 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/console/install_command.py create mode 100644 fastapi_startkit/tests/console/test_install_command.py diff --git a/docs/adr/041-database-install-command.md b/docs/adr/041-database-install-command.md index d75731cc..62ef6f90 100644 --- a/docs/adr/041-database-install-command.md +++ b/docs/adr/041-database-install-command.md @@ -1,4 +1,4 @@ -# 041. database:install command +# 041. InstallCommand base class and database:install Date: 2026-10-22 @@ -6,24 +6,45 @@ Date: 2026-10-22 `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 3. `uv run artisan database:install [--driver sqlite|postgres|mysql] [--force]`: +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. Text is inserted at AST-reported positions rather than regenerated with `ast.unparse`, so the user's formatting and comments are kept. +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 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. +- `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`. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index ef68f0c2..471a18b2 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -36,4 +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. | +| [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. | diff --git a/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py b/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py index ad71492e..5e517dc6 100644 --- a/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py +++ b/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py @@ -1,30 +1,22 @@ -import ast -from collections.abc import Sequence from importlib.resources import files -from pathlib import Path from string import Template +from typing import ClassVar from cleo.helpers import option +from cleo.io.inputs.option import Option -from fastapi_startkit.console.command import Command +from fastapi_startkit.console.install_command import InstallCommand, ProviderEntry 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): +class DatabaseInstallCommand(InstallCommand): name = "database:install" description = "Publish config/database.py and register DatabaseProvider in bootstrap/application.py." - options = [ + providers = (ProviderEntry.of("fastapi_startkit.masoniteorm.DatabaseProvider", "config.database.DatabaseConfig"),) + + options: ClassVar[list[Option]] = [ + *InstallCommand.options, option( "driver", "d", @@ -32,124 +24,17 @@ class DatabaseInstallCommand(Command): flag=False, default="sqlite", ), - option("force", "f", description=f"Overwrite an existing {CONFIG_PATH}."), ] - def handle(self) -> int: + def publish(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}") + self.publish_file( + "config/database.py", + Template(stub).substitute(driver=driver, driver_class=DRIVER_CLASSES[driver]), + ) 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/console/install_command.py b/fastapi_startkit/src/fastapi_startkit/console/install_command.py new file mode 100644 index 00000000..aaf6f22c --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/console/install_command.py @@ -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"{relative_path} already exists, skipped. Use --force to overwrite it.") + return + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + self.line(f"Created {relative_path}") + + 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"Could not find Application(providers=[...]) in {BOOTSTRAP_PATH}.") + 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"{provider.name} is already registered in {BOOTSTRAP_PATH}.") + return 0 + + path.write_text(updated, encoding="utf-8") + self.line(f"Registered {provider.name} in {BOOTSTRAP_PATH}") + 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: + 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)): + 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} + 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()) diff --git a/fastapi_startkit/tests/console/test_database_install_command.py b/fastapi_startkit/tests/console/test_database_install_command.py index 72f80caa..3a29d6d5 100644 --- a/fastapi_startkit/tests/console/test_database_install_command.py +++ b/fastapi_startkit/tests/console/test_database_install_command.py @@ -4,10 +4,11 @@ from cleo.testers.command_tester import CommandTester -from fastapi_startkit.console.database_install_command import DatabaseInstallCommand, register_database_provider +from fastapi_startkit.console.database_install_command import DatabaseInstallCommand +from fastapi_startkit.console.install_command import InstallCommand from fastapi_startkit.foundation.app_provider import AppProvider -MULTILINE_BOOTSTRAP = """from pathlib import Path +BOOTSTRAP = """from pathlib import Path from fastapi_startkit import Application from fastapi_startkit.logging import LogProvider @@ -32,9 +33,9 @@ def run(base_path: Path, args: str = "") -> CommandTester: return tester -def make_app(tmp_path: Path, bootstrap: str = MULTILINE_BOOTSTRAP) -> Path: +def make_app(tmp_path: Path) -> Path: (tmp_path / "bootstrap").mkdir() - (tmp_path / "bootstrap/application.py").write_text(bootstrap) + (tmp_path / "bootstrap/application.py").write_text(BOOTSTRAP) return tmp_path @@ -46,6 +47,10 @@ def providers_of(source: str) -> list[str]: raise AssertionError("providers keyword not found") +def test_is_an_install_command(): + assert issubclass(DatabaseInstallCommand, InstallCommand) + + def test_installs_config_and_registers_provider(tmp_path): app = make_app(tmp_path) @@ -57,14 +62,12 @@ def test_installs_config_and_registers_provider(tmp_path): 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 + compile(bootstrap, "application.py", "exec") output = tester.io.fetch_output() assert "Created config/database.py" in output assert "Registered DatabaseProvider in bootstrap/application.py" in output @@ -99,12 +102,14 @@ def test_force_overwrites_existing_config(tmp_path): def test_rejects_unknown_driver(tmp_path): app = make_app(tmp_path) + bootstrap = (app / "bootstrap/application.py").read_text() tester = run(app, "--driver oracle") assert tester.status_code == 1 assert "Unsupported driver 'oracle'" in tester.io.fetch_error() assert not (app / "config").exists() + assert (app / "bootstrap/application.py").read_text() == bootstrap def test_missing_bootstrap_prints_manual_steps(tmp_path): @@ -112,72 +117,7 @@ def test_missing_bootstrap_prints_manual_steps(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") + assert "from fastapi_startkit.masoniteorm import DatabaseProvider" in tester.io.fetch_error() def test_app_provider_registers_command(): diff --git a/fastapi_startkit/tests/console/test_install_command.py b/fastapi_startkit/tests/console/test_install_command.py new file mode 100644 index 00000000..0137607d --- /dev/null +++ b/fastapi_startkit/tests/console/test_install_command.py @@ -0,0 +1,241 @@ +import ast +from importlib.resources import files +from pathlib import Path +from types import SimpleNamespace + +from cleo.testers.command_tester import CommandTester + +from fastapi_startkit.console.install_command import InstallCommand, ProviderEntry, register_provider +from fastapi_startkit.logging import LogProvider + +IMPORTS = { + "CacheProvider": "from fastapi_startkit.cache import CacheProvider", + "CacheConfig": "from config.cache import CacheConfig", +} + +BOOTSTRAP = """from fastapi_startkit import Application +from config.logging import LoggingConfig + +app = Application( + providers=[ + (LogProvider, LoggingConfig) # logging + ], +) +""" + + +def cache_install(tmp_path: Path) -> type[InstallCommand]: + stub = tmp_path / "cache.py.stub" + stub.write_text("CACHE = True\n") + return ( + InstallCommand.register("cache:install", "Install the cache.") + .copy(stub, "config/cache.py") + .add_provider("fastapi_startkit.cache.CacheProvider", "config.cache.CacheConfig") + ) + + +def provider_only_install() -> type[InstallCommand]: + return InstallCommand.register("broadcast:install").add_provider("fastapi_startkit.broadcasting.BroadcastProvider") + + +def run(command_class: type[InstallCommand], base_path: Path, args: str = "") -> CommandTester: + command = command_class() + 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) -> Path: + (tmp_path / "bootstrap").mkdir() + (tmp_path / "bootstrap/application.py").write_text(BOOTSTRAP) + return tmp_path + + +def register(source: str) -> str | None: + return register_provider(source, "CacheProvider", "(CacheProvider, CacheConfig)", IMPORTS) + + +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_subclass_publishes_and_registers(tmp_path): + app = make_app(tmp_path) + + tester = run(cache_install(tmp_path), app) + + assert tester.status_code == 0 + assert (app / "config/cache.py").read_text() == "CACHE = True\n" + bootstrap = (app / "bootstrap/application.py").read_text() + assert " (LogProvider, LoggingConfig), # logging\n (CacheProvider, CacheConfig),\n" in bootstrap + assert ( + "from config.logging import LoggingConfig\n" + "from fastapi_startkit.cache import CacheProvider\n" + "from config.cache import CacheConfig\n" + ) in bootstrap + output = tester.io.fetch_output() + assert "Created config/cache.py" in output + assert "Registered CacheProvider in bootstrap/application.py" in output + + +def test_second_run_changes_nothing(tmp_path): + app = make_app(tmp_path) + run(cache_install(tmp_path), app) + bootstrap = (app / "bootstrap/application.py").read_text() + + tester = run(cache_install(tmp_path), app) + + assert tester.status_code == 0 + assert (app / "bootstrap/application.py").read_text() == bootstrap + output = tester.io.fetch_output() + assert "config/cache.py already exists, skipped" in output + assert "CacheProvider is already registered" in output + + +def test_fluent_chain_builds_independent_command_classes(): + command = InstallCommand.register("log:install", "Install logging.").add_provider( + LogProvider, "config.logging.LoggingConfig" + ) + + assert issubclass(command, InstallCommand) + assert command.name == "log:install" + assert command.description == "Install logging." + assert InstallCommand.providers == () + assert command.providers == ( + ProviderEntry(f"{LogProvider.__module__}.LogProvider", "config.logging.LoggingConfig"), + ) + + +def test_provider_entry_from_class_derives_entry_and_imports(): + entry = ProviderEntry.of(LogProvider, "config.logging.LoggingConfig") + + assert entry.name == "LogProvider" + assert entry.entry == "(LogProvider, LoggingConfig)" + assert entry.imports == { + "LogProvider": f"from {LogProvider.__module__} import LogProvider", + "LoggingConfig": "from config.logging import LoggingConfig", + } + + +def test_registers_multiple_providers_and_copies_traversable_stub(tmp_path): + app = make_app(tmp_path) + stub = files("fastapi_startkit.installer").joinpath("stubs/database/config.py.stub") + command = ( + InstallCommand.register("multi:install") + .copy(stub, "config/raw.py") + .add_provider("package.AProvider") + .add_provider("package.BProvider", "config.b.BConfig") + ) + + tester = run(command, app) + + assert tester.status_code == 0 + assert (app / "config/raw.py").read_text() == stub.read_text() + assert providers_of((app / "bootstrap/application.py").read_text()) == [ + "(LogProvider, LoggingConfig)", + "AProvider", + "(BProvider, BConfig)", + ] + + +def test_force_overwrites_published_file(tmp_path): + app = make_app(tmp_path) + (app / "config").mkdir() + (app / "config/cache.py").write_text("OLD = True\n") + + run(cache_install(tmp_path), app, "--force") + + assert (app / "config/cache.py").read_text() == "CACHE = True\n" + + +def test_provider_without_config_and_without_publish(tmp_path): + app = make_app(tmp_path) + + tester = run(provider_only_install(), app) + + assert tester.status_code == 0 + assert providers_of((app / "bootstrap/application.py").read_text()) == [ + "(LogProvider, LoggingConfig)", + "BroadcastProvider", + ] + assert not (app / "config").exists() + + +def test_missing_bootstrap_prints_manual_steps(tmp_path): + tester = run(cache_install(tmp_path), tmp_path) + + assert tester.status_code == 1 + assert (tmp_path / "config/cache.py").exists() + error = tester.io.fetch_error() + assert "Could not find Application(providers=[...]) in bootstrap/application.py" in error + assert "from fastapi_startkit.cache import CacheProvider" in error + assert "Register CacheProvider manually:" in error + assert "providers=[..., (CacheProvider, CacheConfig)]" in error + + +def test_single_line_providers_list(): + updated = register( + "from fastapi_startkit import Application\n\napp = Application(providers=[LogProvider, Other])\n" + ) + + assert updated is not None + assert "Application(providers=[LogProvider, Other, (CacheProvider, CacheConfig)])" in updated + + +def test_single_line_providers_list_with_trailing_comma_and_empty_list(): + assert "providers=[A, (CacheProvider, CacheConfig)]" in (register("app = Application(providers=[A,])\n") or "") + assert "providers=[(CacheProvider, CacheConfig)]" in (register("app = Application(providers=[])\n") or "") + + +def test_adds_providers_keyword_when_missing(): + updated = register("from fastapi_startkit import Application\n\napp = Application(\n base_path=None\n)\n") + + assert updated is not None + assert " base_path=None,\n providers=[(CacheProvider, CacheConfig)],\n)" in updated + assert providers_of(updated) == ["(CacheProvider, CacheConfig)"] + + +def test_adds_imports_to_file_without_imports(): + updated = register("app = fastapi_startkit.Application()\n") + + assert updated is not None + assert updated.startswith("from fastapi_startkit.cache import CacheProvider\n") + assert "Application(providers=[(CacheProvider, CacheConfig)])" in updated + + +def test_keeps_existing_imports(): + source = ( + "from fastapi_startkit.cache.providers import CacheProvider\n" + "from config.cache import CacheConfig\n" + "app = Application(providers=[])\n" + ) + + updated = register(source) + + assert updated is not None + assert updated.count("import CacheProvider") == 1 + assert updated.count("import CacheConfig") == 1 + + +def test_already_registered_returns_source_unchanged(): + source = "app = Application(providers=[(CacheProvider, CacheConfig)])\n" + + assert register(source) == source + + +def test_unsupported_bootstrap_shapes_return_none(): + assert register("app = object()\n") is None + assert register("app = Application(providers=PROVIDERS)\n") is None + + +def test_edited_bootstrap_compiles(tmp_path): + app = make_app(tmp_path) + + run(cache_install(tmp_path), app) + + compile((app / "bootstrap/application.py").read_text(), "application.py", "exec")