diff --git a/fastappkit/cli/migrate.py b/fastappkit/cli/migrate.py index 0f71e55..d73f6a4 100644 --- a/fastappkit/cli/migrate.py +++ b/fastappkit/cli/migrate.py @@ -70,6 +70,10 @@ def core( if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) + # Load registry to identify external apps (needed to exclude their tables from autogenerate) + loader = AppLoader(project_root) + registry = loader.load_all() + # Try to collect metadata from core module for core migrations metadata_collector = MetadataCollector() # Try to collect from core.models only @@ -96,6 +100,7 @@ def core( message, core_migration_path, target_metadata=core_metadata_obj, + registry=registry, # Pass registry so external app tables can be excluded ) typer.echo(f"✅ Created migration: {migration_path}") except Exception as e: diff --git a/fastappkit/cli/templates/app/external/README.md.j2 b/fastappkit/cli/templates/app/external/README.md.j2 index 9830e88..312ac26 100644 --- a/fastappkit/cli/templates/app/external/README.md.j2 +++ b/fastappkit/cli/templates/app/external/README.md.j2 @@ -112,9 +112,12 @@ alembic downgrade -1 alembic upgrade head --sql ``` -**Database URL**: The `DATABASE_URL` is read from the `.env` file automatically. You can also: -- Set `DATABASE_URL` environment variable (takes precedence) -- Edit `alembic.ini` sqlalchemy.url (fallback) +**Database URL**: The database URL is determined in this order: +1. App's Settings class (loads from `.env` file) - **RECOMMENDED** +2. `DATABASE_URL` environment variable +3. `alembic.ini` sqlalchemy.url (fallback only) + +The app's `Settings` class automatically loads from `.env` file, so just configure your `.env` file and it will be used. ### When Using the External App in a Core Project diff --git a/fastappkit/cli/templates/app/external/alembic.ini.j2 b/fastappkit/cli/templates/app/external/alembic.ini.j2 index e444566..06d381b 100644 --- a/fastappkit/cli/templates/app/external/alembic.ini.j2 +++ b/fastappkit/cli/templates/app/external/alembic.ini.j2 @@ -61,15 +61,16 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne # Database URL configuration # -# This can be configured in three ways (in order of precedence): -# 1. DATABASE_URL environment variable (from .env file when running independently) -# 2. This sqlalchemy.url setting (fallback) -# 3. Set programmatically in migrations/env.py +# IMPORTANT: The database URL is determined in this order (when running independently): +# 1. App's Settings class (loads from .env file) - RECOMMENDED +# 2. DATABASE_URL environment variable +# 3. This sqlalchemy.url setting (fallback only) # # For independent development, create a .env file with: # DATABASE_URL=sqlite:///./{{ app_name }}.db # -# The migrations/env.py will automatically read DATABASE_URL from the environment. +# The migrations/env.py will automatically use Settings to load from .env file. +# This sqlalchemy.url is only used as a last resort fallback. # Default uses SQLite for development sqlalchemy.url = sqlite:///./{{ app_name }}.db diff --git a/fastappkit/cli/templates/app/external/config.py.j2 b/fastappkit/cli/templates/app/external/config.py.j2 index e7c0302..a79a4f5 100644 --- a/fastappkit/cli/templates/app/external/config.py.j2 +++ b/fastappkit/cli/templates/app/external/config.py.j2 @@ -15,17 +15,9 @@ class Settings(BaseSettings): Loads from .env file automatically. """ - database_url: str = Field( - default="sqlite:///./{{ app_name }}.db", - alias="DATABASE_URL" - ) - debug: bool = Field( - default=False, - alias="DEBUG" - ) + database_url: str = Field(default="sqlite:///./{{ app_name }}.db") + debug: bool = Field(default=False) model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - populate_by_name=True + env_file=".env", env_file_encoding="utf-8", populate_by_name=True ) diff --git a/fastappkit/cli/templates/app/external/main.py.j2 b/fastappkit/cli/templates/app/external/main.py.j2 index 065eded..ee05e16 100644 --- a/fastappkit/cli/templates/app/external/main.py.j2 +++ b/fastappkit/cli/templates/app/external/main.py.j2 @@ -16,10 +16,10 @@ _current_dir = Path(__file__).parent if str(_current_dir) not in sys.path: sys.path.insert(0, str(_current_dir)) -from fastapi import FastAPI +from fastapi import FastAPI # noqa: E402 -from {{ app_name }}.config import Settings -from {{ app_name }} import register +from {{ app_name }}.config import Settings # noqa: E402 +from {{ app_name }} import register # noqa: E402 # Load settings from .env settings = Settings() @@ -41,4 +41,4 @@ if router is not None: if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="127.0.0.1", port=8000, reload=True) + uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True) diff --git a/fastappkit/cli/templates/app/external/migrations/env.py.j2 b/fastappkit/cli/templates/app/external/migrations/env.py.j2 index 727ef6b..b3f68c4 100644 --- a/fastappkit/cli/templates/app/external/migrations/env.py.j2 +++ b/fastappkit/cli/templates/app/external/migrations/env.py.j2 @@ -32,21 +32,41 @@ target_metadata = Base.metadata config = context.config # Get database URL with the following precedence order: -# 1. Config (set by fastappkit when running from core project) -# 2. DATABASE_URL environment variable (from .env file when running independently) -# 3. alembic.ini sqlalchemy.url (fallback) +# 1. Config (set by fastappkit when running from core project - config_file_name is None) +# 2. App's Settings class (loads from .env file when running independently) +# 3. DATABASE_URL environment variable (direct fallback) +# 4. alembic.ini sqlalchemy.url (last resort) # # This allows the external app to work both: -# - When used in a core project (fastappkit sets it via config) -# - When developed independently (reads from .env file) -database_url = config.get_main_option("sqlalchemy.url") -if not database_url: - # If not set in config, try environment variable (from .env) - import os - database_url = os.getenv("DATABASE_URL") - if database_url: - config.set_main_option("sqlalchemy.url", database_url) -# If still not set, alembic.ini's sqlalchemy.url will be used by Alembic +# - When used in a core project (fastappkit sets it via config, config_file_name is None) +# - When developed independently (reads from .env file via Settings, config_file_name is set) +database_url = None + +# Check if fastappkit is running this (config_file_name is None when fastappkit runs it) +# In that case, database URL is already set by fastappkit in config +if config.config_file_name is None: + # Running from core project via fastappkit - use URL already set in config + database_url = config.get_main_option("sqlalchemy.url") +else: + # Running independently - prioritize Settings/.env over alembic.ini + try: + from {{ app_name }}.config import Settings + settings = Settings() + database_url = settings.database_url + except (ImportError, AttributeError): + # Settings not available, try environment variable directly + import os + database_url = os.getenv("DATABASE_URL") + + # If still not set, alembic.ini's sqlalchemy.url will be used by Alembic + if not database_url: + database_url = config.get_main_option("sqlalchemy.url") + +# Strip async drivers (e.g., +asyncpg, +aiosqlite) since Alembic doesn't support async +if database_url: + for async_driver in ["+asyncpg", "+aiosqlite", "+asyncmy"]: + database_url = database_url.replace(async_driver, "") + config.set_main_option("sqlalchemy.url", database_url) # Interpret the config file for Python logging if config.config_file_name is not None: diff --git a/fastappkit/cli/templates/app/internal/migrations/env.py.j2 b/fastappkit/cli/templates/app/internal/migrations/env.py.j2 index 0b22f68..54700bd 100644 --- a/fastappkit/cli/templates/app/internal/migrations/env.py.j2 +++ b/fastappkit/cli/templates/app/internal/migrations/env.py.j2 @@ -20,7 +20,7 @@ if str(_project_root) not in sys.path: sys.path.insert(0, str(_project_root)) # Import core settings -from core.config import Settings +from core.config import Settings # noqa: E402 # Get settings settings = Settings() diff --git a/fastappkit/cli/templates/project/core/config.py.j2 b/fastappkit/cli/templates/project/core/config.py.j2 index c68e7fb..e211619 100644 --- a/fastappkit/cli/templates/project/core/config.py.j2 +++ b/fastappkit/cli/templates/project/core/config.py.j2 @@ -15,17 +15,9 @@ class Settings(BaseSettings): Loads from .env file automatically. """ - database_url: str = Field( - default="sqlite:///./{{ project_name }}.db", - alias="DATABASE_URL" - ) - debug: bool = Field( - default=False, - alias="DEBUG" - ) + database_url: str = Field(default="sqlite:///./{{ app_name }}.db") + debug: bool = Field(default=False) model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - populate_by_name=True + env_file=".env", env_file_encoding="utf-8", populate_by_name=True ) diff --git a/fastappkit/cli/templates/project/core/db/migrations/env.py.j2 b/fastappkit/cli/templates/project/core/db/migrations/env.py.j2 index 0c10bd7..73c30c7 100644 --- a/fastappkit/cli/templates/project/core/db/migrations/env.py.j2 +++ b/fastappkit/cli/templates/project/core/db/migrations/env.py.j2 @@ -18,8 +18,8 @@ if str(_project_root) not in sys.path: sys.path.insert(0, str(_project_root)) # Import your settings -from core.config import Settings -from sqlalchemy import MetaData +from core.config import Settings # noqa: E402 +from sqlalchemy import MetaData # noqa: E402 # Get settings settings = Settings() @@ -37,6 +37,27 @@ if target_metadata is None: # No core models yet, use empty MetaData for initial migration target_metadata = MetaData() +# Get external app tables to exclude from autogenerate (set by fastappkit) +# This prevents core migrations from dropping external app tables +exclude_external_app_tables = config.attributes.get("exclude_external_app_tables", set()) + + +def include_object(object, name, type_, reflected, compare_to): + """ + Filter objects during autogenerate to exclude external app tables. + + This ensures core migrations don't try to drop tables that belong to external apps, + which manage their own migrations independently. + """ + if type_ == "table": + # Exclude external app tables and version tables + if name in exclude_external_app_tables: + return False + # Also exclude external app version tables (alembic_version_) + if name.startswith("alembic_version_") and name != "alembic_version": + return False + return True + # Set database URL from settings # Strip async drivers (e.g., +asyncpg, +aiosqlite) since Alembic doesn't support async database_url = settings.database_url @@ -61,6 +82,7 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + include_object=include_object, ) with context.begin_transaction(): @@ -76,7 +98,11 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + include_object=include_object, + ) with context.begin_transaction(): context.run_migrations() diff --git a/fastappkit/cli/templates/project/core/models.py.j2 b/fastappkit/cli/templates/project/core/models.py.j2 index ce2d646..ae577e1 100644 --- a/fastappkit/cli/templates/project/core/models.py.j2 +++ b/fastappkit/cli/templates/project/core/models.py.j2 @@ -35,4 +35,4 @@ class Base(DeclarativeBase): # # id = Column(Integer, primary_key=True, index=True) # name = Column(String, nullable=False) -# created_at = Column(DateTime, default=datetime.utcnow) +# created_at = Column(DateTime, default=datetime.now(timezone.utc)) diff --git a/fastappkit/migrations/autogen.py b/fastappkit/migrations/autogen.py index 09c34b0..44d47a6 100644 --- a/fastappkit/migrations/autogen.py +++ b/fastappkit/migrations/autogen.py @@ -14,6 +14,7 @@ from fastappkit.core.registry import AppMetadata from fastappkit.core.types import AppType from fastappkit.migrations.context import MigrationContextBuilder +from fastappkit.migrations.version import VersionTableManager if TYPE_CHECKING: from fastappkit.core.registry import AppRegistry @@ -79,17 +80,43 @@ def generate( # External app or no registry - use only app's metadata target_metadata = self.metadata_collector.collect_metadata(app_metadata) + # For core and internal app migrations (they share the same migration directory), + # collect external app table names to exclude from autogenerate + # This prevents core/internal migrations from dropping external app tables + external_app_tables: set[str] = set() + # Core and internal apps both use core/db/migrations/ and share alembic_version table + # So they both need to exclude external app tables + if app_metadata.app_type == AppType.INTERNAL and registry: + # Collect all external app tables and version tables + for ext_app in registry.get_by_type(AppType.EXTERNAL): + # Get external app version table + version_table = VersionTableManager.get_version_table( + AppType.EXTERNAL, ext_app.name + ) + external_app_tables.add(version_table) + + # Get external app model tables + ext_metadata = self.metadata_collector.collect_metadata(ext_app) + if ext_metadata: + for table_name in ext_metadata.tables.keys(): + external_app_tables.add(table_name) + # Build Alembic config # For internal apps, migration_path is core/db/migrations (no special handling needed) alembic_cfg = self.context_builder.build_alembic_config( app_metadata, migration_path, + registry=registry, ) # Set target metadata in config (for autogenerate) if target_metadata: alembic_cfg.attributes["target_metadata"] = target_metadata + # Set external app tables to exclude (for core migrations only) + if external_app_tables: + alembic_cfg.attributes["exclude_external_app_tables"] = external_app_tables + # Generate migration try: # Alembic revision command doesn't return path directly