Skip to content

Add database:install console command - #323

Merged
tmgbedu merged 2 commits into
mainfrom
task/database-install-command
Oct 27, 2026
Merged

tmgbedu merged 2 commits into
mainfrom
task/database-install-command

Conversation

@claude-tmgbedu

@claude-tmgbedu claude-tmgbedu commented Oct 27, 2026 •

Copy link
Copy Markdown

Summary

Adds a core database:install console command, registered by AppProvider so it's available before the database is set up.

  • Publishes config/database.py from the installer's database stub (--driver sqlite|postgres|mysql, default sqlite). An existing file is kept unless --force is passed.
  • Registers (DatabaseProvider, DatabaseConfig) in bootstrap/application.py. It finds Application(...) with ast and inserts the provider and any missing imports at exact positions, so the file's formatting stays the same.
  • Idempotent: if DatabaseProvider is already listed, the command changes nothing. If no bootstrap file is found, it prints manual steps and exits 1.
  • DRIVER_CLASSES is moved into installer/plan.py so the installer and this command use the same mapping.

Reusable InstallCommand base

console/install_command.py gives every package the same install behavior through a fluent class builder:

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")
)
  • Each chain call returns a new subclass, so the result can be passed straight to self.commands([...]).
  • add_provider accepts classes or dotted strings. Use strings for app-side configs and for optional extras that shouldn't be imported.
  • The base class provides --force, publish_file (skips existing files unless forced), and the AST-based registration in bootstrap/application.py (register_provider).
  • database:install subclasses it to add --driver and template rendering.

ADR renumber

The ADR was renumbered from 035 → 041 (docs/adr/041-database-install-command.md), and its index entry was updated. 035 collides with the Config-subclass ADR on #313. 036–040 are taken by main and the open PRs (#314/#315/#317/#318).

Rebase

Rebased onto latest origin/main (284d097). The only conflict was in docs/adr/AGENTS.md, where both index rows were kept.

Test results

  • pytest tests/console/test_install_command.py tests/console/test_database_install_command.py: 23 passed
  • Full suite (--ignore=tests/masoniteorm/postgres --cov): 3352 passed, 10 skipped, coverage 91.69% (threshold 80)
  • ruff check / ruff format --check: clean. The new test file needed reformatting, which was folded into the commit.
  • basedpyright: no new errors. The one remaining error is pre-existing on main (ai/ai.py, unresolved optional langchain.chat_models import).
  • Manual smoke test on a copy of example/fastapi-app: the first run created the config and registered the provider. The second run skipped both.

🤖 Generated with Claude Code

jobins-bedram and others added 2 commits October 27, 2026 02:47
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@claude-tmgbedu claude-tmgbedu left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve (no blockers)

Reviewed the code diff vs origin/main (8 files: ADR 041 + index, console/install_command.py, console/database_install_command.py, AppProvider, installer/plan.py, two test files).

Blockers

None.

Non-blocking notes

  1. Missing [database] extra bricks artisan. On an app installed without fastapi-startkit[database], the generated config/database.py and DatabaseProvider import SQLAlchemy at boot. After this command, uv run artisan (including this command) fails with ImportError. Consider checking importlib.util.find_spec("sqlalchemy") and printing a hint (uv add 'fastapi-startkit[database]'), or at least printing the hint after a successful install.
  2. Parity with init --database. define_database also creates databases/migrations/, databases/seeders/, the DB_* .env entries and the extra. database:install only writes config/database.py. A follow-up or a printed "next steps" list would help. The ADR's "matches what init generates" only holds for the config file.
  3. Imports are deduplicated by name only (inline). If a different DatabaseConfig is already imported (e.g. the base fastapi_startkit.masoniteorm.config.database.DatabaseConfig), the import is skipped and the provider is silently registered with the wrong class. I reproduced this.
  4. The "already registered" check only matches bare names (inline). providers=[(orm.DatabaseProvider, X)] gets a second entry. Matching ast.Attribute.attr too would cover it.
  5. Positional providers is not detected (inline). Application('.', None, [A]) becomes Application('.', None, [A], providers=[...]), which is valid syntax but raises TypeError: got multiple values for argument 'providers' at runtime. This is rare, so treating len(call.args) >= 3 as unsupported would be enough.
  6. Naming: the other ORM commands use db:* (db:migrate, db:seed, db:make:*), but this one is database:install. Fine if that was intended. Mentioning it for consistency.

