diff --git a/docs/development/index.rst b/docs/development/index.rst index 55661b1..ff8a954 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -46,9 +46,9 @@ venvstacks can then be executed via the ``-m`` switch: .. code-block:: console - $ .venv/bin/python -m venvstacks --help + $ .venv/bin/venvstacks --help - Usage: python -m venvstacks [OPTIONS] COMMAND [ARGS]... + Usage: venvstacks [OPTIONS] COMMAND [ARGS]... Lock, build, and publish Python virtual environment stacks. diff --git a/docs/overview.rst b/docs/overview.rst index 7440a1c..6b8211d 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -17,7 +17,14 @@ Installing ---------- ``venvstacks`` is available from the :pypi:`Python Package Index `, -and can be installed with :pypi:`pip`: +and can be installed with :pypi:`pipx` (or similar tools): + +.. code-block:: console + + $ pipx install venvstacks + +Alternatively, it can be installed as a user level package (although this may +make future Python version upgrades more irritating): .. code-block:: console @@ -27,9 +34,9 @@ The command line help also provides additional usage information: .. code-block:: console - $ .venv/bin/python -m venvstacks --help + $ venvstacks --help - Usage: python -m venvstacks [OPTIONS] COMMAND [ARGS]... + Usage: venvstacks [OPTIONS] COMMAND [ARGS]... Lock, build, and publish Python virtual environment stacks. @@ -100,7 +107,7 @@ Locking environment stacks .. code-block:: console - $ python -m venvstacks lock sklearn_demo/venvstacks.toml + $ venvstacks lock sklearn_demo/venvstacks.toml The ``lock`` subcommand takes the defined layer requirements from the specification, and uses them to perform a complete combined resolution of all of the environment stacks @@ -116,7 +123,7 @@ Building environment stacks .. code-block:: console - $ python -m venvstacks build sklearn_demo/venvstacks.toml + $ venvstacks build sklearn_demo/venvstacks.toml The ``build`` subcommand performs the step of converting the layer specifications and their locked requirements into a working Python environment @@ -133,7 +140,7 @@ Publishing environment layer archives .. code-block:: console - $ python -m venvstacks publish --tag-outputs --output-dir demo_artifacts sklearn_demo/venvstacks.toml + $ venvstacks publish --tag-outputs --output-dir demo_artifacts sklearn_demo/venvstacks.toml Once the environments have been successfully built, the ``publish`` command allows each layer to be converted to a separate @@ -154,7 +161,7 @@ Locally exporting environment stacks .. code-block:: console - $ python -m venvstacks local-export --output-dir demo_export sklearn_demo/venvstacks.toml + $ venvstacks local-export --output-dir demo_export sklearn_demo/venvstacks.toml Given that even considering the use of ``venvstacks`` implies that some layer archives may be of significant size (a fully built `pytorch` archive weighs in at multiple gigabytes, for example), diff --git a/pyproject.toml b/pyproject.toml index 13be7f0..f27baa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ rich-cli = [ "typer>=0.12.4", ] +[project.scripts] +venvstacks = "venvstacks.cli:main" + [build-system] requires = ["pdm-backend"] build-backend = "pdm.backend" diff --git a/src/venvstacks/_util.py b/src/venvstacks/_util.py index 0f3ed15..2b70e92 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 +from typing import Any, Generator, Mapping WINDOWS_BUILD = hasattr(os, "add_dll_directory") @@ -78,15 +78,16 @@ def get_env_python(env_path: Path) -> Path: def run_python_command_unchecked( - # Narrow list/dict type specs here due to the way `subprocess.run` params are typed + # Narrow list type spec here due to the way `subprocess.run` params are typed command: list[str], *, - env: dict[str, str] | None = None, + env: Mapping[str, str] | None = None, **kwds: Any, ) -> subprocess.CompletedProcess[str]: - if env is None: - env = os.environ.copy() - env.update(_SUBPROCESS_PYTHON_CONFIG) + run_env = os.environ.copy() + if env is not None: + run_env.update(env) + run_env.update(_SUBPROCESS_PYTHON_CONFIG) result: subprocess.CompletedProcess[str] = subprocess.run( command, env=env, text=True, **kwds ) @@ -94,7 +95,7 @@ def run_python_command_unchecked( def run_python_command( - # Narrow list/dict type specs here due to the way `subprocess.run` params are typed + # Narrow list type spec here due to the way `subprocess.run` params are typed command: list[str], **kwds: Any, ) -> subprocess.CompletedProcess[str]: diff --git a/src/venvstacks/cli.py b/src/venvstacks/cli.py index 5499621..7f21c79 100644 --- a/src/venvstacks/cli.py +++ b/src/venvstacks/cli.py @@ -1,6 +1,7 @@ """Command line interface implementation""" import os.path +import sys from typing import Annotated @@ -8,6 +9,26 @@ from .stacks import StackSpec, BuildEnvironment, _format_json, IndexConfig +# Inspired by the Python 3.13+ `argparse` feature, +# but reports `python -m venvstacks` whenever `__main__` +# refers to something other than the entry point script, +# rather than trying to infer anything from the main +# module's `__spec__` attributes. +_THIS_PACKAGE = __spec__.parent + + +def _get_usage_name() -> str: + exec_name = os.path.basename(sys.argv[0]).removesuffix(".exe") + if exec_name == _THIS_PACKAGE: + # Entry point wrapper, suggest direct execution + return exec_name + # Could be `python -m`, could be the test suite, + # could be something else calling `venvstacks.cli.main`, + # but treat it as `python -m venvstacks` regardless + py_name = os.path.basename(sys.executable).removesuffix(".exe") + return f"{py_name} -m {_THIS_PACKAGE}" + + _cli = typer.Typer( add_completion=False, pretty_exceptions_show_locals=False, @@ -15,7 +36,7 @@ ) -@_cli.callback(name="python -m venvstacks") +@_cli.callback(name=_get_usage_name()) def handle_app_options() -> None: """Lock, build, and publish Python virtual environment stacks.""" # TODO: Handle app logging config via main command level options diff --git a/tests/README.md b/tests/README.md index 1bdf172..8a6224d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -69,7 +69,7 @@ Updating metadata and examining built artifacts To generate a full local sample project build to help debug failures: $ cd /path/to/repo/ - $ pdm run python -m venvstacks build --publish \ + $ pdm run venvstacks build --publish \ tests/sample_project/venvstacks.toml ~/path/to/output/folder This assumes `pdm sync --dev` has been used to set up a local development venv. diff --git a/tests/test_cli_invocation.py b/tests/test_cli_invocation.py index c17ff02..df5a9b9 100644 --- a/tests/test_cli_invocation.py +++ b/tests/test_cli_invocation.py @@ -1,5 +1,8 @@ """Test cases for CLI invocation""" +import subprocess +import sys + from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path @@ -17,6 +20,7 @@ from venvstacks import cli from venvstacks.stacks import BuildEnvironment, EnvironmentLock, IndexConfig +from venvstacks._util import run_python_command_unchecked def report_traceback(exc: BaseException | None) -> str: @@ -128,6 +132,8 @@ def mocked_runner() -> Generator[MockedRunner, None, None]: class TestTopLevelCommand: def test_implicit_help(self, mocked_runner: MockedRunner) -> None: result = mocked_runner.invoke([]) + # Usage message should suggest indirect execution + assert "Usage: python -m 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 @@ -142,8 +148,29 @@ 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" + ) + 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 + # Check operation result last to ensure test results are as informative as possible + assert result.returncode == 0 + -EXPECTED_USAGE_PREFIX = "Usage: python -m venvstacks" +EXPECTED_USAGE_PREFIX = "Usage: python -m venvstacks " EXPECTED_SUBCOMMANDS = ["lock", "build", "local-export", "publish"] NO_SPEC_PATH: list[str] = [] NEEDS_SPEC_PATH = sorted(set(EXPECTED_SUBCOMMANDS) - set(NO_SPEC_PATH))