From c1c9f53ca9d5786468a50642ff38524709f5c380 Mon Sep 17 00:00:00 2001 From: Cody Fincher <204685+cofin@users.noreply.github.com> Date: Mon, 18 Mar 2024 09:04:26 -0500 Subject: [PATCH] feat: only print when terminal is `TTY` enabled (#3219) --- litestar/cli/_utils.py | 25 +++++-- litestar/cli/commands/core.py | 3 +- tests/unit/test_cli/test_core_commands.py | 84 +++++++++++++++++++---- 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/litestar/cli/_utils.py b/litestar/cli/_utils.py index 29ed4826ea..ac082868eb 100644 --- a/litestar/cli/_utils.py +++ b/litestar/cli/_utils.py @@ -101,8 +101,8 @@ def from_env(cls, app_path: str | None, app_dir: Path | None = None) -> Litestar if app_path and getenv("LITESTAR_APP") is None: os.environ["LITESTAR_APP"] = app_path if app_path: - if not quiet_console: - console.print(f"Using {app_name} from env: [bright_blue]{app_path!r}") + if not quiet_console and isatty(): + console.print(f"Using {app_name} app from env: [bright_blue]{app_path!r}") loaded_app = _load_app_from_path(app_path) else: loaded_app = _autodiscover_app(cwd) @@ -303,6 +303,8 @@ def _autodiscovery_paths(base_dir: Path, arbitrary: bool = True) -> Generator[Pa def _autodiscover_app(cwd: Path) -> LoadedApp: + app_name = getenv("LITESTAR_APP_NAME") or "Litestar" + quiet_console = getenv("LITESTAR_QUIET_CONSOLE") or False for file_path in _autodiscovery_paths(cwd): import_path = _path_to_dotted_path(file_path.relative_to(cwd)) module = importlib.import_module(import_path) @@ -314,13 +316,15 @@ def _autodiscover_app(cwd: Path) -> LoadedApp: if isinstance(value, Litestar): app_string = f"{import_path}:{attr}" os.environ["LITESTAR_APP"] = app_string - console.print(f"Using Litestar app from [bright_blue]{app_string}") + if not quiet_console and isatty(): + console.print(f"Using {app_name} app from [bright_blue]{app_string}") return LoadedApp(app=value, app_path=app_string, is_factory=False) if hasattr(module, "create_app"): app_string = f"{import_path}:create_app" os.environ["LITESTAR_APP"] = app_string - console.print(f"Using Litestar factory [bright_blue]{app_string}") + if not quiet_console and isatty(): + console.print(f"Using {app_name} factory from [bright_blue]{app_string}") return LoadedApp(app=module.create_app(), app_path=app_string, is_factory=True) for attr, value in module.__dict__.items(): @@ -334,10 +338,11 @@ def _autodiscover_app(cwd: Path) -> LoadedApp: if return_annotation in ("Litestar", Litestar): app_string = f"{import_path}:{attr}" os.environ["LITESTAR_APP"] = app_string - console.print(f"Using Litestar factory [bright_blue]{app_string}") + if not quiet_console and sys.stdout.isatty(): + console.print(f"Using {app_name} factory from [bright_blue]{app_string}") return LoadedApp(app=value(), app_path=f"{app_string}", is_factory=True) - raise LitestarCLIException("Could not find a Litestar app or factory") + raise LitestarCLIException(f"Could not find {app_name} instance or factory") def _format_is_enabled(value: Any) -> str: @@ -544,3 +549,11 @@ def remove_default_schema_routes( else openapi_config.openapi_controller.path ) return remove_routes_with_patterns(routes, (schema_path,)) + + +def isatty() -> bool: + """Detect if a terminal is TTY enabled. + + This is a convenience wrapper around the built in system methods. This allows for easier testing of TTY/non-TTY modes. + """ + return sys.stdout.isatty() diff --git a/litestar/cli/commands/core.py b/litestar/cli/commands/core.py index e3273f1d82..803634bc1c 100644 --- a/litestar/cli/commands/core.py +++ b/litestar/cli/commands/core.py @@ -18,6 +18,7 @@ LitestarEnv, console, create_ssl_files, + isatty, remove_default_schema_routes, remove_routes_with_patterns, show_app_info, @@ -253,7 +254,7 @@ def run_command( else validate_ssl_file_paths(ssl_certfile, ssl_keyfile) ) - if not quiet_console: + if not quiet_console and isatty(): console.rule("[yellow]Starting server process", align="left") show_app_info(app) with _server_lifespan(app): diff --git a/tests/unit/test_cli/test_core_commands.py b/tests/unit/test_cli/test_core_commands.py index e6c476bf9c..3400dd328b 100644 --- a/tests/unit/test_cli/test_core_commands.py +++ b/tests/unit/test_cli/test_core_commands.py @@ -14,6 +14,7 @@ from litestar import __version__ as litestar_version from litestar.cli import _utils +from litestar.cli.commands import core from litestar.cli.main import litestar_group as cli_command from litestar.exceptions import LitestarWarning @@ -57,8 +58,11 @@ def mock_show_app_info(mocker: MockerFixture) -> MagicMock: (False, None, None, None, 2), ], ) +@pytest.mark.parametrize("tty_enabled", [True, False]) +@pytest.mark.parametrize("quiet_console", [True, False]) def test_run_command( mock_show_app_info: MagicMock, + mocker: MockerFixture, runner: CliRunner, monkeypatch: MonkeyPatch, reload: Optional[bool], @@ -74,10 +78,17 @@ def test_run_command( custom_app_file: Optional[Path], create_app_file: CreateAppFileFixture, set_in_env: bool, + tty_enabled: bool, + quiet_console: bool, mock_subprocess_run: MagicMock, mock_uvicorn_run: MagicMock, tmp_project_dir: Path, ) -> None: + monkeypatch.delenv("LITESTAR_QUIET_CONSOLE", raising=False) + if quiet_console: + monkeypatch.setenv("LITESTAR_QUIET_CONSOLE", "true") + mocker.patch.object(core, "isatty", return_value=tty_enabled) + mocker.patch.object(_utils, "isatty", return_value=tty_enabled) args = [] if custom_app_file: args.extend(["--app", f"{custom_app_file.stem}:app"]) @@ -194,9 +205,14 @@ def test_run_command( ssl_keyfile=None, ) - mock_show_app_info.assert_called_once() + if tty_enabled and not quiet_console: + mock_show_app_info.assert_called_once() + else: + mock_show_app_info.assert_not_called() +@pytest.mark.parametrize("quiet_console", [True, False]) +@pytest.mark.parametrize("tty_enabled", [True, False]) @pytest.mark.parametrize( "file_name,file_content,factory_name", [ @@ -213,12 +229,20 @@ def test_run_command_with_autodiscover_app_factory( file_content: str, factory_name: str, patch_autodiscovery_paths: Callable[[List[str]], None], + tty_enabled: bool, + quiet_console: bool, create_app_file: CreateAppFileFixture, + mocker: MockerFixture, + monkeypatch: MonkeyPatch, ) -> None: + monkeypatch.delenv("LITESTAR_QUIET_CONSOLE", raising=False) + if quiet_console: + monkeypatch.setenv("LITESTAR_QUIET_CONSOLE", "true") + mocker.patch.object(core, "isatty", return_value=tty_enabled) + mocker.patch.object(_utils, "isatty", return_value=tty_enabled) patch_autodiscovery_paths([file_name]) path = create_app_file(file_name, content=file_content) result = runner.invoke(cli_command, "run") - assert result.exception is None assert result.exit_code == 0 @@ -232,11 +256,28 @@ def test_run_command_with_autodiscover_app_factory( ssl_certfile=None, ssl_keyfile=None, ) + if tty_enabled and not quiet_console: + assert len(result.output) > 0 + else: + assert len(result.output) == 0 +@pytest.mark.parametrize("quiet_console", [True, False]) +@pytest.mark.parametrize("tty_enabled", [True, False]) def test_run_command_with_app_factory( - runner: CliRunner, mock_uvicorn_run: MagicMock, create_app_file: CreateAppFileFixture + runner: CliRunner, + mock_uvicorn_run: MagicMock, + create_app_file: CreateAppFileFixture, + tty_enabled: bool, + quiet_console: bool, + mocker: MockerFixture, + monkeypatch: MonkeyPatch, ) -> None: + monkeypatch.delenv("LITESTAR_QUIET_CONSOLE", raising=False) + if quiet_console: + monkeypatch.setenv("LITESTAR_QUIET_CONSOLE", "true") + mocker.patch.object(core, "isatty", return_value=tty_enabled) + mocker.patch.object(_utils, "isatty", return_value=tty_enabled) path = create_app_file("_create_app_with_path.py", content=CREATE_APP_FILE_CONTENT) app_path = f"{path.stem}:create_app" result = runner.invoke(cli_command, ["--app", app_path, "run"]) @@ -254,6 +295,10 @@ def test_run_command_with_app_factory( ssl_certfile=None, ssl_keyfile=None, ) + if tty_enabled and not quiet_console: + assert len(result.output) > 0 + else: + assert len(result.output) == 0 @pytest.mark.parametrize( @@ -390,9 +435,15 @@ def test_run_command_debug( @pytest.mark.usefixtures("mock_uvicorn_run", "unset_env") def test_run_command_quiet_console( - app_file: Path, runner: CliRunner, monkeypatch: MonkeyPatch, create_app_file: CreateAppFileFixture + app_file: Path, + mocker: MockerFixture, + runner: CliRunner, + monkeypatch: MonkeyPatch, + create_app_file: CreateAppFileFixture, ) -> None: - console = Console(file=io.StringIO()) + mocker.patch.object(core, "isatty", return_value=True) + mocker.patch.object(_utils, "isatty", return_value=True) + console = Console(file=io.StringIO(), force_interactive=True) monkeypatch.setattr(_utils, "console", console) path = create_app_file("_create_app_with_path.py", content=CREATE_APP_FILE_CONTENT) @@ -401,10 +452,10 @@ def test_run_command_quiet_console( result = runner.invoke(cli_command, ["--app", app_path, "run"]) assert result.exit_code == 0 normal_output = console.file.getvalue() # type: ignore[attr-defined] - assert "Using Litestar from env:" in normal_output + assert "Using Litestar app from env:" in normal_output assert "Starting server process" in result.stdout del result - console = Console(file=io.StringIO()) + console = Console(file=io.StringIO(), force_interactive=True) monkeypatch.setattr(_utils, "console", console) monkeypatch.setenv("LITESTAR_QUIET_CONSOLE", "1") assert os.getenv("LITESTAR_QUIET_CONSOLE") == "1" @@ -412,15 +463,22 @@ def test_run_command_quiet_console( assert result.exit_code == 0 quiet_output = console.file.getvalue() # type: ignore[attr-defined] assert "Starting server process" not in result.stdout - assert "Using Litestar from env:" not in quiet_output + assert "Using Litestar app from env:" not in quiet_output console.clear() @pytest.mark.usefixtures("mock_uvicorn_run", "unset_env") def test_run_command_custom_app_name( - app_file: Path, runner: CliRunner, monkeypatch: MonkeyPatch, create_app_file: CreateAppFileFixture + app_file: Path, + runner: CliRunner, + monkeypatch: MonkeyPatch, + create_app_file: CreateAppFileFixture, + mocker: MockerFixture, ) -> None: - console = Console(file=io.StringIO()) + mocker.patch.object(core, "isatty", return_value=True) + mocker.patch.object(_utils, "isatty", return_value=True) + + console = Console(file=io.StringIO(), force_interactive=True) monkeypatch.setattr(_utils, "console", console) path = create_app_file("_create_app_with_path.py", content=CREATE_APP_FILE_CONTENT) @@ -429,15 +487,15 @@ def test_run_command_custom_app_name( result = runner.invoke(cli_command, ["--app", app_path, "run"]) assert result.exit_code == 0 _output = console.file.getvalue() # type: ignore[attr-defined] - assert "Using Litestar from env:" in _output - console = Console(file=io.StringIO()) + assert "Using Litestar app from env:" in _output + console = Console(file=io.StringIO(), force_interactive=True) monkeypatch.setattr(_utils, "console", console) monkeypatch.setenv("LITESTAR_APP_NAME", "My Stuff") assert os.getenv("LITESTAR_APP_NAME") == "My Stuff" result = runner.invoke(cli_command, ["--app", app_path, "run"]) assert result.exit_code == 0 _output = console.file.getvalue() # type: ignore[attr-defined] - assert "Using My Stuff from env:" in _output + assert "Using My Stuff app from env:" in _output @pytest.mark.usefixtures("mock_uvicorn_run", "unset_env")