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()