diff --git a/src/venvstacks/_util.py b/src/venvstacks/_util.py index 2b70e92..2ef5a50 100644 --- a/src/venvstacks/_util.py +++ b/src/venvstacks/_util.py @@ -8,7 +8,7 @@ from contextlib import contextmanager from pathlib import Path -from typing import Any, Generator, Mapping +from typing import Any, Generator, Literal, Mapping, overload WINDOWS_BUILD = hasattr(os, "add_dll_directory") @@ -77,19 +77,43 @@ def get_env_python(env_path: Path) -> Path: } +@overload +def run_python_command_unchecked( + command: list[str], + *, + env: Mapping[str, str] | None = ..., + text: Literal[True]|None = ..., + **kwds: Any +) -> subprocess.CompletedProcess[str]: + ... +@overload +def run_python_command_unchecked( + command: list[str], + *, + env: Mapping[str, str] | None = ..., + text: Literal[False] = ..., + **kwds: Any +) -> subprocess.CompletedProcess[bytes]: + ... def run_python_command_unchecked( - # Narrow list type spec here due to the way `subprocess.run` params are typed command: list[str], *, env: Mapping[str, str] | None = None, + text: bool|None = True, **kwds: Any, -) -> subprocess.CompletedProcess[str]: +) -> subprocess.CompletedProcess[str]|subprocess.CompletedProcess[bytes]: + # Ensure required env vars are passed down on Windows, + # and run Python in isolated mode with UTF-8 as the text encoding run_env = os.environ.copy() if env is not None: run_env.update(env) run_env.update(_SUBPROCESS_PYTHON_CONFIG) + # Default to running in text mode, + # but allow it to be explicitly switched off + text = text if text else False + encoding = "utf-8" if text else None result: subprocess.CompletedProcess[str] = subprocess.run( - command, env=env, text=True, **kwds + command, env=env, text=text, encoding=encoding, **kwds ) return result @@ -99,6 +123,6 @@ def run_python_command( command: list[str], **kwds: Any, ) -> subprocess.CompletedProcess[str]: - result = run_python_command_unchecked(command, **kwds) + result = run_python_command_unchecked(command, text=True, **kwds) result.check_returncode() return result diff --git a/tests/support.py b/tests/support.py index 9327cb7..217ed5b 100644 --- a/tests/support.py +++ b/tests/support.py @@ -3,6 +3,7 @@ import json import os import subprocess +import sys import tomllib from dataclasses import dataclass, fields @@ -10,6 +11,8 @@ from typing import Any, cast, Mapping from unittest.mock import create_autospec +import pytest + from venvstacks._util import run_python_command from venvstacks.stacks import ( BuildEnvironment, @@ -20,6 +23,21 @@ _THIS_DIR = Path(__file__).parent +################################## +# Marking test cases +################################## + +# Basic marking uses the pytest.mark API directly +# See pyproject.toml and tests/README.md for the defined marks + +def requires_venv(description: str) -> pytest.MarkDecorator: + """Skip test case when running tests outside a virtual environment""" + return pytest.mark.skipif( + sys.prefix == sys.base_prefix, + reason=f"{description} requires test execution in venv", + ) + + ################################## # Exporting test artifacts ################################## diff --git a/tests/test_cli_invocation.py b/tests/test_cli_invocation.py index df5a9b9..d3b1a6d 100644 --- a/tests/test_cli_invocation.py +++ b/tests/test_cli_invocation.py @@ -22,6 +22,7 @@ from venvstacks.stacks import BuildEnvironment, EnvironmentLock, IndexConfig from venvstacks._util import run_python_command_unchecked +from support import requires_venv def report_traceback(exc: BaseException | None) -> str: if exc is None: @@ -148,26 +149,41 @@ def test_implicit_help(self, mocked_runner: MockedRunner) -> None: assert result.exception is None, report_traceback(result.exception) assert result.exit_code == 0 - # See https://github.com/lmstudio-ai/venvstacks/issues/42 - @pytest.mark.xfail( - sys.platform == "win32", reason="UnicodeDecodeError parsing output" - ) + @requires_venv("Entry point test") + def test_entry_point_help_raw(self) -> None: + expected_entry_point = Path(sys.executable).parent / "venvstacks" + command = [str(expected_entry_point), "--help"] + result = run_python_command_unchecked( + command, text=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + if result.stdout is not None: + # Usage message should suggest direct execution + assert b"Usage: venvstacks [" in result.stdout + # Top-level callback docstring is used as the overall CLI help text + cli_help = cli.handle_app_options.__doc__ + assert cli_help is not None + assert cli_help.strip().encode() in result.stdout + # Check operation result last to ensure test results are as informative as possible + assert result.returncode == 0 + assert result.stdout is not None + + @requires_venv("Entry point test") def test_entry_point_help(self) -> None: - if sys.prefix == sys.base_prefix: - pytest.skip("Entry point test requires test execution in venv") expected_entry_point = Path(sys.executable).parent / "venvstacks" command = [str(expected_entry_point), "--help"] result = run_python_command_unchecked( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) - # Usage message should suggest direct execution - assert "Usage: venvstacks [" in result.stdout - # Top-level callback docstring is used as the overall CLI help text - cli_help = cli.handle_app_options.__doc__ - assert cli_help is not None - assert cli_help.strip() in result.stdout + if result.stdout is not None: + # Usage message should suggest direct execution + assert "Usage: venvstacks [" in result.stdout + # Top-level callback docstring is used as the overall CLI help text + cli_help = cli.handle_app_options.__doc__ + assert cli_help is not None + assert cli_help.strip() in result.stdout # Check operation result last to ensure test results are as informative as possible assert result.returncode == 0 + assert result.stdout is not None EXPECTED_USAGE_PREFIX = "Usage: python -m venvstacks "