diff --git a/docs/adr/041-database-install-command.md b/docs/adr/041-database-install-command.md index 62ef6f90..945983c5 100644 --- a/docs/adr/041-database-install-command.md +++ b/docs/adr/041-database-install-command.md @@ -1,4 +1,4 @@ -# 041. InstallCommand base class and database:install +# 041. InstallCommand base class and db:install Date: 2026-10-22 @@ -32,19 +32,22 @@ CacheInstallCommand = ( - `add_provider` takes classes or dotted strings. A class is converted to `module.QualName`. A string is needed when the class lives in the user's app (`config.database.DatabaseConfig` does not exist when the framework loads), or when importing it would load an optional extra. Each `ProviderEntry` derives the inserted entry (`(Provider, Config)`, or a bare `Provider` when there is no config) and the `from module import Name` statements. - `copy` accepts a path or an `importlib.resources` `Traversable` and copies the stub verbatim. - `handle()` runs `publish()` and then registers every provider. The default `publish()` copies each stub. A subclass that needs options or templating overrides `publish()` and calls `publish_file(relative_path, content)`. `publish_file` creates parent directories and skips an existing file unless `--force` is passed. `--force` is declared on the base class, and subclasses add their own options with `options = [*InstallCommand.options, ...]`. -- `register_provider(source, provider, entry, imports)` is a pure function that returns the edited source, the unchanged source if the provider is already registered, or `None` if there is no `Application(providers=[...])` to edit. +- `register_provider(source, provider, entry, imports)` is a pure function. `imports` maps each name to its module. It returns the edited source, or the unchanged source if the provider is already registered. It raises `RegistrationError` with a reason when it cannot edit the file safely. The command prints that reason and the manual steps, and returns 1. -`DatabaseInstallCommand` is a regular subclass, because it has a `--driver` option and renders the stub template. It declares `providers = (ProviderEntry.of("fastapi_startkit.masoniteorm.DatabaseProvider", "config.database.DatabaseConfig"),)`. `uv run artisan database:install [--driver sqlite|postgres|mysql] [--force]`: +`DbInstallCommand` is a regular subclass, because it has a `--driver` option and renders the stub template. It declares `providers = (ProviderEntry.of("fastapi_startkit.masoniteorm.DatabaseProvider", "config.database.DatabaseConfig"),)`. The command is named `db:install` to match the other ORM commands (`db:migrate`, `db:seed`, `db:make:*`). `uv run artisan db:install [--driver sqlite|postgres|mysql] [--force]`: -- Renders `config/database.py` from the installer's `database/config.py.stub`, so it matches what `fastapi-startkit init` generates. `config/` is created if missing. If the file already exists it is skipped with a message, unless `--force` is passed. -- Parses `bootstrap/application.py` with `ast` and finds the `Application(...)` call. If its `providers` list already names `DatabaseProvider`, nothing is changed. Otherwise `(DatabaseProvider, DatabaseConfig)` is appended to the list, following its layout: a new line for multi-line lists, inline for single-line lists. If there is no `providers=` keyword, one is added. Missing `from fastapi_startkit.masoniteorm import DatabaseProvider` and `from config.database import DatabaseConfig` imports are inserted after the last top-level import. -- If `bootstrap/application.py` or the `Application(...)` call is missing, it prints the lines to add by hand and returns 1. The config file is still written. +- Renders `config/database.py` from the installer's `database/config.py.stub`, so it matches the config file that `fastapi-startkit init` generates. `config/` is created if missing. If the file already exists it is skipped with a message, unless `--force` is passed. +- Parses `bootstrap/application.py` with `ast` and finds the `Application(...)` call. The providers list is the `providers=` keyword or, because `Application(base_path, env, providers)` also accepts it positionally, the third positional argument. Appending `providers=` next to a positional list would raise `TypeError` at boot, so a positional list is edited in place. If the call uses `*args`, or the providers value is not a list literal, the command stops. +- The provider counts as already registered when the list names it as a bare name (`DatabaseProvider`) or as an attribute (`orm.DatabaseProvider`, `fastapi_startkit.masoniteorm.DatabaseProvider`). Nothing is changed in that case. Otherwise `(DatabaseProvider, DatabaseConfig)` is appended to the list, following its layout: a new line for multi-line lists, inline for single-line lists. If there is no providers argument, `providers=` is added. +- Missing `from fastapi_startkit.masoniteorm import DatabaseProvider` and `from config.database import DatabaseConfig` imports are inserted after the last top-level import. A name that is already imported is compared by where it comes from, not only by name. The import is reused when its full path matches, or when both paths resolve to the same object in modules that are already loaded (for example a re-export). Nothing new is imported to check this. If the name is bound to something else, such as the base `fastapi_startkit.masoniteorm.config.database.DatabaseConfig`, the command stops with `DatabaseConfig is already imported as , not config.database.DatabaseConfig.` and prints the manual steps. Relative imports count as a different path. We considered importing under an alias (`DatabaseConfig as AppDatabaseConfig`), but rejected it: it adds a second name for the same concept to the user's bootstrap file and hides a likely mistake. Stopping is explicit, and the fix is a one-line edit. +- If `bootstrap/application.py` or the `Application(...)` call is missing, or the file cannot be edited safely, it prints the reason and the lines to add by hand, and returns 1. The config file is still written. +- After a successful run it prints next steps. The `DB_*` `.env` entries and the extras come from the installer's `define_database(driver, project_name)` section, so they stay the same as `init --database`. If `importlib.util.find_spec("sqlalchemy")` finds nothing, it first prints `uv add "fastapi-startkit[database,]"`, because the generated config and `DatabaseProvider` import SQLAlchemy and every `artisan` call fails without it. It then prints `mkdir -p databases/migrations` if that directory is missing, because `db:make:migration` does not create it, and points to `db:make:migration` and `db:migrate`. The command does not create directories or seeders, and it does not edit `.env`, so that it only writes the config file and the bootstrap edit. The command lives in `fastapi_startkit/console/` rather than `masoniteorm/commands/`, because importing `masoniteorm.commands` loads SQLAlchemy, which is an optional extra. `install_command.py` imports only `ast`, cleo and `Command`, so packages with optional extras can subclass it without loading anything extra. Text is inserted at AST-reported positions rather than regenerated with `ast.unparse`, so the user's formatting and comments are kept. ## Validation -- `tests/console/test_install_command.py` tests commands built with the fluent API: a copied stub with a tuple entry, a provider-only command with a bare entry, several providers with a `Traversable` stub, class references converted to imports, and chain calls that do not mutate the base class. It also tests `register_provider` directly on single-line, multi-line, empty and missing `providers` lists, files with no imports, existing imports, already-registered providers and unsupported shapes. -- `tests/console/test_database_install_command.py` runs `database:install` against a temporary app directory. It covers a fresh install, a second run that changes nothing, an existing config with and without `--force`, a missing `config/` directory, a missing bootstrap file, an unsupported driver, and loading the edited bootstrap file. +- `tests/console/test_install_command.py` tests commands built with the fluent API: a copied stub with a tuple entry, a provider-only command with a bare entry, several providers with a `Traversable` stub, class references converted to imports, and chain calls that do not mutate the base class. It also tests `register_provider` directly on single-line, multi-line, empty, missing and positional `providers` lists, files with no imports, existing imports, already-registered providers (bare names and attributes), names imported from another module, aliased or relative imports, re-exported classes and unsupported shapes. +- `tests/console/test_db_install_command.py` runs `db:install` against a temporary app directory. It covers a fresh install, a second run that changes nothing, an existing config with and without `--force`, a missing `config/` directory, a missing bootstrap file, an unsupported driver, a conflicting `DatabaseConfig` import, the next steps for each driver and an existing migrations directory, the hint when SQLAlchemy is missing, and loading the edited bootstrap file. - The full suite passes with coverage of at least 80%. - Manual run, twice, against an app generated by `fastapi-startkit init --database none`. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 3aea549e..1a2970f5 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -37,4 +37,4 @@ Read this index first, then open the records relevant to the change. Before impl | [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. | | [037](037-http-exception-status-and-fastapi-title.md) | 2026-10-25 | HTTP exceptions keep their status; FastAPI title from config | `HTTPExceptionHandler` rendered every `HTTPException` and `ModelNotFoundException` as 500. Starlette `HTTPException` now goes through FastAPI's `http_exception_handler`, which keeps the status, the `detail` and the headers. A new dependency-free `fastapi_startkit.exceptions.HttpException` base (`get_status`/`get_response`/`get_headers`) is subclassed by `ModelNotFoundException`, `RouteNotFoundException`, `MethodNotAllowedException`, `AuthorizationException` and `ThrottleRequestsException` (429 with `Retry-After`), and is registered as its own FastAPI handler so it never reaches the re-raising `ServerErrorMiddleware`. `HTTPExceptionHandler.report` skips 4xx HTTP exceptions and still reports 5xx. `FastAPIConfig.title` reads `APP_NAME` (default `FastAPI Startkit`). `Application.__init__` no longer discards the FastAPI instance a provider creates in `register`. | | [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | -| [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and database:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It does nothing if the provider is already listed, and prints manual steps and exits 1 if the bootstrap file is missing. `database:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. Both live in `console/` so the optional SQLAlchemy extra is not imported. | +| [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. | diff --git a/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py b/fastapi_startkit/src/fastapi_startkit/console/db_install_command.py similarity index 51% rename from fastapi_startkit/src/fastapi_startkit/console/database_install_command.py rename to fastapi_startkit/src/fastapi_startkit/console/db_install_command.py index 5e517dc6..0e2eaebd 100644 --- a/fastapi_startkit/src/fastapi_startkit/console/database_install_command.py +++ b/fastapi_startkit/src/fastapi_startkit/console/db_install_command.py @@ -1,4 +1,5 @@ from importlib.resources import files +from importlib.util import find_spec from string import Template from typing import ClassVar @@ -6,11 +7,13 @@ from cleo.io.inputs.option import Option from fastapi_startkit.console.install_command import InstallCommand, ProviderEntry -from fastapi_startkit.installer.plan import DRIVER_CLASSES +from fastapi_startkit.installer.plan import DRIVER_CLASSES, define_database, project_name_for +MIGRATIONS_PATH = "databases/migrations" -class DatabaseInstallCommand(InstallCommand): - name = "database:install" + +class DbInstallCommand(InstallCommand): + name = "db:install" description = "Publish config/database.py and register DatabaseProvider in bootstrap/application.py." providers = (ProviderEntry.of("fastapi_startkit.masoniteorm.DatabaseProvider", "config.database.DatabaseConfig"),) @@ -26,6 +29,12 @@ class DatabaseInstallCommand(InstallCommand): ), ] + def handle(self) -> int: + status = super().handle() + if status == 0: + self.next_steps() + return status + def publish(self) -> int: driver = self.option("driver") if driver not in DRIVER_CLASSES: @@ -38,3 +47,19 @@ def publish(self) -> int: Template(stub).substitute(driver=driver, driver_class=DRIVER_CLASSES[driver]), ) return 0 + + def next_steps(self) -> None: + section = define_database(self.option("driver"), project_name_for(self.base_path)) + self.line("") + self.line("Next steps:") + if find_spec("sqlalchemy") is None: + self.line( + f' Install the database extra: uv add "fastapi-startkit[{",".join(section.extras)}]"' + ) + self.line(" Add to .env:") + for key, value in section.environment.items(): + self.line(f" {key}={value}") + if not (self.base_path / MIGRATIONS_PATH).is_dir(): + self.line(f" Create the migrations directory: mkdir -p {MIGRATIONS_PATH}") + self.line(" Create a migration: uv run python artisan db:make:migration create_users_table") + self.line(" Run migrations: uv run python artisan db:migrate") diff --git a/fastapi_startkit/src/fastapi_startkit/console/install_command.py b/fastapi_startkit/src/fastapi_startkit/console/install_command.py index aaf6f22c..16d3b5fa 100644 --- a/fastapi_startkit/src/fastapi_startkit/console/install_command.py +++ b/fastapi_startkit/src/fastapi_startkit/console/install_command.py @@ -1,4 +1,5 @@ import ast +import sys from collections.abc import Mapping, Sequence from dataclasses import dataclass from importlib.resources.abc import Traversable @@ -43,10 +44,11 @@ def entry(self) -> str: @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) - } + return {name: module for module, _, name in (reference.rpartition(".") for reference in references)} + + +class RegistrationError(Exception): + pass class InstallCommand(Command): @@ -99,16 +101,16 @@ def register_providers(self) -> int: 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}.") + try: + if not path.exists(): + raise RegistrationError(f"{BOOTSTRAP_PATH} does not exist.") + source = path.read_text(encoding="utf-8") + updated = register_provider(source, provider.name, provider.entry, provider.imports) + except RegistrationError as error: + self.line_error(f"Could not edit {BOOTSTRAP_PATH}: {error}") self.line_error(f"Register {provider.name} manually:") - for statement in provider.imports.values(): - self.line_error(f" {statement}") + for name, module in provider.imports.items(): + self.line_error(f" {import_statement(name, module)}") self.line_error(f" providers=[..., {provider.entry}]") return 1 @@ -121,23 +123,21 @@ def register_provider(self, provider: ProviderEntry) -> int: return 0 -def register_provider(source: str, provider: str, entry: str, imports: Mapping[str, str]) -> str | None: +def register_provider(source: str, provider: str, entry: str, imports: Mapping[str, str]) -> str: tree = ast.parse(source) call = find_application_call(tree) if call is None: - return None + raise RegistrationError("Could not find an Application(...) call.") - lines = source.splitlines(keepends=True) - keyword = next((keyword for keyword in call.keywords if keyword.arg == "providers"), None) + providers = providers_argument(call) + if providers is not None and is_registered(providers, provider): + return source - if keyword is None: + lines = source.splitlines(keepends=True) + if providers 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 = append_item(source, lines, providers, providers.elts, entry) inserts.extend(import_inserts(tree, lines, imports)) for position, text in sorted(inserts, key=lambda insert: insert[0], reverse=True): @@ -157,6 +157,25 @@ def find_application_call(tree: ast.Module) -> ast.Call | None: return None +def providers_argument(call: ast.Call) -> ast.List | None: + if any(isinstance(argument, ast.Starred) for argument in call.args): + raise RegistrationError("Application(...) is called with *args.") + + keyword = next((keyword for keyword in call.keywords if keyword.arg == "providers"), None) + value = keyword.value if keyword is not None else call.args[2] if len(call.args) > 2 else None + if value is not None and not isinstance(value, ast.List): + raise RegistrationError("providers is not a list literal.") + return value + + +def is_registered(providers: ast.List, provider: str) -> bool: + return any( + (isinstance(node, ast.Name) and node.id == provider) + or (isinstance(node, ast.Attribute) and node.attr == provider) + for node in ast.walk(providers) + ) + + def append_item( source: str, lines: list[str], node: ast.expr, items: Sequence[ast.expr | ast.keyword], text: str ) -> list[Insert]: @@ -184,8 +203,12 @@ def append_item( 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] + bound = bound_names(existing) + for name, module in imports.items(): + if name in bound and not same_object(bound[name], f"{module}.{name}"): + raise RegistrationError(f"{name} is already imported as {bound[name]}, not {module}.{name}.") + + missing = [import_statement(name, module) for name, module in imports.items() if name not in bound] if not missing: return [] if not existing: @@ -196,5 +219,32 @@ def import_inserts(tree: ast.Module, lines: list[str], imports: Mapping[str, str return [(offset(lines, last, 0) + len(lines[last - 1].rstrip("\r\n")), "\n" + "\n".join(missing))] +def bound_names(imports: list[ast.Import | ast.ImportFrom]) -> dict[str, str]: + bound: dict[str, str] = {} + for node in imports: + for alias in node.names: + if isinstance(node, ast.Import): + bound[alias.asname or alias.name.partition(".")[0]] = alias.name + else: + bound[alias.asname or alias.name] = f"{'.' * node.level}{node.module or ''}.{alias.name}" + return bound + + +def same_object(reference: str, expected: str) -> bool: + if reference == expected: + return True + resolved = [resolve(path) for path in (reference, expected)] + return resolved[0] is not None and resolved[0] is resolved[1] + + +def resolve(reference: str) -> object | None: + module, _, name = reference.rpartition(".") + return getattr(sys.modules.get(module), name, None) + + +def import_statement(name: str, module: str) -> str: + return f"from {module} import {name}" + + 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 69e0ac98..25d42f12 100644 --- a/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/foundation/app_provider.py @@ -1,4 +1,4 @@ -from fastapi_startkit.console.database_install_command import DatabaseInstallCommand +from fastapi_startkit.console.db_install_command import DbInstallCommand from fastapi_startkit.console.publish_command import PublishCommand from fastapi_startkit.support import Provider @@ -8,4 +8,4 @@ def register(self) -> None: pass def boot(self) -> None: - self.commands([PublishCommand, DatabaseInstallCommand]) + self.commands([PublishCommand, DbInstallCommand]) diff --git a/fastapi_startkit/tests/console/test_database_install_command.py b/fastapi_startkit/tests/console/test_db_install_command.py similarity index 57% rename from fastapi_startkit/tests/console/test_database_install_command.py rename to fastapi_startkit/tests/console/test_db_install_command.py index 3a29d6d5..04454502 100644 --- a/fastapi_startkit/tests/console/test_database_install_command.py +++ b/fastapi_startkit/tests/console/test_db_install_command.py @@ -2,9 +2,12 @@ from pathlib import Path from types import SimpleNamespace +import pytest from cleo.testers.command_tester import CommandTester -from fastapi_startkit.console.database_install_command import DatabaseInstallCommand +from fastapi_startkit.console import db_install_command + +from fastapi_startkit.console.db_install_command import DbInstallCommand from fastapi_startkit.console.install_command import InstallCommand from fastapi_startkit.foundation.app_provider import AppProvider @@ -26,7 +29,7 @@ def run(base_path: Path, args: str = "") -> CommandTester: - command = DatabaseInstallCommand() + command = DbInstallCommand() command.set_container(SimpleNamespace(base_path=base_path)) # type: ignore[arg-type] tester = CommandTester(command) tester.execute(args) @@ -34,7 +37,7 @@ def run(base_path: Path, args: str = "") -> CommandTester: def make_app(tmp_path: Path) -> Path: - (tmp_path / "bootstrap").mkdir() + (tmp_path / "bootstrap").mkdir(parents=True) (tmp_path / "bootstrap/application.py").write_text(BOOTSTRAP) return tmp_path @@ -47,8 +50,14 @@ def providers_of(source: str) -> list[str]: raise AssertionError("providers keyword not found") -def test_is_an_install_command(): - assert issubclass(DatabaseInstallCommand, InstallCommand) +@pytest.fixture(autouse=True) +def database_extra_installed(monkeypatch): + monkeypatch.setattr(db_install_command, "find_spec", lambda name: object()) + + +def test_is_an_install_command_named_db_install(): + assert issubclass(DbInstallCommand, InstallCommand) + assert DbInstallCommand.name == "db:install" def test_installs_config_and_registers_provider(tmp_path): @@ -126,4 +135,74 @@ def test_app_provider_registers_command(): AppProvider(application).boot() # type: ignore[arg-type] - assert DatabaseInstallCommand in application.commands + assert DbInstallCommand in application.commands + + +def test_prints_next_steps_after_install(tmp_path): + app = make_app(tmp_path) + + tester = run(app) + + output = tester.io.fetch_output() + assert "Next steps:" in output + assert "DB_CONNECTION=sqlite\n" in output + assert "DB_DATABASE=databases/database.sqlite\n" in output + assert "mkdir -p databases/migrations" in output + assert "uv run python artisan db:make:migration create_users_table" in output + assert "uv run python artisan db:migrate" in output + assert "uv add" not in output + + +def test_next_steps_skip_existing_migrations_directory(tmp_path): + app = make_app(tmp_path) + (app / "databases/migrations").mkdir(parents=True) + + assert "mkdir" not in run(app).io.fetch_output() + + +def test_next_steps_use_driver_environment(tmp_path): + app = make_app(tmp_path / "my-shop") + + output = run(app, "--driver postgres").io.fetch_output() + + assert "DB_CONNECTION=postgres\n" in output + assert "DB_PORT=5432\n" in output + assert "DB_DATABASE=my_shop\n" in output + assert "DB_USERNAME=postgres\n" in output + + +def test_hints_to_install_database_extra_when_sqlalchemy_is_missing(tmp_path, monkeypatch): + app = make_app(tmp_path) + looked_up: list[str] = [] + monkeypatch.setattr(db_install_command, "find_spec", lambda name: looked_up.append(name)) + + tester = run(app, "--driver mysql") + + assert tester.status_code == 0 + assert looked_up == ["sqlalchemy"] + assert 'uv add "fastapi-startkit[database,mysql]"' in tester.io.fetch_output() + + +def test_no_next_steps_when_registration_fails(tmp_path): + tester = run(tmp_path) + + assert "Next steps:" not in tester.io.fetch_output() + + +def test_does_not_register_when_database_config_is_imported_from_elsewhere(tmp_path): + app = make_app(tmp_path) + bootstrap = BOOTSTRAP.replace( + "from config.logging import LoggingConfig\n", + "from config.logging import LoggingConfig\n" + "from fastapi_startkit.masoniteorm.config.database import DatabaseConfig\n", + ) + (app / "bootstrap/application.py").write_text(bootstrap) + + tester = run(app) + + assert tester.status_code == 1 + assert (app / "bootstrap/application.py").read_text() == bootstrap + assert ( + "DatabaseConfig is already imported as fastapi_startkit.masoniteorm.config.database.DatabaseConfig, " + "not config.database.DatabaseConfig." + ) in tester.io.fetch_error() diff --git a/fastapi_startkit/tests/console/test_install_command.py b/fastapi_startkit/tests/console/test_install_command.py index 0137607d..d25faf96 100644 --- a/fastapi_startkit/tests/console/test_install_command.py +++ b/fastapi_startkit/tests/console/test_install_command.py @@ -1,17 +1,16 @@ import ast +import re from importlib.resources import files from pathlib import Path from types import SimpleNamespace +import pytest from cleo.testers.command_tester import CommandTester -from fastapi_startkit.console.install_command import InstallCommand, ProviderEntry, register_provider +from fastapi_startkit.console.install_command import InstallCommand, ProviderEntry, RegistrationError, register_provider from fastapi_startkit.logging import LogProvider -IMPORTS = { - "CacheProvider": "from fastapi_startkit.cache import CacheProvider", - "CacheConfig": "from config.cache import CacheConfig", -} +IMPORTS = {"CacheProvider": "fastapi_startkit.cache", "CacheConfig": "config.cache"} BOOTSTRAP = """from fastapi_startkit import Application from config.logging import LoggingConfig @@ -52,7 +51,7 @@ def make_app(tmp_path: Path) -> Path: return tmp_path -def register(source: str) -> str | None: +def register(source: str) -> str: return register_provider(source, "CacheProvider", "(CacheProvider, CacheConfig)", IMPORTS) @@ -116,10 +115,7 @@ def test_provider_entry_from_class_derives_entry_and_imports(): 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", - } + assert entry.imports == {"LogProvider": LogProvider.__module__, "LoggingConfig": "config.logging"} def test_registers_multiple_providers_and_copies_traversable_stub(tmp_path): @@ -172,7 +168,7 @@ def test_missing_bootstrap_prints_manual_steps(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 "bootstrap/application.py does not exist." in error assert "from fastapi_startkit.cache import CacheProvider" in error assert "Register CacheProvider manually:" in error assert "providers=[..., (CacheProvider, CacheConfig)]" in error @@ -183,19 +179,17 @@ def test_single_line_providers_list(): "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 "") + assert "providers=[A, (CacheProvider, CacheConfig)]" in register("app = Application(providers=[A,])\n") + assert "providers=[(CacheProvider, CacheConfig)]" in register("app = Application(providers=[])\n") 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)"] @@ -203,21 +197,19 @@ def test_adds_providers_keyword_when_missing(): 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 fastapi_startkit.cache 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 @@ -228,9 +220,97 @@ def test_already_registered_returns_source_unchanged(): 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 +@pytest.mark.parametrize( + ("source", "message"), + [ + ("app = object()\n", "Could not find an Application(...) call."), + ("app = Application(providers=PROVIDERS)\n", "providers is not a list literal."), + ("app = Application('.', None, PROVIDERS)\n", "providers is not a list literal."), + ("app = Application(*ARGS)\n", "Application(...) is called with *args."), + ], +) +def test_unsupported_bootstrap_shapes_raise(source, message): + with pytest.raises(RegistrationError, match=re.escape(message)): + register(source) + + +@pytest.mark.parametrize( + "item", + ["(cache.CacheProvider, CacheConfig)", "fastapi_startkit.cache.CacheProvider", "(cache.CacheProvider, x.Config)"], +) +def test_attribute_reference_counts_as_registered(item): + source = f"import cache\napp = Application(providers=[{item}])\n" + + assert register(source) == source + + +def test_inserts_into_positional_providers_list(): + updated = register("from fastapi_startkit import Application\n\napp = Application('.', None, [A])\n") + + assert "Application('.', None, [A, (CacheProvider, CacheConfig)])" in updated + assert "providers=" not in updated + + +def test_positional_providers_already_registered_returns_source_unchanged(): + source = "app = Application('.', None, [CacheProvider])\n" + + assert register(source) == source + + +def test_name_imported_from_another_module_raises(): + source = "from fastapi_startkit.masoniteorm.config import CacheConfig\napp = Application(providers=[])\n" + + with pytest.raises( + RegistrationError, + match=re.escape( + "CacheConfig is already imported as fastapi_startkit.masoniteorm.config.CacheConfig, not config.cache.CacheConfig." + ), + ): + register(source) + + +def test_aliased_import_of_another_module_raises(): + source = "from other import Something as CacheProvider\napp = Application(providers=[])\n" + + with pytest.raises( + RegistrationError, + match="CacheProvider is already imported as other.Something, not fastapi_startkit.cache.CacheProvider.", + ): + register(source) + + +def test_relative_import_of_same_name_raises(): + source = "from .cache import CacheConfig\napp = Application(providers=[])\n" + + with pytest.raises(RegistrationError, match=re.escape("CacheConfig is already imported as .cache.CacheConfig")): + register(source) + + +def test_reexported_class_from_loaded_module_is_not_a_conflict(): + source = f"from {LogProvider.__module__} import LogProvider\napp = Application(providers=[])\n" + + updated = register_provider(source, "LogProvider", "LogProvider", {"LogProvider": "fastapi_startkit.logging"}) + + assert updated.count("import LogProvider") == 1 + assert "Application(providers=[LogProvider])" in updated + + +def test_conflicting_import_prints_manual_steps_and_leaves_bootstrap(tmp_path): + app = make_app(tmp_path) + bootstrap = BOOTSTRAP.replace( + "from config.logging import LoggingConfig\n", + "from config.logging import LoggingConfig\nfrom legacy.cache import CacheConfig\n", + ) + (app / "bootstrap/application.py").write_text(bootstrap) + + tester = run(cache_install(tmp_path), app) + + assert tester.status_code == 1 + assert (app / "bootstrap/application.py").read_text() == bootstrap + error = tester.io.fetch_error() + assert "CacheConfig is already imported as legacy.cache.CacheConfig, not config.cache.CacheConfig." in error + assert "Register CacheProvider manually:" in error + assert "from config.cache import CacheConfig" in error def test_edited_bootstrap_compiles(tmp_path): @@ -239,3 +319,11 @@ def test_edited_bootstrap_compiles(tmp_path): run(cache_install(tmp_path), app) compile((app / "bootstrap/application.py").read_text(), "application.py", "exec") + + +def test_plain_imports_bind_module_names(): + updated = register("import os\napp = Application(providers=[])\n") + + assert updated.startswith("import os\nfrom fastapi_startkit.cache import CacheProvider\n") + with pytest.raises(RegistrationError, match=re.escape("CacheConfig is already imported as config.cache, not")): + register("import config.cache as CacheConfig\napp = Application(providers=[])\n")