diff --git a/example/fastapi-app/config/fastapi.py b/example/fastapi-app/config/fastapi.py index ea734791..718195d1 100644 --- a/example/fastapi-app/config/fastapi.py +++ b/example/fastapi-app/config/fastapi.py @@ -5,6 +5,7 @@ @dataclasses.dataclass class FastAPIConfig: + app_url: str = dataclasses.field(default_factory=lambda: env("APP_URL", "http://localhost")) host: str = dataclasses.field(default_factory=lambda: env("APP_HOST", "127.0.0.1")) port: int = dataclasses.field(default_factory=lambda: env("APP_PORT", 8000)) reload: bool = dataclasses.field(default_factory=lambda: env("APP_RELOAD", True)) diff --git a/example/fastapi-app/uv.lock b/example/fastapi-app/uv.lock index 53eba160..d98c77d9 100644 --- a/example/fastapi-app/uv.lock +++ b/example/fastapi-app/uv.lock @@ -299,7 +299,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.26.0" +version = "0.43.0" source = { editable = "../../fastapi_startkit" } dependencies = [ { name = "cleo" }, @@ -321,23 +321,26 @@ fastapi = [ requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, + { name = "anthropic", marker = "extra == 'ai'", specifier = ">=0.49.0" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "dotty-dict", specifier = ">=1.3.1" }, { name = "faker", marker = "extra == 'database'", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, + { name = "google-generativeai", marker = "extra == 'ai'", specifier = ">=0.8.0" }, { name = "inflection", specifier = ">=0.5.1" }, { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, + { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"] [package.metadata.requires-dev] dev = [ @@ -345,10 +348,12 @@ dev = [ { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "faker", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.124.4" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.9.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38" }, { name = "twine", specifier = ">=6.2.0" }, diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py index a595b4d1..75cea457 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py @@ -1,8 +1,9 @@ from cleo.helpers import option + from fastapi_startkit import Config from fastapi_startkit.console.command import Command from fastapi_startkit.environment import value as cast_value -from fastapi_startkit.support import Uriable, Uri +from fastapi_startkit.support import Uri, Uriable class ServeCommand(Command): @@ -46,10 +47,15 @@ def resolve_option(self, key: str, default: str | int | None = None): return cast_value(value) def resolve_url(self) -> Uriable: - host = self.resolve_option("host", "127.0.0.1") - port = self.resolve_option("port", 8000) + host = self.option("host") or Config.get("fastapi.app_url", "http://127.0.0.1:8000") + port = self.option("port") + + if host and not host.startswith("http"): + host = f"http://{host}" + + uri = Uri.of(host) - return Uri.of(Config.get("fastapi.app_url", "http://127.0.0.1:8000")).with_host(host).with_port(port) + return uri.with_port(port) if port else uri def handle(self): import uvicorn @@ -62,11 +68,12 @@ def handle(self): cfg_reload_excludes = Config.get("fastapi.reload_excludes") or None url = self.resolve_url() + reload = self.resolve_option("reload", True) kwargs = { "host": url.host(), "port": url.port(), - "reload": self.resolve_option("reload", True), + "reload": reload, "ws": "websockets-sansio", } @@ -77,9 +84,9 @@ def handle(self): "factory": True, } ) - if cfg_reload_dirs is not None: + if cfg_reload_dirs is not None and reload: kwargs["reload_dirs"] = cfg_reload_dirs - if cfg_reload_excludes is not None: + if cfg_reload_excludes is not None and reload: kwargs["reload_excludes"] = cfg_reload_excludes self.line(f"Starting Uvicorn server on {url.host()}:{url.port()} [{self.option('app')}]...") diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py index f85534c4..5289e42b 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py @@ -5,14 +5,7 @@ @dataclasses.dataclass class FastAPIConfig: - """Server configuration for the uvicorn/FastAPI serve command. - - All fields can be overridden via environment variables or by publishing a - ``config/fastapi.py`` file in the application root. - """ - - host: str = dataclasses.field(default_factory=lambda: env("APP_HOST", "127.0.0.1")) - port: int = dataclasses.field(default_factory=lambda: env("APP_PORT", 8000)) + app_url: str = dataclasses.field(default_factory=lambda: env("APP_URL", "http://127.0.0.1:8000")) reload: bool = dataclasses.field(default_factory=lambda: env("APP_RELOAD", True)) reload_dirs: list | None = None reload_excludes: list = dataclasses.field( diff --git a/fastapi_startkit/tests/fastapi/test_serve_command.py b/fastapi_startkit/tests/fastapi/test_serve_command.py index b1cc7ebb..5c56e105 100644 --- a/fastapi_startkit/tests/fastapi/test_serve_command.py +++ b/fastapi_startkit/tests/fastapi/test_serve_command.py @@ -136,17 +136,17 @@ def test_config_port_used_when_no_cli_flag(self): tester, _ = run(config={"fastapi.port": 7777, "fastapi.app_url": "http://127.0.0.1:7777"}) assert "7777" in tester.io.fetch_output() - def test_app_url_host_overridden_by_config_default(self): - """resolve_url() merges APP_URL with fastapi.host/port. + def test_app_url_is_single_source_of_truth(self): + """resolve_url() uses fastapi.app_url as the single source of truth. - APP_URL sets the base, but fastapi.host and fastapi.port (which default - to 127.0.0.1 / 8000) take priority in the current implementation. - When no explicit host/port is in config, defaults win over APP_URL. + When fastapi.app_url is set in config and no CLI --host/--port flags are + provided, the host and port are derived directly from the configured URL. """ tester, _ = run(config={"fastapi.app_url": "http://staging.example.com:5000"}) output = tester.io.fetch_output() - # Default host wins because resolve_option("host", "127.0.0.1") takes over. - assert _DEFAULT_HOST in output + # app_url is the source of truth — staging.example.com:5000 is used directly. + assert "staging.example.com" in output + assert "5000" in output # --------------------------------------------------------------------------- diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index dd79aa53..b07acda4 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -593,7 +593,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.42.0" +version = "0.43.0" source = { editable = "." } dependencies = [ { name = "cleo" },