From 9554ca97d052aab7c1951f5d2f111a948e70677d Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 30 Apr 2026 09:16:15 +0200 Subject: [PATCH] chore: replace [tool.setuptools] script-files with stackvox install-helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `script-files` is setuptools-specific, has no PEP 621 equivalent, and doesn't round-trip through hatchling/uv if we ever migrate build backends. The setuptools docs already mark it as discouraged. This drops it. The bash helper now ships as package data (`stackvox/data/stackvox-say`) and a new `stackvox install-helper` subcommand copies it onto PATH: pipx install stackvox stackvox install-helper # copies bash helper to ~/.local/bin stackvox-say "now use the fast helper" `--prefix DIR` overrides the destination; if the chosen prefix isn't on PATH the command emits a hint pointing at the absolute path. The step is opt-in — users who only need the Python `stackvox say` client can skip it. Trade-off: one extra command after install. Reward: the helper keeps its bash-only, ~13ms-IPC latency (a Python entry point would have added ~150ms of interpreter startup, defeating the design). - tests/test_cli.py: TestCmdInstallHelper covers the copy, the exec-bit, prefix creation, and the on-/off-PATH branches. - README install section explains the opt-in helper step. - bin/ directory removed; helper now lives at stackvox/data/stackvox-say. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 13 +++++- pyproject.toml | 11 +++-- stackvox/cli.py | 71 ++++++++++++++++++++++++++++- {bin => stackvox/data}/stackvox-say | 0 tests/test_cli.py | 39 ++++++++++++++++ 5 files changed, 125 insertions(+), 9 deletions(-) rename {bin => stackvox/data}/stackvox-say (100%) diff --git a/README.md b/README.md index 0becb5f..147bf38 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k From PyPI — recommended for most users: ```bash -pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH) +pipx install stackvox # `stackvox` CLI on PATH # or -pip install stackvox # use as a library +pip install stackvox # use as a library ``` +If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package: + +```bash +stackvox install-helper # copies bash helper to ~/.local/bin + # use --prefix DIR to install elsewhere +``` + +This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client. + From git, if you want an unreleased commit: ```bash diff --git a/pyproject.toml b/pyproject.toml index 75732bc..0000963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,15 +45,16 @@ Issues = "https://github.com/StackOneHQ/stackvox/issues" [project.scripts] stackvox = "stackvox.cli:main" -[tool.setuptools] -script-files = ["bin/stackvox-say"] - [tool.setuptools.packages.find] include = ["stackvox*"] [tool.setuptools.package-data] -# Ship the PEP 561 marker so downstream type checkers trust our inline hints. -stackvox = ["py.typed"] +# Ship the PEP 561 marker so downstream type checkers trust our inline hints, +# and the bash helper as package data — extracted onto PATH by +# `stackvox install-helper`. (We deliberately don't use [tool.setuptools] +# script-files = [...]: it's discouraged, has no PEP 621 equivalent, and +# doesn't round-trip through hatchling/uv if we ever migrate build backends.) +stackvox = ["py.typed", "data/stackvox-say"] [tool.ruff] line-length = 110 diff --git a/stackvox/cli.py b/stackvox/cli.py index 1d40f50..48f11f3 100644 --- a/stackvox/cli.py +++ b/stackvox/cli.py @@ -21,7 +21,19 @@ def _configure_logging() -> None: ) -SUBCOMMANDS = {"serve", "stop", "status", "say", "speak", "voices", "welcome", "completion"} +SUBCOMMANDS = { + "serve", + "stop", + "status", + "say", + "speak", + "voices", + "welcome", + "completion", + "install-helper", +} + +DEFAULT_HELPER_PREFIX = Path.home() / ".local" / "bin" _BASH_COMPLETION = r"""# stackvox bash completion. Install with one of: # eval "$(stackvox completion bash)" # current shell @@ -34,7 +46,7 @@ def _configure_logging() -> None: subcommand="${COMP_WORDS[1]:-}" if [[ ${COMP_CWORD} -eq 1 ]]; then - COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion" -- "$cur") ) + COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") ) return 0 fi @@ -43,6 +55,10 @@ def _configure_logging() -> None: COMPREPLY=( $(compgen -f -- "$cur") ) return 0 ;; + --prefix) + COMPREPLY=( $(compgen -d -- "$cur") ) + return 0 + ;; --speed) COMPREPLY=( $(compgen -W "0.8 0.9 1.0 1.1 1.2 1.5" -- "$cur") ) return 0 @@ -66,6 +82,9 @@ def _configure_logging() -> None: completion) COMPREPLY=( $(compgen -W "bash" -- "$cur") ) ;; + install-helper) + COMPREPLY=( $(compgen -W "--prefix --help" -- "$cur") ) + ;; *) COMPREPLY=( $(compgen -W "--help" -- "$cur") ) ;; @@ -113,6 +132,17 @@ def _build_parser() -> argparse.ArgumentParser: p_completion = sub.add_parser("completion", help="Print a shell completion script") p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for") + p_install_helper = sub.add_parser( + "install-helper", + help="Copy the stackvox-say bash helper onto PATH (default: ~/.local/bin)", + ) + p_install_helper.add_argument( + "--prefix", + type=Path, + default=DEFAULT_HELPER_PREFIX, + help=f"Install directory (default: {DEFAULT_HELPER_PREFIX})", + ) + return parser @@ -221,6 +251,42 @@ def _cmd_completion(args: argparse.Namespace) -> int: return 1 +def _cmd_install_helper(args: argparse.Namespace) -> int: + """Copy the bundled `stackvox-say` bash helper onto PATH. + + The helper is shipped as package data (`stackvox/data/stackvox-say`) rather + than installed automatically by setuptools — `script-files` is discouraged + and doesn't round-trip through modern build backends. This subcommand is + the explicit one-time install step. + """ + import os + import shutil + import stat + from importlib.resources import as_file, files + + prefix = Path(args.prefix).expanduser() + prefix.mkdir(parents=True, exist_ok=True) + dest = prefix / "stackvox-say" + + src = files("stackvox").joinpath("data/stackvox-say") + with as_file(src) as src_path: + shutil.copy2(src_path, dest) + + # Ensure executable bits regardless of how the wheel preserved them. + dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + print(f"Installed stackvox-say to {dest}", file=sys.stderr) + + path_dirs = os.environ.get("PATH", "").split(os.pathsep) + if str(prefix) not in path_dirs: + print( + f"Note: {prefix} is not on your PATH. Either add it to PATH or invoke " + f"the helper via its full path: {dest}", + file=sys.stderr, + ) + return 0 + + def main() -> int: _configure_logging() argv = sys.argv[1:] @@ -247,6 +313,7 @@ def main() -> int: "voices": _cmd_voices, "welcome": _cmd_welcome, "completion": _cmd_completion, + "install-helper": _cmd_install_helper, } return handlers[args.cmd](args) diff --git a/bin/stackvox-say b/stackvox/data/stackvox-say similarity index 100% rename from bin/stackvox-say rename to stackvox/data/stackvox-say diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d4d041..7e7bb96 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -251,3 +251,42 @@ def test_unsupported_shell_returns_one(self, capsys): rc = cli._cmd_completion(_ns(shell="fish")) assert rc == 1 assert "unsupported shell" in capsys.readouterr().err + + +class TestCmdInstallHelper: + def test_copies_helper_to_prefix_with_exec_bit(self, tmp_path): + prefix = tmp_path / "bin" + rc = cli._cmd_install_helper(_ns(prefix=prefix)) + + assert rc == 0 + dest = prefix / "stackvox-say" + assert dest.exists() + # First line should be the bash shebang from the bundled script. + assert dest.read_text(encoding="utf-8").startswith("#!/bin/bash") + # Owner-execute bit is set. + import stat as st + + assert dest.stat().st_mode & st.S_IXUSR + + def test_creates_prefix_dir_if_missing(self, tmp_path): + prefix = tmp_path / "deep" / "nested" / "bin" + assert not prefix.exists() + rc = cli._cmd_install_helper(_ns(prefix=prefix)) + assert rc == 0 + assert (prefix / "stackvox-say").is_file() + + def test_warns_when_prefix_not_on_path(self, tmp_path, mocker, capsys): + prefix = tmp_path / "out-of-path" + # Force PATH to a value that definitely doesn't include `prefix`. + mocker.patch.dict("os.environ", {"PATH": "/usr/bin:/bin"}, clear=False) + cli._cmd_install_helper(_ns(prefix=prefix)) + err = capsys.readouterr().err + assert "not on your PATH" in err + + def test_no_warning_when_prefix_on_path(self, tmp_path, mocker, capsys): + prefix = tmp_path / "on-path" + prefix.mkdir(parents=True) + # Put `prefix` on PATH so the warning shouldn't fire. + mocker.patch.dict("os.environ", {"PATH": f"{prefix}:/usr/bin"}, clear=False) + cli._cmd_install_helper(_ns(prefix=prefix)) + assert "not on your PATH" not in capsys.readouterr().err