From 6e644d1845f4873619317496c0733ddef20a48d4 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Sun, 13 Sep 2026 15:25:49 -0700 Subject: [PATCH] fix(cli): pyfly run resolves the port like the application does `pyfly run` chose the port from the --port flag, then a raw read of pyfly.yaml, then 8080. That bypassed the configuration system entirely: the relaxed-binding override PYFLY_SERVER_PORT, the -D server.port=9000 example advertised by --define's own help text (which becomes exactly that variable), and the profile overlays were all inert for the bind port, while the application read the same key through Config and believed the overridden value. Found starting a service whose default port was taken: the server bound 8080 next to the other process and nothing listened where the operator asked. The CLI now resolves the port with the same precedence as Config: PYFLY_SERVER_PORT first (a non-integer is a usage error, not a silent 8080), then the merged configuration for the active profiles, and only then the raw file. Seven tests cover the file, the env override with and without a file, the -D path end to end, the profile overlay and the bad value. --- src/pyfly/cli/run.py | 37 +++++++++++++++-- tests/cli/test_run_launch_env.py | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/pyfly/cli/run.py b/src/pyfly/cli/run.py index f0337fff..028f98f0 100644 --- a/src/pyfly/cli/run.py +++ b/src/pyfly/cli/run.py @@ -385,11 +385,42 @@ def _run_with_uvicorn_reload(app_path: str, host: str, port: int, reload_dirs: l def _read_port_from_config() -> int | None: - """Read the application port from pyfly.yaml if available. + """Resolve the application port the way the application itself does. - Spring ``server.port`` parity: reads ``pyfly.server.port`` (the former - ``pyfly.web.port`` key was removed in v26.06.102). + Spring ``server.port`` parity: ``pyfly.server.port`` (the former ``pyfly.web.port`` + key was removed in v26.06.102), resolved with the same precedence as ``Config``: + + 1. ``PYFLY_SERVER_PORT`` in the environment — the relaxed-binding override, and + also what ``-D server.port=…`` becomes after ``_build_launch_env``. + 2. The merged configuration for the active profiles (``pyfly.yaml`` plus + ``pyfly-{profile}.yaml`` overlays, in ``config/`` or the project root). + 3. A raw read of ``pyfly.yaml`` if the loader cannot run. + + Before, only step 3 existed: the CLI bound the base port while the application, + which reads the same key through ``Config``, believed the overridden one. The + ``-D server.port=9000`` example in the ``--define`` help text was therefore inert. """ + env_port = os.environ.get("PYFLY_SERVER_PORT") + if env_port is not None and env_port.strip(): + try: + return int(env_port) + except ValueError: + raise click.BadParameter( + f"PYFLY_SERVER_PORT must be an integer, got {env_port!r}", + param_hint="PYFLY_SERVER_PORT / -D server.port", + ) from None + + profiles = [p.strip() for p in os.environ.get("PYFLY_PROFILES_ACTIVE", "").split(",") if p.strip()] + try: + from pyfly.core.config import Config + + merged = Config.from_sources(Path.cwd(), active_profiles=profiles or None, load_defaults=False) + port = merged.get("pyfly.server.port") + if port is not None: + return int(port) + except Exception: # noqa: BLE001 - the loader is best effort here; fall back to the raw file + pass + import yaml config_path = Path("pyfly.yaml") diff --git a/tests/cli/test_run_launch_env.py b/tests/cli/test_run_launch_env.py index be57f333..1442b135 100644 --- a/tests/cli/test_run_launch_env.py +++ b/tests/cli/test_run_launch_env.py @@ -60,3 +60,71 @@ def test_bad_define_raises(self) -> None: with pytest.raises(click.BadParameter): _build_launch_env((), ("noequals",), (), debug=False) + + +class TestReadPortFromConfig: + """``pyfly run`` must resolve the port the way the application does. + + The raw ``pyfly.yaml`` read ignored the relaxed-binding override + ``PYFLY_SERVER_PORT`` (which is also what ``-D server.port=…`` becomes) and the + profile overlays, so the CLI bound the base port while the app believed another. + """ + + @pytest.fixture(autouse=True) + def _clean_env(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: # type: ignore[no-untyped-def] + monkeypatch.delenv("PYFLY_SERVER_PORT", raising=False) + monkeypatch.delenv("PYFLY_PROFILES_ACTIVE", raising=False) + monkeypatch.chdir(tmp_path) + + def test_no_config_file_means_no_port(self) -> None: + from pyfly.cli.run import _read_port_from_config + + assert _read_port_from_config() is None + + def test_reads_server_port_from_pyfly_yaml(self, tmp_path) -> None: # type: ignore[no-untyped-def] + from pyfly.cli.run import _read_port_from_config + + (tmp_path / "pyfly.yaml").write_text("pyfly:\n server:\n port: 8085\n") + assert _read_port_from_config() == 8085 + + def test_env_override_wins_over_yaml(self, tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[no-untyped-def] + from pyfly.cli.run import _read_port_from_config + + (tmp_path / "pyfly.yaml").write_text("pyfly:\n server:\n port: 8085\n") + monkeypatch.setenv("PYFLY_SERVER_PORT", "8090") + assert _read_port_from_config() == 8090 + + def test_env_override_works_without_yaml(self, monkeypatch: pytest.MonkeyPatch) -> None: + from pyfly.cli.run import _read_port_from_config + + monkeypatch.setenv("PYFLY_SERVER_PORT", "8090") + assert _read_port_from_config() == 8090 + + def test_define_flag_reaches_the_port(self, tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[no-untyped-def] + """``-D server.port=9000`` is what the CLI help advertises; it must bind 9000.""" + import os + + from pyfly.cli.run import _read_port_from_config + + (tmp_path / "pyfly.yaml").write_text("pyfly:\n server:\n port: 8085\n") + for key, value in _build_launch_env((), ("server.port=9000",), (), debug=False).items(): + monkeypatch.setenv(key, value) + assert os.environ["PYFLY_SERVER_PORT"] == "9000" + assert _read_port_from_config() == 9000 + + def test_profile_overlay_wins_over_base(self, tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[no-untyped-def] + from pyfly.cli.run import _read_port_from_config + + (tmp_path / "pyfly.yaml").write_text("pyfly:\n server:\n port: 8085\n") + (tmp_path / "pyfly-dev.yaml").write_text("pyfly:\n server:\n port: 8095\n") + monkeypatch.setenv("PYFLY_PROFILES_ACTIVE", "dev") + assert _read_port_from_config() == 8095 + + def test_bad_env_value_is_a_usage_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + import click + + from pyfly.cli.run import _read_port_from_config + + monkeypatch.setenv("PYFLY_SERVER_PORT", "eighty") + with pytest.raises(click.BadParameter): + _read_port_from_config()