Repository navigation
Add database:install console command - #323
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
claude-tmgbedu
left a comment
There was a problem hiding this comment.
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
- Missing
[database]extra bricksartisan. On an app installed withoutfastapi-startkit[database], the generatedconfig/database.pyandDatabaseProviderimport SQLAlchemy at boot. After this command,uv run artisan(including this command) fails withImportError. Consider checkingimportlib.util.find_spec("sqlalchemy")and printing a hint (uv add 'fastapi-startkit[database]'), or at least printing the hint after a successful install. - Parity with
init --database.define_databasealso createsdatabases/migrations/,databases/seeders/, theDB_*.enventries and the extra.database:installonly writesconfig/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. - Imports are deduplicated by name only (inline). If a different
DatabaseConfigis already imported (e.g. the basefastapi_startkit.masoniteorm.config.database.DatabaseConfig), the import is skipped and the provider is silently registered with the wrong class. I reproduced this. - The "already registered" check only matches bare names (inline).
providers=[(orm.DatabaseProvider, X)]gets a second entry. Matchingast.Attribute.attrtoo would cover it. - Positional
providersis not detected (inline).Application('.', None, [A])becomesApplication('.', None, [A], providers=[...]), which is valid syntax but raisesTypeError: got multiple values for argument 'providers'at runtime. This is rare, so treatinglen(call.args) >= 3as unsupported would be enough. - Naming: the other ORM commands use
db:*(db:migrate,db:seed,db:make:*), but this one isdatabase: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_offsetis handled correctly inoffset),from __future__/docstring files, imports insidetry, semicolon imports, andApplicationnested in a function. All of them produced valid, correctly formatted output. Non-listproviders=values correctly fall back to the manual steps. - No regressions to core abstractions.
AppProvideronly 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.pyanddatabase_install_command.pyare at 100%.ruff checkandruff format --checkpass on the changed files.basedpyrighton 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} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Summary
Adds a core
database:installconsole command, registered byAppProviderso it's available before the database is set up.config/database.pyfrom the installer's database stub (--driver sqlite|postgres|mysql, defaultsqlite). An existing file is kept unless--forceis passed.(DatabaseProvider, DatabaseConfig)inbootstrap/application.py. It findsApplication(...)withastand inserts the provider and any missing imports at exact positions, so the file's formatting stays the same.DatabaseProvideris already listed, the command changes nothing. If no bootstrap file is found, it prints manual steps and exits 1.DRIVER_CLASSESis moved intoinstaller/plan.pyso the installer and this command use the same mapping.Reusable
InstallCommandbaseconsole/install_command.pygives every package the same install behavior through a fluent class builder:self.commands([...]).add_provideraccepts classes or dotted strings. Use strings for app-side configs and for optional extras that shouldn't be imported.--force,publish_file(skips existing files unless forced), and the AST-based registration inbootstrap/application.py(register_provider).database:installsubclasses it to add--driverand 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 indocs/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--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 optionallangchain.chat_modelsimport).example/fastapi-app: the first run created the config and registered the provider. The second run skipped both.🤖 Generated with Claude Code