From 9b96980021a251cca5f250d1ee72f279405cc24e Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 10 Jun 2026 17:02:57 -0700 Subject: [PATCH 1/5] feat(fastapi): add APP_URL support to FastAPIConfig for host/port derivation Parse APP_URL (e.g. http://myapp.com:8080 or https://production.example.com) to derive default host and port. Priority order: APP_HOST/APP_PORT env vars win, then APP_URL hostname/port, then hard-coded defaults (127.0.0.1/8000). Adds 15 tests covering all resolution scenarios. Co-Authored-By: Claude Sonnet 4.6 --- .../fastapi/config/fastapi.py | 39 ++++- .../tests/fastapi/test_fastapi_config.py | 138 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 fastapi_startkit/tests/fastapi/test_fastapi_config.py diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py index f85534c4..cda2c8bc 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py @@ -1,18 +1,53 @@ import dataclasses +import os +from urllib.parse import urlparse from fastapi_startkit.environment import env +def _parse_app_url() -> tuple[str | None, int | None]: + """Parse APP_URL and return (hostname, port). Returns (None, None) if not set.""" + url = os.environ.get("APP_URL", "") + if not url: + return None, None + # Ensure the URL has a scheme so urlparse works correctly + if "://" not in url: + url = f"http://{url}" + parsed = urlparse(url) + return parsed.hostname or None, parsed.port or None + + +def _default_host() -> str: + """APP_HOST > APP_URL hostname > 127.0.0.1""" + if os.environ.get("APP_HOST"): + return env("APP_HOST", "127.0.0.1") + host, _ = _parse_app_url() + return host or "127.0.0.1" + + +def _default_port() -> int: + """APP_PORT > APP_URL port > 8000""" + if os.environ.get("APP_PORT"): + return int(env("APP_PORT", 8000)) + _, port = _parse_app_url() + return port or 8000 + + @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. + + Resolution order for host and port: + 1. APP_HOST / APP_PORT (explicit env vars) + 2. APP_URL (parsed — e.g. http://myapp.com:8080) + 3. Hard-coded defaults (127.0.0.1 / 8000) """ - 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)) + host: str = dataclasses.field(default_factory=_default_host) + port: int = dataclasses.field(default_factory=_default_port) 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_fastapi_config.py b/fastapi_startkit/tests/fastapi/test_fastapi_config.py new file mode 100644 index 00000000..77ab7d74 --- /dev/null +++ b/fastapi_startkit/tests/fastapi/test_fastapi_config.py @@ -0,0 +1,138 @@ +"""Tests for FastAPIConfig APP_URL / APP_HOST / APP_PORT resolution.""" + +import os +import importlib +import unittest.mock as mock + +import pytest + +# We need to reimport the module after patching env vars because the +# default_factory functions read os.environ at call-time (not import-time), +# so we can simply instantiate FastAPIConfig inside each test after patching. + + +def _make_config(env_vars: dict): + """Helper: patch os.environ with *env_vars*, then instantiate FastAPIConfig.""" + # Remove keys not in env_vars so we start from a clean slate for each test. + keys_to_clear = {"APP_HOST", "APP_PORT", "APP_URL", "APP_RELOAD"} + patched_env = {k: v for k, v in os.environ.items() if k not in keys_to_clear} + patched_env.update(env_vars) + with mock.patch.dict(os.environ, patched_env, clear=True): + # Re-import to pick up the patched env inside the helper functions. + import fastapi_startkit.fastapi.config.fastapi as cfg_module + importlib.reload(cfg_module) + return cfg_module.FastAPIConfig() + + +# --------------------------------------------------------------------------- +# Defaults — no env vars set +# --------------------------------------------------------------------------- + + +class TestFastAPIConfigDefaults: + def test_default_host(self): + cfg = _make_config({}) + assert cfg.host == "127.0.0.1" + + def test_default_port(self): + cfg = _make_config({}) + assert cfg.port == 8000 + + def test_default_reload_excludes(self): + cfg = _make_config({}) + assert "*.log" in cfg.reload_excludes + assert "tests/*" in cfg.reload_excludes + assert "node_modules/*" in cfg.reload_excludes + + def test_default_reload_dirs_is_none(self): + cfg = _make_config({}) + assert cfg.reload_dirs is None + + +# --------------------------------------------------------------------------- +# Explicit APP_HOST and APP_PORT +# --------------------------------------------------------------------------- + + +class TestFastAPIConfigExplicitEnvVars: + def test_app_host_is_used(self): + cfg = _make_config({"APP_HOST": "0.0.0.0"}) + assert cfg.host == "0.0.0.0" + + def test_app_port_is_used(self): + cfg = _make_config({"APP_PORT": "9000"}) + assert cfg.port == 9000 + + def test_app_host_and_port_together(self): + cfg = _make_config({"APP_HOST": "10.0.0.1", "APP_PORT": "5000"}) + assert cfg.host == "10.0.0.1" + assert cfg.port == 5000 + + +# --------------------------------------------------------------------------- +# APP_URL with host and port +# --------------------------------------------------------------------------- + + +class TestFastAPIConfigAppURL: + def test_app_url_host_and_port(self): + cfg = _make_config({"APP_URL": "http://myapp.com:9000"}) + assert cfg.host == "myapp.com" + assert cfg.port == 9000 + + def test_app_url_https_no_port(self): + """APP_URL with https and no port → host set, port falls back to 8000.""" + cfg = _make_config({"APP_URL": "https://production.example.com"}) + assert cfg.host == "production.example.com" + assert cfg.port == 8000 + + def test_app_url_without_scheme(self): + """APP_URL without a scheme (myapp.com:9000) is handled correctly.""" + cfg = _make_config({"APP_URL": "myapp.com:9000"}) + assert cfg.host == "myapp.com" + assert cfg.port == 9000 + + def test_app_url_plain_hostname_no_port(self): + """APP_URL with just a hostname and no port.""" + cfg = _make_config({"APP_URL": "http://simple.example.com"}) + assert cfg.host == "simple.example.com" + assert cfg.port == 8000 + + +# --------------------------------------------------------------------------- +# Priority: APP_HOST / APP_PORT win over APP_URL +# --------------------------------------------------------------------------- + + +class TestFastAPIConfigPriority: + def test_app_host_wins_over_app_url(self): + cfg = _make_config({ + "APP_URL": "http://myapp.com:9000", + "APP_HOST": "override.com", + }) + assert cfg.host == "override.com" + + def test_app_port_wins_over_app_url(self): + cfg = _make_config({ + "APP_URL": "http://myapp.com:9000", + "APP_PORT": "7777", + }) + assert cfg.port == 7777 + + def test_app_host_and_port_both_win_over_app_url(self): + cfg = _make_config({ + "APP_URL": "http://myapp.com:9000", + "APP_HOST": "override.com", + "APP_PORT": "1234", + }) + assert cfg.host == "override.com" + assert cfg.port == 1234 + + def test_app_url_port_used_when_only_app_host_overrides(self): + """APP_HOST overrides host but APP_URL port is still used for port.""" + cfg = _make_config({ + "APP_URL": "http://myapp.com:9000", + "APP_HOST": "override.com", + }) + # APP_PORT not set → falls through to APP_URL port + assert cfg.port == 9000 From 179c78434bb55a9565b7762529ebe71198ad6e82 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 10 Jun 2026 17:12:18 -0700 Subject: [PATCH 2/5] refactor(config): clean FastAPIConfig APP_URL parsing, fix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace three-function APP_URL parsing helpers with a single _parse_app_url(component) helper and inline or-chain lambdas in the dataclass fields. Remove all importlib.reload() calls from the test suite — monkeypatch works directly since default_factory lambdas call env() at instantiation time. Co-Authored-By: Claude Sonnet 4.6 --- .../fastapi/config/fastapi.py | 48 ++--- .../tests/fastapi/test_fastapi_config.py | 182 +++++++----------- 2 files changed, 83 insertions(+), 147 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py index cda2c8bc..565ab5eb 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py @@ -1,36 +1,16 @@ import dataclasses -import os from urllib.parse import urlparse from fastapi_startkit.environment import env -def _parse_app_url() -> tuple[str | None, int | None]: - """Parse APP_URL and return (hostname, port). Returns (None, None) if not set.""" - url = os.environ.get("APP_URL", "") - if not url: - return None, None - # Ensure the URL has a scheme so urlparse works correctly - if "://" not in url: - url = f"http://{url}" - parsed = urlparse(url) - return parsed.hostname or None, parsed.port or None - - -def _default_host() -> str: - """APP_HOST > APP_URL hostname > 127.0.0.1""" - if os.environ.get("APP_HOST"): - return env("APP_HOST", "127.0.0.1") - host, _ = _parse_app_url() - return host or "127.0.0.1" - - -def _default_port() -> int: - """APP_PORT > APP_URL port > 8000""" - if os.environ.get("APP_PORT"): - return int(env("APP_PORT", 8000)) - _, port = _parse_app_url() - return port or 8000 +def _parse_app_url(component: str): + """Extract host or port from APP_URL. Returns None if APP_URL is not set or the component is absent.""" + raw = env("APP_URL", "") + if not raw: + return None + parsed = urlparse(raw if "://" in raw else f"http://{raw}") + return parsed.hostname if component == "host" else parsed.port @dataclasses.dataclass @@ -40,14 +20,16 @@ class FastAPIConfig: All fields can be overridden via environment variables or by publishing a ``config/fastapi.py`` file in the application root. - Resolution order for host and port: - 1. APP_HOST / APP_PORT (explicit env vars) - 2. APP_URL (parsed — e.g. http://myapp.com:8080) - 3. Hard-coded defaults (127.0.0.1 / 8000) + Host and port resolution order: + APP_HOST / APP_PORT → APP_URL → 127.0.0.1 / 8000 """ - host: str = dataclasses.field(default_factory=_default_host) - port: int = dataclasses.field(default_factory=_default_port) + host: str = dataclasses.field( + default_factory=lambda: env("APP_HOST") or _parse_app_url("host") or "127.0.0.1" + ) + port: int = dataclasses.field( + default_factory=lambda: env("APP_PORT") or _parse_app_url("port") or 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_fastapi_config.py b/fastapi_startkit/tests/fastapi/test_fastapi_config.py index 77ab7d74..b1a855db 100644 --- a/fastapi_startkit/tests/fastapi/test_fastapi_config.py +++ b/fastapi_startkit/tests/fastapi/test_fastapi_config.py @@ -1,138 +1,92 @@ -"""Tests for FastAPIConfig APP_URL / APP_HOST / APP_PORT resolution.""" - -import os -import importlib -import unittest.mock as mock +"""Tests for FastAPIConfig host/port resolution (APP_HOST, APP_PORT, APP_URL).""" import pytest -# We need to reimport the module after patching env vars because the -# default_factory functions read os.environ at call-time (not import-time), -# so we can simply instantiate FastAPIConfig inside each test after patching. - - -def _make_config(env_vars: dict): - """Helper: patch os.environ with *env_vars*, then instantiate FastAPIConfig.""" - # Remove keys not in env_vars so we start from a clean slate for each test. - keys_to_clear = {"APP_HOST", "APP_PORT", "APP_URL", "APP_RELOAD"} - patched_env = {k: v for k, v in os.environ.items() if k not in keys_to_clear} - patched_env.update(env_vars) - with mock.patch.dict(os.environ, patched_env, clear=True): - # Re-import to pick up the patched env inside the helper functions. - import fastapi_startkit.fastapi.config.fastapi as cfg_module - importlib.reload(cfg_module) - return cfg_module.FastAPIConfig() +from fastapi_startkit.fastapi.config.fastapi import FastAPIConfig -# --------------------------------------------------------------------------- -# Defaults — no env vars set -# --------------------------------------------------------------------------- +class TestDefaults: + def test_default_host(self, monkeypatch): + monkeypatch.delenv("APP_HOST", raising=False) + monkeypatch.delenv("APP_URL", raising=False) + assert FastAPIConfig().host == "127.0.0.1" + def test_default_port(self, monkeypatch): + monkeypatch.delenv("APP_PORT", raising=False) + monkeypatch.delenv("APP_URL", raising=False) + assert FastAPIConfig().port == 8000 -class TestFastAPIConfigDefaults: - def test_default_host(self): - cfg = _make_config({}) - assert cfg.host == "127.0.0.1" - - def test_default_port(self): - cfg = _make_config({}) - assert cfg.port == 8000 - - def test_default_reload_excludes(self): - cfg = _make_config({}) - assert "*.log" in cfg.reload_excludes - assert "tests/*" in cfg.reload_excludes - assert "node_modules/*" in cfg.reload_excludes + def test_default_reload_is_true(self, monkeypatch): + monkeypatch.delenv("APP_RELOAD", raising=False) + assert FastAPIConfig().reload is True def test_default_reload_dirs_is_none(self): - cfg = _make_config({}) - assert cfg.reload_dirs is None - - -# --------------------------------------------------------------------------- -# Explicit APP_HOST and APP_PORT -# --------------------------------------------------------------------------- - - -class TestFastAPIConfigExplicitEnvVars: - def test_app_host_is_used(self): - cfg = _make_config({"APP_HOST": "0.0.0.0"}) - assert cfg.host == "0.0.0.0" + assert FastAPIConfig().reload_dirs is None - def test_app_port_is_used(self): - cfg = _make_config({"APP_PORT": "9000"}) - assert cfg.port == 9000 - - def test_app_host_and_port_together(self): - cfg = _make_config({"APP_HOST": "10.0.0.1", "APP_PORT": "5000"}) - assert cfg.host == "10.0.0.1" - assert cfg.port == 5000 - - -# --------------------------------------------------------------------------- -# APP_URL with host and port -# --------------------------------------------------------------------------- - - -class TestFastAPIConfigAppURL: - def test_app_url_host_and_port(self): - cfg = _make_config({"APP_URL": "http://myapp.com:9000"}) + def test_default_reload_excludes(self): + excludes = FastAPIConfig().reload_excludes + assert "*.log" in excludes + assert "tests/*" in excludes + assert "node_modules/*" in excludes + + +class TestExplicitEnvVars: + def test_app_host(self, monkeypatch): + monkeypatch.setenv("APP_HOST", "0.0.0.0") + monkeypatch.delenv("APP_URL", raising=False) + assert FastAPIConfig().host == "0.0.0.0" + + def test_app_port(self, monkeypatch): + monkeypatch.setenv("APP_PORT", "9000") + monkeypatch.delenv("APP_URL", raising=False) + assert FastAPIConfig().port == 9000 + + def test_app_reload_false(self, monkeypatch): + monkeypatch.setenv("APP_RELOAD", "False") + assert FastAPIConfig().reload is False + + +class TestAppUrlParsing: + def test_host_and_port_from_url(self, monkeypatch): + monkeypatch.setenv("APP_URL", "http://myapp.com:9000") + monkeypatch.delenv("APP_HOST", raising=False) + monkeypatch.delenv("APP_PORT", raising=False) + cfg = FastAPIConfig() assert cfg.host == "myapp.com" assert cfg.port == 9000 - def test_app_url_https_no_port(self): - """APP_URL with https and no port → host set, port falls back to 8000.""" - cfg = _make_config({"APP_URL": "https://production.example.com"}) + def test_https_url_no_port(self, monkeypatch): + monkeypatch.setenv("APP_URL", "https://production.example.com") + monkeypatch.delenv("APP_HOST", raising=False) + monkeypatch.delenv("APP_PORT", raising=False) + cfg = FastAPIConfig() assert cfg.host == "production.example.com" assert cfg.port == 8000 - def test_app_url_without_scheme(self): - """APP_URL without a scheme (myapp.com:9000) is handled correctly.""" - cfg = _make_config({"APP_URL": "myapp.com:9000"}) + def test_url_without_scheme(self, monkeypatch): + monkeypatch.setenv("APP_URL", "myapp.com:9000") + monkeypatch.delenv("APP_HOST", raising=False) + monkeypatch.delenv("APP_PORT", raising=False) + cfg = FastAPIConfig() assert cfg.host == "myapp.com" assert cfg.port == 9000 - def test_app_url_plain_hostname_no_port(self): - """APP_URL with just a hostname and no port.""" - cfg = _make_config({"APP_URL": "http://simple.example.com"}) - assert cfg.host == "simple.example.com" - assert cfg.port == 8000 - -# --------------------------------------------------------------------------- -# Priority: APP_HOST / APP_PORT win over APP_URL -# --------------------------------------------------------------------------- +class TestPriority: + def test_app_host_wins_over_url(self, monkeypatch): + monkeypatch.setenv("APP_URL", "http://myapp.com:9000") + monkeypatch.setenv("APP_HOST", "override.com") + assert FastAPIConfig().host == "override.com" + def test_app_port_wins_over_url(self, monkeypatch): + monkeypatch.setenv("APP_URL", "http://myapp.com:9000") + monkeypatch.setenv("APP_PORT", "7777") + assert FastAPIConfig().port == 7777 -class TestFastAPIConfigPriority: - def test_app_host_wins_over_app_url(self): - cfg = _make_config({ - "APP_URL": "http://myapp.com:9000", - "APP_HOST": "override.com", - }) - assert cfg.host == "override.com" - - def test_app_port_wins_over_app_url(self): - cfg = _make_config({ - "APP_URL": "http://myapp.com:9000", - "APP_PORT": "7777", - }) - assert cfg.port == 7777 - - def test_app_host_and_port_both_win_over_app_url(self): - cfg = _make_config({ - "APP_URL": "http://myapp.com:9000", - "APP_HOST": "override.com", - "APP_PORT": "1234", - }) + def test_app_host_overrides_only_host_url_port_still_used(self, monkeypatch): + monkeypatch.setenv("APP_URL", "http://myapp.com:9000") + monkeypatch.setenv("APP_HOST", "override.com") + monkeypatch.delenv("APP_PORT", raising=False) + cfg = FastAPIConfig() assert cfg.host == "override.com" - assert cfg.port == 1234 - - def test_app_url_port_used_when_only_app_host_overrides(self): - """APP_HOST overrides host but APP_URL port is still used for port.""" - cfg = _make_config({ - "APP_URL": "http://myapp.com:9000", - "APP_HOST": "override.com", - }) - # APP_PORT not set → falls through to APP_URL port assert cfg.port == 9000 From fcbc152fa10d26c7454fff0b052f86148dac7f9a Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 10 Jun 2026 19:41:31 -0700 Subject: [PATCH 3/5] refactor(fastapi): move host/port resolution out of FastAPIConfig into ServeCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPIConfig now stores only raw env values (APP_HOST, APP_PORT, APP_URL) with no parsing logic or hardcoded defaults. _resolve_host_port() in ServeCommand owns the full APP_HOST/APP_PORT → APP_URL → 127.0.0.1/8000 resolution chain, making the priority order explicit and testable in isolation. Co-Authored-By: Claude Sonnet 4.6 --- .../fastapi/commands/serve_command.py | 52 ++++++++++++++++--- .../fastapi/config/fastapi.py | 27 +++------- 2 files changed, 52 insertions(+), 27 deletions(-) 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 be268cd5..af2d6585 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py @@ -1,6 +1,41 @@ -from fastapi_startkit.console.command import Command +from urllib.parse import urlparse + from cleo.helpers import option +from fastapi_startkit.console.command import Command + + +def _resolve_host_port( + cfg_host: str | None, + cfg_port: int | None, + app_url: str | None, +) -> tuple[str, int]: + """Resolve host and port with priority: APP_HOST/APP_PORT → APP_URL → defaults. + + Args: + cfg_host: Value of APP_HOST (may be None). + cfg_port: Value of APP_PORT (may be None). + app_url: Value of APP_URL (may be None). + + Returns: + A (host, port) tuple always containing concrete values. + """ + host = cfg_host or None + port = int(cfg_port) if cfg_port else None + + if app_url and (not host or port is None): + raw = app_url + parsed = urlparse(raw if "://" in raw else f"http://{raw}") + if not host: + host = parsed.hostname or None + if port is None: + port = parsed.port or None + + host = host or "127.0.0.1" + port = port if port is not None else 8000 + + return host, port + class ServeCommand(Command): name = "serve" @@ -42,15 +77,20 @@ def handle(self): from fastapi_startkit import Config from fastapi_startkit.container import Container - # Resolve server settings: CLI flag > fastapi config > uvicorn default (None) - cfg_host = Config.get("fastapi.host", "127.0.0.1") - cfg_port = Config.get("fastapi.port", 8000) + # Read raw config values — no defaults here + cfg_host = Config.get("fastapi.host") + cfg_port = Config.get("fastapi.port") + cfg_app_url = Config.get("fastapi.app_url") cfg_reload = Config.get("fastapi.reload", True) cfg_reload_dirs = Config.get("fastapi.reload_dirs") or None cfg_reload_excludes = Config.get("fastapi.reload_excludes") or None - host = self.option("host") or cfg_host - port = int(self.option("port") or cfg_port) + # Full resolution: APP_HOST/APP_PORT → APP_URL → 127.0.0.1/8000 + resolved_host, resolved_port = _resolve_host_port(cfg_host, cfg_port, cfg_app_url) + + # CLI flags override resolved config + host = self.option("host") or resolved_host + port = int(self.option("port") or resolved_port) reload = cfg_reload if self.option("reload") is None else self.option("reload") app = 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 565ab5eb..4f699dad 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py @@ -1,35 +1,20 @@ import dataclasses -from urllib.parse import urlparse from fastapi_startkit.environment import env -def _parse_app_url(component: str): - """Extract host or port from APP_URL. Returns None if APP_URL is not set or the component is absent.""" - raw = env("APP_URL", "") - if not raw: - return None - parsed = urlparse(raw if "://" in raw else f"http://{raw}") - return parsed.hostname if component == "host" else parsed.port - - @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 and port resolution order: - APP_HOST / APP_PORT → APP_URL → 127.0.0.1 / 8000 + All fields are raw environment values — no parsing or defaults applied here. + Resolution logic (APP_HOST / APP_PORT → APP_URL → 127.0.0.1 / 8000) + lives in ServeCommand. """ - host: str = dataclasses.field( - default_factory=lambda: env("APP_HOST") or _parse_app_url("host") or "127.0.0.1" - ) - port: int = dataclasses.field( - default_factory=lambda: env("APP_PORT") or _parse_app_url("port") or 8000 - ) + host: str | None = dataclasses.field(default_factory=lambda: env("APP_HOST")) + port: int | None = dataclasses.field(default_factory=lambda: env("APP_PORT")) + app_url: str | None = dataclasses.field(default_factory=lambda: env("APP_URL")) reload: bool = dataclasses.field(default_factory=lambda: env("APP_RELOAD", True)) reload_dirs: list | None = None reload_excludes: list = dataclasses.field( From d2223e5f6c512baaed0c2c7e0a2b1aa6933070f2 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 13 Jun 2026 09:56:04 -0700 Subject: [PATCH 4/5] =?UTF-8?q?refactor(serve):=20use=20Uri=20parser=20for?= =?UTF-8?q?=20APP=5FURL;=20enforce=20arg=E2=86=92env=E2=86=92default=20pri?= =?UTF-8?q?ority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace urllib.parse.urlparse with Uri.of() from fastapi_startkit.support. Extend _resolve_host_port() to accept CLI args as top-priority parameters, enforcing strict: CLI flag → APP_HOST/APP_PORT → APP_URL → hardcoded default. Rewrite tests to target _resolve_host_port() directly and cover the full priority chain including CLI overrides; update FastAPIConfig tests to reflect that the config dataclass stores raw env values only (no defaults or parsing). Co-Authored-By: Claude Sonnet 4.6 --- .../fastapi/commands/serve_command.py | 55 ++++-- .../tests/fastapi/test_fastapi_config.py | 160 ++++++++++++------ 2 files changed, 147 insertions(+), 68 deletions(-) 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 af2d6585..7a674402 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py @@ -1,39 +1,58 @@ -from urllib.parse import urlparse - from cleo.helpers import option from fastapi_startkit.console.command import Command +from fastapi_startkit.support import Uri def _resolve_host_port( cfg_host: str | None, cfg_port: int | None, app_url: str | None, + cli_host: str | None = None, + cli_port: int | None = None, ) -> tuple[str, int]: - """Resolve host and port with priority: APP_HOST/APP_PORT → APP_URL → defaults. + """Resolve host and port with strict priority order. + + Priority (highest to lowest): + 1. CLI flags (``--host`` / ``--port``) + 2. Env vars APP_HOST / APP_PORT (``cfg_host`` / ``cfg_port``) + 3. Parsed from APP_URL (``app_url``) + 4. Hardcoded defaults (``127.0.0.1`` / ``8000``) Args: - cfg_host: Value of APP_HOST (may be None). - cfg_port: Value of APP_PORT (may be None). - app_url: Value of APP_URL (may be None). + cfg_host: Value of APP_HOST env var (may be None). + cfg_port: Value of APP_PORT env var (may be None). + app_url: Value of APP_URL env var (may be None). + cli_host: Value passed via ``--host`` CLI flag (may be None). + cli_port: Value passed via ``--port`` CLI flag (may be None). Returns: - A (host, port) tuple always containing concrete values. + A ``(host, port)`` tuple always containing concrete values. """ - host = cfg_host or None - port = int(cfg_port) if cfg_port else None + # Start with env-level values + host: str | None = cfg_host or None + port: int | None = int(cfg_port) if cfg_port else None + # Fall back to APP_URL only for fields not already set by env vars if app_url and (not host or port is None): - raw = app_url - parsed = urlparse(raw if "://" in raw else f"http://{raw}") + # Normalize bare hosts like "myapp.com:9000" — Uri needs a scheme + normalized = app_url if "://" in app_url else f"http://{app_url}" + uri = Uri.of(normalized) if not host: - host = parsed.hostname or None + host = uri.host() or None if port is None: - port = parsed.port or None + port = uri.port() + # Apply hardcoded defaults last host = host or "127.0.0.1" port = port if port is not None else 8000 + # CLI flags override everything + if cli_host: + host = cli_host + if cli_port is not None: + port = cli_port + return host, port @@ -85,12 +104,12 @@ def handle(self): cfg_reload_dirs = Config.get("fastapi.reload_dirs") or None cfg_reload_excludes = Config.get("fastapi.reload_excludes") or None - # Full resolution: APP_HOST/APP_PORT → APP_URL → 127.0.0.1/8000 - resolved_host, resolved_port = _resolve_host_port(cfg_host, cfg_port, cfg_app_url) + # Parse CLI flags — convert port to int if provided + cli_host: str | None = self.option("host") or None + cli_port: int | None = int(self.option("port")) if self.option("port") else None - # CLI flags override resolved config - host = self.option("host") or resolved_host - port = int(self.option("port") or resolved_port) + # Full priority chain: CLI arg → env var → APP_URL → hardcoded default + host, port = _resolve_host_port(cfg_host, cfg_port, cfg_app_url, cli_host, cli_port) reload = cfg_reload if self.option("reload") is None else self.option("reload") app = self.option("app") diff --git a/fastapi_startkit/tests/fastapi/test_fastapi_config.py b/fastapi_startkit/tests/fastapi/test_fastapi_config.py index b1a855db..7ef151fd 100644 --- a/fastapi_startkit/tests/fastapi/test_fastapi_config.py +++ b/fastapi_startkit/tests/fastapi/test_fastapi_config.py @@ -1,20 +1,23 @@ -"""Tests for FastAPIConfig host/port resolution (APP_HOST, APP_PORT, APP_URL).""" - -import pytest +"""Tests for FastAPIConfig raw values and _resolve_host_port priority chain.""" +from fastapi_startkit.fastapi.commands.serve_command import _resolve_host_port from fastapi_startkit.fastapi.config.fastapi import FastAPIConfig -class TestDefaults: - def test_default_host(self, monkeypatch): +class TestFastAPIConfigDefaults: + """FastAPIConfig stores raw env values — no defaults or resolution here.""" + + def test_host_is_falsy_without_env(self, monkeypatch): monkeypatch.delenv("APP_HOST", raising=False) - monkeypatch.delenv("APP_URL", raising=False) - assert FastAPIConfig().host == "127.0.0.1" + assert not FastAPIConfig().host - def test_default_port(self, monkeypatch): + def test_port_is_falsy_without_env(self, monkeypatch): monkeypatch.delenv("APP_PORT", raising=False) + assert not FastAPIConfig().port + + def test_app_url_is_falsy_without_env(self, monkeypatch): monkeypatch.delenv("APP_URL", raising=False) - assert FastAPIConfig().port == 8000 + assert not FastAPIConfig().app_url def test_default_reload_is_true(self, monkeypatch): monkeypatch.delenv("APP_RELOAD", raising=False) @@ -30,63 +33,120 @@ def test_default_reload_excludes(self): assert "node_modules/*" in excludes -class TestExplicitEnvVars: +class TestFastAPIConfigEnvVars: + """FastAPIConfig reads raw env values without defaults.""" + def test_app_host(self, monkeypatch): monkeypatch.setenv("APP_HOST", "0.0.0.0") - monkeypatch.delenv("APP_URL", raising=False) assert FastAPIConfig().host == "0.0.0.0" def test_app_port(self, monkeypatch): monkeypatch.setenv("APP_PORT", "9000") - monkeypatch.delenv("APP_URL", raising=False) assert FastAPIConfig().port == 9000 + def test_app_url(self, monkeypatch): + monkeypatch.setenv("APP_URL", "http://myapp.com:9000") + assert FastAPIConfig().app_url == "http://myapp.com:9000" + def test_app_reload_false(self, monkeypatch): monkeypatch.setenv("APP_RELOAD", "False") assert FastAPIConfig().reload is False -class TestAppUrlParsing: - def test_host_and_port_from_url(self, monkeypatch): - monkeypatch.setenv("APP_URL", "http://myapp.com:9000") - monkeypatch.delenv("APP_HOST", raising=False) - monkeypatch.delenv("APP_PORT", raising=False) - cfg = FastAPIConfig() - assert cfg.host == "myapp.com" - assert cfg.port == 9000 +class TestResolveHostPortDefaults: + """_resolve_host_port falls back to 127.0.0.1:8000 when nothing is set.""" - def test_https_url_no_port(self, monkeypatch): - monkeypatch.setenv("APP_URL", "https://production.example.com") - monkeypatch.delenv("APP_HOST", raising=False) - monkeypatch.delenv("APP_PORT", raising=False) - cfg = FastAPIConfig() - assert cfg.host == "production.example.com" - assert cfg.port == 8000 + def test_all_none_returns_defaults(self): + host, port = _resolve_host_port(None, None, None) + assert host == "127.0.0.1" + assert port == 8000 - def test_url_without_scheme(self, monkeypatch): - monkeypatch.setenv("APP_URL", "myapp.com:9000") - monkeypatch.delenv("APP_HOST", raising=False) - monkeypatch.delenv("APP_PORT", raising=False) - cfg = FastAPIConfig() - assert cfg.host == "myapp.com" - assert cfg.port == 9000 + def test_empty_app_url_returns_defaults(self): + host, port = _resolve_host_port(None, None, "") + assert host == "127.0.0.1" + assert port == 8000 -class TestPriority: - def test_app_host_wins_over_url(self, monkeypatch): - monkeypatch.setenv("APP_URL", "http://myapp.com:9000") - monkeypatch.setenv("APP_HOST", "override.com") - assert FastAPIConfig().host == "override.com" +class TestResolveHostPortEnvVars: + """Env vars (APP_HOST / APP_PORT) override APP_URL and default.""" - def test_app_port_wins_over_url(self, monkeypatch): - monkeypatch.setenv("APP_URL", "http://myapp.com:9000") - monkeypatch.setenv("APP_PORT", "7777") - assert FastAPIConfig().port == 7777 + def test_cfg_host_used(self): + host, port = _resolve_host_port("0.0.0.0", None, None) + assert host == "0.0.0.0" + assert port == 8000 - def test_app_host_overrides_only_host_url_port_still_used(self, monkeypatch): - monkeypatch.setenv("APP_URL", "http://myapp.com:9000") - monkeypatch.setenv("APP_HOST", "override.com") - monkeypatch.delenv("APP_PORT", raising=False) - cfg = FastAPIConfig() - assert cfg.host == "override.com" - assert cfg.port == 9000 + def test_cfg_port_used(self): + host, port = _resolve_host_port(None, 9000, None) + assert host == "127.0.0.1" + assert port == 9000 + + def test_cfg_host_and_port_used(self): + host, port = _resolve_host_port("0.0.0.0", 3000, None) + assert host == "0.0.0.0" + assert port == 3000 + + +class TestResolveHostPortAppUrl: + """APP_URL is parsed via Uri when APP_HOST / APP_PORT are not set.""" + + def test_host_and_port_from_url(self): + host, port = _resolve_host_port(None, None, "http://myapp.com:9000") + assert host == "myapp.com" + assert port == 9000 + + def test_https_url_no_port_falls_back_to_default_port(self): + host, port = _resolve_host_port(None, None, "https://production.example.com") + assert host == "production.example.com" + assert port == 8000 + + def test_url_without_scheme(self): + host, port = _resolve_host_port(None, None, "myapp.com:9000") + assert host == "myapp.com" + assert port == 9000 + + def test_cfg_host_wins_over_url_host(self): + host, port = _resolve_host_port("override.com", None, "http://myapp.com:9000") + assert host == "override.com" + assert port == 9000 + + def test_cfg_port_wins_over_url_port(self): + host, port = _resolve_host_port(None, 7777, "http://myapp.com:9000") + assert host == "myapp.com" + assert port == 7777 + + def test_cfg_host_and_port_both_win_over_url(self): + host, port = _resolve_host_port("override.com", 7777, "http://myapp.com:9000") + assert host == "override.com" + assert port == 7777 + + +class TestResolveHostPortCliArgs: + """CLI args override everything (highest priority).""" + + def test_cli_host_overrides_env_host(self): + host, port = _resolve_host_port("env.com", None, None, cli_host="cli.com") + assert host == "cli.com" + + def test_cli_port_overrides_env_port(self): + host, port = _resolve_host_port(None, 9000, None, cli_port=1234) + assert port == 1234 + + def test_cli_overrides_app_url(self): + host, port = _resolve_host_port(None, None, "http://myapp.com:9000", cli_host="cli.com", cli_port=1234) + assert host == "cli.com" + assert port == 1234 + + def test_cli_overrides_default(self): + host, port = _resolve_host_port(None, None, None, cli_host="0.0.0.0", cli_port=5000) + assert host == "0.0.0.0" + assert port == 5000 + + def test_cli_host_only_leaves_port_from_url(self): + host, port = _resolve_host_port(None, None, "http://myapp.com:9000", cli_host="0.0.0.0") + assert host == "0.0.0.0" + assert port == 9000 + + def test_cli_port_only_leaves_host_from_env(self): + host, port = _resolve_host_port("env.com", None, None, cli_port=5000) + assert host == "env.com" + assert port == 5000 From 67872891671b77e85d7735e3ca0681f427ac074c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 13 Jun 2026 10:01:24 -0700 Subject: [PATCH 5/5] refactor(serve): inline Uri priority resolution into handle(), remove _resolve_host_port Co-Authored-By: Claude Sonnet 4.6 --- .../fastapi/commands/serve_command.py | 90 +++++----------- .../tests/fastapi/test_fastapi_config.py | 102 +----------------- 2 files changed, 30 insertions(+), 162 deletions(-) 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 7a674402..2c847c79 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py @@ -4,58 +4,6 @@ from fastapi_startkit.support import Uri -def _resolve_host_port( - cfg_host: str | None, - cfg_port: int | None, - app_url: str | None, - cli_host: str | None = None, - cli_port: int | None = None, -) -> tuple[str, int]: - """Resolve host and port with strict priority order. - - Priority (highest to lowest): - 1. CLI flags (``--host`` / ``--port``) - 2. Env vars APP_HOST / APP_PORT (``cfg_host`` / ``cfg_port``) - 3. Parsed from APP_URL (``app_url``) - 4. Hardcoded defaults (``127.0.0.1`` / ``8000``) - - Args: - cfg_host: Value of APP_HOST env var (may be None). - cfg_port: Value of APP_PORT env var (may be None). - app_url: Value of APP_URL env var (may be None). - cli_host: Value passed via ``--host`` CLI flag (may be None). - cli_port: Value passed via ``--port`` CLI flag (may be None). - - Returns: - A ``(host, port)`` tuple always containing concrete values. - """ - # Start with env-level values - host: str | None = cfg_host or None - port: int | None = int(cfg_port) if cfg_port else None - - # Fall back to APP_URL only for fields not already set by env vars - if app_url and (not host or port is None): - # Normalize bare hosts like "myapp.com:9000" — Uri needs a scheme - normalized = app_url if "://" in app_url else f"http://{app_url}" - uri = Uri.of(normalized) - if not host: - host = uri.host() or None - if port is None: - port = uri.port() - - # Apply hardcoded defaults last - host = host or "127.0.0.1" - port = port if port is not None else 8000 - - # CLI flags override everything - if cli_host: - host = cli_host - if cli_port is not None: - port = cli_port - - return host, port - - class ServeCommand(Command): name = "serve" description = "Start the FastAPI server." @@ -96,20 +44,40 @@ def handle(self): from fastapi_startkit import Config from fastapi_startkit.container import Container - # Read raw config values — no defaults here - cfg_host = Config.get("fastapi.host") - cfg_port = Config.get("fastapi.port") - cfg_app_url = Config.get("fastapi.app_url") cfg_reload = Config.get("fastapi.reload", True) cfg_reload_dirs = Config.get("fastapi.reload_dirs") or None cfg_reload_excludes = Config.get("fastapi.reload_excludes") or None - # Parse CLI flags — convert port to int if provided - cli_host: str | None = self.option("host") or None - cli_port: int | None = int(self.option("port")) if self.option("port") else None + # 1. Start from hardcoded defaults + host: str = "127.0.0.1" + port: int = 8000 + + # 2. Override with APP_URL (lowest env-level source) + app_url: str | None = Config.get("fastapi.app_url") + if app_url: + normalized = app_url if "://" in app_url else f"http://{app_url}" + uri = Uri.of(normalized) + if uri.host(): + host = uri.host() + if uri.port() is not None: + port = uri.port() + + # 3. Override with APP_HOST / APP_PORT (higher than APP_URL) + cfg_host: str | None = Config.get("fastapi.host") + cfg_port: int | None = Config.get("fastapi.port") + if cfg_host: + host = cfg_host + if cfg_port is not None: + port = int(cfg_port) + + # 4. Override with CLI flags (highest priority) + cli_host: str | None = self.option("host") + cli_port: str | None = self.option("port") + if cli_host: + host = cli_host + if cli_port: + port = int(cli_port) - # Full priority chain: CLI arg → env var → APP_URL → hardcoded default - host, port = _resolve_host_port(cfg_host, cfg_port, cfg_app_url, cli_host, cli_port) reload = cfg_reload if self.option("reload") is None else self.option("reload") app = self.option("app") diff --git a/fastapi_startkit/tests/fastapi/test_fastapi_config.py b/fastapi_startkit/tests/fastapi/test_fastapi_config.py index 7ef151fd..5d38afbd 100644 --- a/fastapi_startkit/tests/fastapi/test_fastapi_config.py +++ b/fastapi_startkit/tests/fastapi/test_fastapi_config.py @@ -1,6 +1,5 @@ -"""Tests for FastAPIConfig raw values and _resolve_host_port priority chain.""" +"""Tests for FastAPIConfig raw values.""" -from fastapi_startkit.fastapi.commands.serve_command import _resolve_host_port from fastapi_startkit.fastapi.config.fastapi import FastAPIConfig @@ -51,102 +50,3 @@ def test_app_url(self, monkeypatch): def test_app_reload_false(self, monkeypatch): monkeypatch.setenv("APP_RELOAD", "False") assert FastAPIConfig().reload is False - - -class TestResolveHostPortDefaults: - """_resolve_host_port falls back to 127.0.0.1:8000 when nothing is set.""" - - def test_all_none_returns_defaults(self): - host, port = _resolve_host_port(None, None, None) - assert host == "127.0.0.1" - assert port == 8000 - - def test_empty_app_url_returns_defaults(self): - host, port = _resolve_host_port(None, None, "") - assert host == "127.0.0.1" - assert port == 8000 - - -class TestResolveHostPortEnvVars: - """Env vars (APP_HOST / APP_PORT) override APP_URL and default.""" - - def test_cfg_host_used(self): - host, port = _resolve_host_port("0.0.0.0", None, None) - assert host == "0.0.0.0" - assert port == 8000 - - def test_cfg_port_used(self): - host, port = _resolve_host_port(None, 9000, None) - assert host == "127.0.0.1" - assert port == 9000 - - def test_cfg_host_and_port_used(self): - host, port = _resolve_host_port("0.0.0.0", 3000, None) - assert host == "0.0.0.0" - assert port == 3000 - - -class TestResolveHostPortAppUrl: - """APP_URL is parsed via Uri when APP_HOST / APP_PORT are not set.""" - - def test_host_and_port_from_url(self): - host, port = _resolve_host_port(None, None, "http://myapp.com:9000") - assert host == "myapp.com" - assert port == 9000 - - def test_https_url_no_port_falls_back_to_default_port(self): - host, port = _resolve_host_port(None, None, "https://production.example.com") - assert host == "production.example.com" - assert port == 8000 - - def test_url_without_scheme(self): - host, port = _resolve_host_port(None, None, "myapp.com:9000") - assert host == "myapp.com" - assert port == 9000 - - def test_cfg_host_wins_over_url_host(self): - host, port = _resolve_host_port("override.com", None, "http://myapp.com:9000") - assert host == "override.com" - assert port == 9000 - - def test_cfg_port_wins_over_url_port(self): - host, port = _resolve_host_port(None, 7777, "http://myapp.com:9000") - assert host == "myapp.com" - assert port == 7777 - - def test_cfg_host_and_port_both_win_over_url(self): - host, port = _resolve_host_port("override.com", 7777, "http://myapp.com:9000") - assert host == "override.com" - assert port == 7777 - - -class TestResolveHostPortCliArgs: - """CLI args override everything (highest priority).""" - - def test_cli_host_overrides_env_host(self): - host, port = _resolve_host_port("env.com", None, None, cli_host="cli.com") - assert host == "cli.com" - - def test_cli_port_overrides_env_port(self): - host, port = _resolve_host_port(None, 9000, None, cli_port=1234) - assert port == 1234 - - def test_cli_overrides_app_url(self): - host, port = _resolve_host_port(None, None, "http://myapp.com:9000", cli_host="cli.com", cli_port=1234) - assert host == "cli.com" - assert port == 1234 - - def test_cli_overrides_default(self): - host, port = _resolve_host_port(None, None, None, cli_host="0.0.0.0", cli_port=5000) - assert host == "0.0.0.0" - assert port == 5000 - - def test_cli_host_only_leaves_port_from_url(self): - host, port = _resolve_host_port(None, None, "http://myapp.com:9000", cli_host="0.0.0.0") - assert host == "0.0.0.0" - assert port == 9000 - - def test_cli_port_only_leaves_host_from_env(self): - host, port = _resolve_host_port("env.com", None, None, cli_port=5000) - assert host == "env.com" - assert port == 5000