What I checked

  • AST insertion: single-line, multi-line, trailing comments, a trailing comma or none, UTF-8 before the insertion point (byte vs char col_offset is handled correctly in offset), from __future__/docstring files, imports inside try, semicolon imports, and Application nested in a function. All of them produced valid, correctly formatted output. Non-list providers= values correctly fall back to the manual steps.
  • No regressions to core abstractions. AppProvider only gains one command, and the import chain (installer.plan) is stdlib-only, so the optional extras are not loaded.
  • AGENTS.md rules: ADR 041 and its index entry are present. There are no explanatory comments or docstrings.

Results

  • uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3352 passed, 10 skipped. Total coverage 91.69%. install_command.py and database_install_command.py are at 100%.
  • ruff check and ruff format --check pass on the changed files. basedpyright on the changed sources: 0 errors, 0 warnings.


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}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: imports are deduplicated by bound name only. If DatabaseConfig is already imported from another module (e.g. the base masoniteorm DatabaseConfig), the from config.database import DatabaseConfig insert is skipped and the provider is registered with the wrong class without any warning. Consider comparing module + name, or failing with the manual steps when the name is already bound to a different module.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #324. An existing import is now compared by its full path, or by object identity when both modules are already loaded, so re-exports still match. If the name is bound to a different module (for example the base masoniteorm DatabaseConfig, a relative import, or an alias), the command stops with DatabaseConfig is already imported as <path>, not config.database.DatabaseConfig., prints the manual steps and exits 1 without editing the file. We chose to stop rather than alias, and the reason is in ADR 041. Regression tests: test_name_imported_from_another_module_raises, test_aliased_import_of_another_module_raises, test_relative_import_of_same_name_raises, test_reexported_class_from_loaded_module_is_not_a_conflict and test_does_not_register_when_database_config_is_imported_from_elsewhere.

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)):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: only bare ast.Name matches. providers=[(orm.DatabaseProvider, X)] is not detected and gets a second entry. Matching isinstance(node, ast.Attribute) and node.attr == provider as well would cover this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #324. is_registered now matches ast.Attribute.attr as well as bare names, so (orm.DatabaseProvider, X) and fastapi_startkit.masoniteorm.DatabaseProvider leave the file unchanged. Regression test: test_attribute_reference_counts_as_registered (parametrized).

lines = source.splitlines(keepends=True)
keyword = next((keyword for keyword in call.keywords if keyword.arg == "providers"), None)

if keyword is None:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: Application(base_path, env, providers) accepts providers positionally. Application('.', None, [A]) gets providers=[...] appended, which parses but raises TypeError: got multiple values for argument 'providers' at boot. Returning None (manual steps) when len(call.args) >= 3 would be a cheap guard.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #324. Instead of falling back to manual steps, a positional providers list (the third positional argument) is now edited in place. Application(".", None, [A]) becomes Application(".", None, [A, (CacheProvider, CacheConfig)]). A positional value that is not a list, or a call with *args, stops with a clear reason. Regression tests: test_inserts_into_positional_providers_list, test_positional_providers_already_registered_returns_source_unchanged and test_unsupported_bootstrap_shapes_raise.

),
]

def publish(self) -> int:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: if the app does not have the fastapi-startkit[database] extra, the rendered config and DatabaseProvider import SQLAlchemy at boot, so every artisan invocation fails afterwards. Suggest a find_spec("sqlalchemy") check with a hint to run uv add 'fastapi-startkit[database]', plus a short next-steps message (DB_* env vars, databases/migrations/), since init --database creates those and this command does not.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #324, where the command is also renamed to db:install. After a successful run it prints next steps. When find_spec("sqlalchemy") finds nothing, these start with uv add "fastapi-startkit[database,<driver>]". They also list the DB_* .env entries, taken from the installer's define_database section, mkdir -p databases/migrations when the directory is missing, and db:make:migration / db:migrate. Regression tests: test_hints_to_install_database_extra_when_sqlalchemy_is_missing, test_prints_next_steps_after_install, test_next_steps_use_driver_environment and test_next_steps_skip_existing_migrations_directory.

@tmgbedu
tmgbedu merged commit ea6a05d into main Oct 27, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants