toolrack-cli 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {toolrack_cli-0.1.0/src/toolrack_cli.egg-info → toolrack_cli-0.2.0}/PKG-INFO +68 -3
  2. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/README.md +67 -2
  3. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/pyproject.toml +4 -0
  4. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/__init__.py +1 -1
  5. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/cli.py +116 -0
  6. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/completion.py +11 -0
  7. toolrack_cli-0.2.0/src/toolrack/pytest_plugin.py +55 -0
  8. toolrack_cli-0.2.0/src/toolrack/testing.py +262 -0
  9. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0/src/toolrack_cli.egg-info}/PKG-INFO +68 -3
  10. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/SOURCES.txt +5 -1
  11. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/entry_points.txt +3 -0
  12. toolrack_cli-0.2.0/tests/test_testing.py +130 -0
  13. toolrack_cli-0.2.0/tests/test_validate.py +106 -0
  14. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/LICENSE +0 -0
  15. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/setup.cfg +0 -0
  16. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/__main__.py +0 -0
  17. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/entrypoint.py +0 -0
  18. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/runtime.py +0 -0
  19. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/dependency_links.txt +0 -0
  20. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/requires.txt +0 -0
  21. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/top_level.txt +0 -0
  22. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/tests/test_cli.py +0 -0
  23. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/tests/test_completion.py +0 -0
  24. {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/tests/test_cross_shell.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toolrack-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Turn a folder of small scripts plus YAML sidecars into one typed, completable CLI
5
5
  Author: Jan Chimiak
6
6
  License-Expression: MIT
@@ -33,6 +33,9 @@ and builds a typed Click CLI at runtime.
33
33
  If you want the starter repository that uses this engine, see
34
34
  [`toolrack-template`](https://github.com/anfit/toolrack-template).
35
35
 
36
+ [![PyPI](https://img.shields.io/pypi/v/toolrack-cli)](https://pypi.org/project/toolrack-cli/)
37
+ [![Unit tests](https://github.com/anfit/toolrack/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/anfit/toolrack/actions/workflows/unit-tests.yml)
38
+
36
39
  ![Animated toolrack walkthrough: register, discover, and run scripts](docs/assets/toolrack-demo.gif)
37
40
 
38
41
  ## Quickstart
@@ -127,10 +130,72 @@ Which also makes the AI prompt much nicer:
127
130
 
128
131
  > Hey AI, make me one like this, but mauve.
129
132
 
133
+ ## Testing Your Tools
134
+
135
+ Installing `toolrack-cli` also gives your test suite a pytest plugin. It runs the
136
+ real CLI in a subprocess against a repository, so tests cover what users run:
137
+ sidecar parsing, argument mapping, exit codes and the script itself. Python,
138
+ Bash and SQL scripts are all supported.
139
+
140
+ **Check the whole repo in CI.** `core validate` is read-only and exits non-zero
141
+ on a missing script or sidecar, an invalid sidecar, a bad `aliases.cfg`, or two
142
+ scripts that map to the same command. Unregistered sidecar pairs and a stale
143
+ cache are warnings; `--strict` makes them failures.
144
+
145
+ ```sh
146
+ my core validate --strict
147
+ ```
148
+
149
+ **Or from pytest**, against your real repo (`toolrack_project`) or a throwaway
150
+ one (`toolrack_repo`):
151
+
152
+ ```python
153
+ def test_repo_is_valid(toolrack_project):
154
+ toolrack_project.validate(strict=True) # AssertionError lists every problem
155
+
156
+ def test_python_tool(toolrack_repo):
157
+ toolrack_repo.add_script(
158
+ "greet/hello.py",
159
+ "import sys\nprint('hi', sys.argv[2])\n",
160
+ {"description": "Say hi.", "args": [{"name": "name", "required": True}]},
161
+ )
162
+ assert toolrack_repo.run("greet", "hello", "--name", "Ada").check().stdout == "hi Ada\n"
163
+
164
+ def test_bash_tool(toolrack_repo, toolrack_require_bash): # skips without bash
165
+ toolrack_repo.add_script("ops/fail.sh", "exit 3\n", {"description": "Fails."})
166
+ assert toolrack_repo.run("ops", "fail").returncode == 3
167
+
168
+ def test_sql_tool(toolrack_repo, toolrack_require_posix): # no database needed
169
+ psql = toolrack_repo.stub_executable("psql", record_env=("PGDATABASE",))
170
+ script = toolrack_repo.add_script("db/ping.sql", "SELECT 1;", {"description": "Ping."})
171
+ toolrack_repo.run("db", "ping", env={"PGDATABASE": "dev"}).check()
172
+ assert psql.calls()[0]["argv"] == ["-f", str(script)]
173
+ ```
174
+
175
+ SQL scripts run as `psql -f <file>`, so `stub_executable` puts a recording fake
176
+ `psql` first on `PATH` instead of needing a database. It works on POSIX hosts
177
+ only. `ToolrackRepo` is in `toolrack.testing` if you prefer to use it without
178
+ the fixtures.
179
+
180
+ ## Migrating From a Checked-Out toolrack
181
+
182
+ If you installed the engine editable from a clone (`pip install -e path/to/toolrack`,
183
+ distribution name `toolrack`), switch your scripts repo to the PyPI release:
184
+
185
+ ```sh
186
+ python3 ./sync_toolrack.py # refresh setup_toolrack.py
187
+ python3 ./setup_toolrack.py --yes --force --cli-name <your-command>
188
+ ```
189
+
190
+ Setup removes the old editable install, installs `toolrack-cli`, rewrites the
191
+ wrappers, and regenerates completion. Full steps, plus the manual route, are in
192
+ the [toolrack-template README](https://github.com/anfit/toolrack-template#migrating-from-a-checked-out-toolrack).
193
+
130
194
  ## Deployment Model
131
195
 
132
- `toolrack` is meant to be installed into a scripts repository and launched
133
- through a repo-local wrapper in `bin/`.
196
+ `toolrack` is meant to be installed into a scripts repository's virtualenv
197
+ (`pip install toolrack-cli`) and launched through a repo-local wrapper in `bin/`.
198
+ The PyPI distribution is `toolrack-cli`; the package and command are `toolrack`.
134
199
 
135
200
  The wrapper is responsible for:
136
201
 
@@ -7,6 +7,9 @@ and builds a typed Click CLI at runtime.
7
7
  If you want the starter repository that uses this engine, see
8
8
  [`toolrack-template`](https://github.com/anfit/toolrack-template).
9
9
 
10
+ [![PyPI](https://img.shields.io/pypi/v/toolrack-cli)](https://pypi.org/project/toolrack-cli/)
11
+ [![Unit tests](https://github.com/anfit/toolrack/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/anfit/toolrack/actions/workflows/unit-tests.yml)
12
+
10
13
  ![Animated toolrack walkthrough: register, discover, and run scripts](docs/assets/toolrack-demo.gif)
11
14
 
12
15
  ## Quickstart
@@ -101,10 +104,72 @@ Which also makes the AI prompt much nicer:
101
104
 
102
105
  > Hey AI, make me one like this, but mauve.
103
106
 
107
+ ## Testing Your Tools
108
+
109
+ Installing `toolrack-cli` also gives your test suite a pytest plugin. It runs the
110
+ real CLI in a subprocess against a repository, so tests cover what users run:
111
+ sidecar parsing, argument mapping, exit codes and the script itself. Python,
112
+ Bash and SQL scripts are all supported.
113
+
114
+ **Check the whole repo in CI.** `core validate` is read-only and exits non-zero
115
+ on a missing script or sidecar, an invalid sidecar, a bad `aliases.cfg`, or two
116
+ scripts that map to the same command. Unregistered sidecar pairs and a stale
117
+ cache are warnings; `--strict` makes them failures.
118
+
119
+ ```sh
120
+ my core validate --strict
121
+ ```
122
+
123
+ **Or from pytest**, against your real repo (`toolrack_project`) or a throwaway
124
+ one (`toolrack_repo`):
125
+
126
+ ```python
127
+ def test_repo_is_valid(toolrack_project):
128
+ toolrack_project.validate(strict=True) # AssertionError lists every problem
129
+
130
+ def test_python_tool(toolrack_repo):
131
+ toolrack_repo.add_script(
132
+ "greet/hello.py",
133
+ "import sys\nprint('hi', sys.argv[2])\n",
134
+ {"description": "Say hi.", "args": [{"name": "name", "required": True}]},
135
+ )
136
+ assert toolrack_repo.run("greet", "hello", "--name", "Ada").check().stdout == "hi Ada\n"
137
+
138
+ def test_bash_tool(toolrack_repo, toolrack_require_bash): # skips without bash
139
+ toolrack_repo.add_script("ops/fail.sh", "exit 3\n", {"description": "Fails."})
140
+ assert toolrack_repo.run("ops", "fail").returncode == 3
141
+
142
+ def test_sql_tool(toolrack_repo, toolrack_require_posix): # no database needed
143
+ psql = toolrack_repo.stub_executable("psql", record_env=("PGDATABASE",))
144
+ script = toolrack_repo.add_script("db/ping.sql", "SELECT 1;", {"description": "Ping."})
145
+ toolrack_repo.run("db", "ping", env={"PGDATABASE": "dev"}).check()
146
+ assert psql.calls()[0]["argv"] == ["-f", str(script)]
147
+ ```
148
+
149
+ SQL scripts run as `psql -f <file>`, so `stub_executable` puts a recording fake
150
+ `psql` first on `PATH` instead of needing a database. It works on POSIX hosts
151
+ only. `ToolrackRepo` is in `toolrack.testing` if you prefer to use it without
152
+ the fixtures.
153
+
154
+ ## Migrating From a Checked-Out toolrack
155
+
156
+ If you installed the engine editable from a clone (`pip install -e path/to/toolrack`,
157
+ distribution name `toolrack`), switch your scripts repo to the PyPI release:
158
+
159
+ ```sh
160
+ python3 ./sync_toolrack.py # refresh setup_toolrack.py
161
+ python3 ./setup_toolrack.py --yes --force --cli-name <your-command>
162
+ ```
163
+
164
+ Setup removes the old editable install, installs `toolrack-cli`, rewrites the
165
+ wrappers, and regenerates completion. Full steps, plus the manual route, are in
166
+ the [toolrack-template README](https://github.com/anfit/toolrack-template#migrating-from-a-checked-out-toolrack).
167
+
104
168
  ## Deployment Model
105
169
 
106
- `toolrack` is meant to be installed into a scripts repository and launched
107
- through a repo-local wrapper in `bin/`.
170
+ `toolrack` is meant to be installed into a scripts repository's virtualenv
171
+ (`pip install toolrack-cli`) and launched through a repo-local wrapper in `bin/`.
172
+ The PyPI distribution is `toolrack-cli`; the package and command are `toolrack`.
108
173
 
109
174
  The wrapper is responsible for:
110
175
 
@@ -39,6 +39,10 @@ dev = [
39
39
  [project.scripts]
40
40
  toolrack = "toolrack.entrypoint:main"
41
41
 
42
+ # Fixtures for testing your own scripts repo; inert unless a test requests them.
43
+ [project.entry-points.pytest11]
44
+ toolrack = "toolrack.pytest_plugin"
45
+
42
46
  [tool.setuptools.dynamic]
43
47
  version = { attr = "toolrack.__version__" }
44
48
 
@@ -1,3 +1,3 @@
1
1
  """toolrack - typed dispatcher for a user-owned scripts repository."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -853,6 +853,122 @@ def cmd_reregister():
853
853
  click.echo(f"\n{ok} ok, {errors} errors.")
854
854
 
855
855
 
856
+ @dataclass(frozen=True)
857
+ class Diagnostic:
858
+ level: str # "error" or "warning"
859
+ subject: str
860
+ message: str
861
+
862
+
863
+ def _check_repository(repo: RepositoryContext) -> tuple[list[str], list[Diagnostic]]:
864
+ """Inspect the whole repository without writing anything.
865
+
866
+ Returns the registry entries that are fully valid and the diagnostics for
867
+ everything else. Unlike discovery at runtime (which skips broken entries
868
+ with a warning), this reports every problem so CI can fail on it.
869
+ """
870
+ import yaml
871
+
872
+ diagnostics: list[Diagnostic] = []
873
+ ok: list[str] = []
874
+ registry = _read_registry(repo)
875
+
876
+ try:
877
+ aliases = _load_aliases(repo)
878
+ except RuntimeError as exc:
879
+ diagnostics.append(Diagnostic("error", os.path.basename(repo.aliases_file), str(exc)))
880
+ aliases = {}
881
+
882
+ claimed: dict[tuple[str, ...], str] = {}
883
+ for entry in registry:
884
+ abs_path = _abs_script_path(entry, repo)
885
+ if not os.path.isfile(abs_path):
886
+ diagnostics.append(Diagnostic("error", entry, "registered script not found"))
887
+ continue
888
+ ext = os.path.splitext(abs_path)[1].lower()
889
+ if ext not in _RUNNABLE_EXTS:
890
+ diagnostics.append(Diagnostic("error", entry, f"unsupported extension '{ext}'"))
891
+ continue
892
+ try:
893
+ sidecar = _load_sidecar(abs_path)
894
+ if sidecar is None:
895
+ diagnostics.append(
896
+ Diagnostic("error", entry, f"sidecar missing (run: {CLI_NAME} core register {entry})")
897
+ )
898
+ continue
899
+ _validate_sidecar(sidecar, abs_path)
900
+ group_parts, cmd_name, _ = _group_path_and_cmd(entry, aliases, repo)
901
+ _command_spec(abs_path, cmd_name, sidecar)
902
+ except (RuntimeError, ValueError, yaml.YAMLError) as exc:
903
+ diagnostics.append(Diagnostic("error", entry, str(exc)))
904
+ continue
905
+
906
+ key = (*group_parts, cmd_name)
907
+ if key in claimed:
908
+ diagnostics.append(
909
+ Diagnostic(
910
+ "error",
911
+ entry,
912
+ f"command '{' '.join(key)}' is already provided by {claimed[key]}",
913
+ )
914
+ )
915
+ continue
916
+ claimed[key] = entry
917
+ ok.append(entry)
918
+
919
+ registered = set(registry)
920
+ for dirpath, dirnames, filenames in os.walk(repo.scripts_root):
921
+ dirnames[:] = sorted(
922
+ name for name in dirnames if name not in _SKIP_DIRS and not name.startswith(".")
923
+ )
924
+ for filename in sorted(filenames):
925
+ if not filename.endswith(".yml"):
926
+ continue
927
+ stem = os.path.join(dirpath, os.path.splitext(filename)[0])
928
+ for script_ext in sorted(_RUNNABLE_EXTS):
929
+ if os.path.isfile(stem + script_ext):
930
+ canonical = _resolve_script_path(stem + script_ext, repo)
931
+ if canonical not in registered:
932
+ diagnostics.append(
933
+ Diagnostic(
934
+ "warning",
935
+ canonical,
936
+ f"has a sidecar but is not registered (run: {CLI_NAME} core auto-register)",
937
+ )
938
+ )
939
+ break
940
+
941
+ if registry and _read_cache(registry, repo) is None:
942
+ diagnostics.append(
943
+ Diagnostic(
944
+ "warning",
945
+ os.path.basename(repo.cache_file),
946
+ f"cache is missing or stale (run: {CLI_NAME} core refresh-cache)",
947
+ )
948
+ )
949
+ return ok, diagnostics
950
+
951
+
952
+ @core.command("validate")
953
+ @click.option("--strict", is_flag=True, help="Treat warnings as errors.")
954
+ def cmd_validate(strict: bool):
955
+ """Check registry, sidecars and aliases without changing anything (for CI)."""
956
+ ok, diagnostics = _check_repository(_current_repo())
957
+ for entry in ok:
958
+ click.echo(f" OK {entry}")
959
+ errors = warnings = 0
960
+ for item in diagnostics:
961
+ if item.level == "error":
962
+ errors += 1
963
+ click.echo(f" ERROR {item.subject}: {item.message}", err=True)
964
+ else:
965
+ warnings += 1
966
+ click.echo(f" WARNING {item.subject}: {item.message}", err=True)
967
+ click.echo(f"\n{len(ok)} ok, {errors} errors, {warnings} warnings.")
968
+ if errors or (strict and warnings):
969
+ raise click.exceptions.Exit(1)
970
+
971
+
856
972
  @core.command("refresh-cache")
857
973
  def cmd_refresh_cache():
858
974
  """Rebuild the command metadata cache next to the registry."""
@@ -54,6 +54,17 @@ CORE_COMMANDS: dict[str, dict] = {
54
54
  "args": [{"name": "script_path", "positional": True, "required": True}],
55
55
  },
56
56
  "reregister": {"description": "Re-validate all registered commands."},
57
+ "validate": {
58
+ "description": "Check registry, sidecars and aliases without changing anything (for CI).",
59
+ "args": [
60
+ {
61
+ "name": "strict",
62
+ "flag": True,
63
+ "option": "--strict",
64
+ "help": "Treat warnings as errors.",
65
+ }
66
+ ],
67
+ },
57
68
  "refresh-cache": {"description": "Rebuild the command metadata cache next to the registry."},
58
69
  "list": {"description": "List all registered scripts."},
59
70
  "install-completion": {
@@ -0,0 +1,55 @@
1
+ """pytest fixtures for testing a toolrack scripts repository.
2
+
3
+ Registered through the ``pytest11`` entry point, so installing ``toolrack-cli``
4
+ makes these fixtures available in any project's tests:
5
+
6
+ ``toolrack_project``
7
+ The real repository around the test run (found by walking up from the
8
+ current directory, like the CLI does). Use it to validate and smoke-test
9
+ your actual scripts, e.g. ``toolrack_project.validate(strict=True)``.
10
+
11
+ ``toolrack_repo``
12
+ A fresh, empty temporary repository for tests that need controlled
13
+ scripts and sidecars.
14
+
15
+ Both return :class:`toolrack.testing.ToolrackRepo`, which runs the real CLI in
16
+ a subprocess.
17
+
18
+ ``toolrack_require_bash``
19
+ Request it (or use ``@pytest.mark.usefixtures``) in tests of ``.sh`` /
20
+ ``.bash`` scripts: skips the test when no working Bash is available.
21
+
22
+ ``toolrack_require_posix``
23
+ Skips on Windows; for tests that use ``stub_executable`` (for example a
24
+ fake ``psql`` to test ``.sql`` scripts).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+
31
+ import pytest
32
+
33
+ from toolrack.testing import ToolrackRepo, bash_available
34
+
35
+
36
+ @pytest.fixture()
37
+ def toolrack_project(request) -> ToolrackRepo:
38
+ return ToolrackRepo.discover(request.config.rootpath)
39
+
40
+
41
+ @pytest.fixture()
42
+ def toolrack_repo(tmp_path) -> ToolrackRepo:
43
+ return ToolrackRepo.create(tmp_path / "repo")
44
+
45
+
46
+ @pytest.fixture(scope="session")
47
+ def toolrack_require_bash() -> None:
48
+ if not bash_available():
49
+ pytest.skip("no working bash available")
50
+
51
+
52
+ @pytest.fixture()
53
+ def toolrack_require_posix() -> None:
54
+ if os.name == "nt":
55
+ pytest.skip("requires a POSIX host")
@@ -0,0 +1,262 @@
1
+ """Helpers for testing a toolrack scripts repository.
2
+
3
+ These run the real CLI in a subprocess (``python -m toolrack``) with the
4
+ ``TOOLRACK_*`` environment pointing at a repository, so a test exercises exactly
5
+ what a user would run, including sidecar parsing, argument handling and the
6
+ script itself. Nothing is monkeypatched.
7
+
8
+ repo = ToolrackRepo.create(tmp_path)
9
+ repo.add_script("greet/hello.py", "print('hi')", {"description": "Say hi."})
10
+ assert repo.run("greet", "hello").stdout.strip() == "hi"
11
+ repo.validate() # raises AssertionError listing every problem
12
+
13
+ The pytest fixtures in :mod:`toolrack.pytest_plugin` wrap these.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import NamedTuple
23
+
24
+
25
+ class RunResult(NamedTuple):
26
+ args: tuple[str, ...]
27
+ returncode: int
28
+ stdout: str
29
+ stderr: str
30
+
31
+ @property
32
+ def ok(self) -> bool:
33
+ return self.returncode == 0
34
+
35
+ def check(self) -> "RunResult":
36
+ """Return self, or raise AssertionError with full output on failure."""
37
+ if not self.ok:
38
+ raise AssertionError(
39
+ f"toolrack {' '.join(self.args)} exited {self.returncode}\n"
40
+ f"--- stdout ---\n{self.stdout}\n--- stderr ---\n{self.stderr}"
41
+ )
42
+ return self
43
+
44
+
45
+ _DEFAULT_CONTENT = {
46
+ ".py": "print('ok')\n",
47
+ ".sh": "#!/usr/bin/env bash\necho ok\n",
48
+ ".bash": "#!/usr/bin/env bash\necho ok\n",
49
+ ".sql": "SELECT 1;\n",
50
+ }
51
+
52
+
53
+ def bash_available() -> bool:
54
+ """True when a working ``bash`` can run scripts.
55
+
56
+ Checks by running it: on Windows ``bash`` on PATH is often the WSL stub,
57
+ which exists but fails when no distribution is installed.
58
+ """
59
+ from toolrack import cli
60
+
61
+ try:
62
+ done = subprocess.run(
63
+ [cli._resolve_bash_exe(), "-c", "echo toolrack"],
64
+ capture_output=True,
65
+ text=True,
66
+ timeout=15,
67
+ check=False,
68
+ )
69
+ except (OSError, subprocess.TimeoutExpired):
70
+ return False
71
+ return done.returncode == 0 and done.stdout.strip() == "toolrack"
72
+
73
+
74
+ class StubCommand:
75
+ """A fake executable on the test PATH that records how it was called."""
76
+
77
+ def __init__(self, path: Path, log: Path) -> None:
78
+ self.path = path
79
+ self._log = log
80
+
81
+ def calls(self) -> list[dict]:
82
+ """Every invocation so far: ``{"argv": [...], "env": {...}}``."""
83
+ import json
84
+
85
+ if not self._log.is_file():
86
+ return []
87
+ return [json.loads(line) for line in self._log.read_text(encoding="utf-8").splitlines() if line]
88
+
89
+
90
+ class ToolrackRepo:
91
+ """A scripts repository (real or temporary) driven through the real CLI.
92
+
93
+ Supports every script kind toolrack runs: Python (``.py``), Bash
94
+ (``.sh``/``.bash``) and SQL (``.sql``, which toolrack runs as
95
+ ``psql -f <file>``; fake ``psql`` with :meth:`stub_executable`).
96
+ """
97
+
98
+ def __init__(self, root: str | os.PathLike[str], cli_name: str = "toolrack") -> None:
99
+ self.root = Path(root).resolve()
100
+ self.cli_name = cli_name
101
+
102
+ # -- construction -----------------------------------------------------
103
+
104
+ @classmethod
105
+ def create(cls, root: str | os.PathLike[str]) -> "ToolrackRepo":
106
+ """Make an empty repository (``scripts/`` plus an empty registry)."""
107
+ repo = cls(root)
108
+ repo.scripts.mkdir(parents=True, exist_ok=True)
109
+ repo.registry.touch()
110
+ return repo
111
+
112
+ @classmethod
113
+ def discover(cls, start: str | os.PathLike[str] | None = None) -> "ToolrackRepo":
114
+ """Find the repository containing ``start`` (default: cwd), like the CLI does."""
115
+ from toolrack import runtime
116
+
117
+ cwd = os.getcwd()
118
+ try:
119
+ if start is not None:
120
+ os.chdir(start)
121
+ return cls(runtime.resolve_repository().repo_root)
122
+ finally:
123
+ os.chdir(cwd)
124
+
125
+ # -- layout -----------------------------------------------------------
126
+
127
+ @property
128
+ def scripts(self) -> Path:
129
+ return self.root / "scripts"
130
+
131
+ @property
132
+ def registry(self) -> Path:
133
+ return self.root / ".toolrack"
134
+
135
+ @property
136
+ def aliases(self) -> Path:
137
+ return self.root / "aliases.cfg"
138
+
139
+ def registered(self) -> list[str]:
140
+ if not self.registry.is_file():
141
+ return []
142
+ lines = (line.strip() for line in self.registry.read_text(encoding="utf-8").splitlines())
143
+ return [line for line in lines if line and not line.startswith("#")]
144
+
145
+ # -- building a repo --------------------------------------------------
146
+
147
+ def add_script(
148
+ self,
149
+ rel_path: str,
150
+ content: str | None = None,
151
+ sidecar: dict | None = None,
152
+ register: bool = True,
153
+ ) -> Path:
154
+ """Write a script (and optional YAML sidecar) under ``scripts/``.
155
+
156
+ ``sidecar`` is dumped as the adjacent ``.yml``. With ``register=True``
157
+ the repo-relative path is appended to the registry. Writing files
158
+ directly keeps this independent of the commands under test.
159
+ """
160
+ import yaml
161
+
162
+ path = self.scripts / rel_path
163
+ path.parent.mkdir(parents=True, exist_ok=True)
164
+ if content is None:
165
+ content = _DEFAULT_CONTENT.get(path.suffix.lower(), "")
166
+ # newline="\n": Bash must never see CRLF, whatever the host OS.
167
+ path.write_text(content, encoding="utf-8", newline="\n")
168
+ if sidecar is not None:
169
+ path.with_suffix(".yml").write_text(
170
+ yaml.safe_dump(sidecar, sort_keys=False, allow_unicode=True), encoding="utf-8"
171
+ )
172
+ if register:
173
+ entry = path.relative_to(self.root).as_posix()
174
+ if entry not in self.registered():
175
+ with open(self.registry, "a", encoding="utf-8") as handle:
176
+ handle.write(entry + "\n")
177
+ return path
178
+
179
+ def write_aliases(self, groups: dict[str, str]) -> Path:
180
+ lines = ["[groups]"] + [f"{name} = {alias}" for name, alias in groups.items()]
181
+ self.aliases.write_text("\n".join(lines) + "\n", encoding="utf-8")
182
+ return self.aliases
183
+
184
+ def stub_executable(
185
+ self, name: str, stdout: str = "", exit_code: int = 0, record_env: tuple[str, ...] = ()
186
+ ) -> StubCommand:
187
+ """Put a fake ``name`` first on PATH for :meth:`run`, recording each call.
188
+
189
+ Use it for tools your scripts or toolrack shell out to, most notably
190
+ ``psql`` for ``.sql`` scripts, so tests need no real database.
191
+ ``record_env`` lists environment variables to capture per call (for
192
+ example ``("PGDATABASE",)``). POSIX only.
193
+ """
194
+ if os.name == "nt":
195
+ raise NotImplementedError(
196
+ "stub_executable needs a POSIX host: Windows cannot launch a script shim "
197
+ "by bare name without a compiled .exe"
198
+ )
199
+ bin_dir = self.root / ".toolrack-test-bin"
200
+ bin_dir.mkdir(exist_ok=True)
201
+ log = bin_dir / f"{name}.calls.jsonl"
202
+ shim = bin_dir / name
203
+ shim.write_text(
204
+ f"#!{sys.executable}\n"
205
+ "import json, os, sys\n"
206
+ f"record = {{'argv': sys.argv[1:], 'env': {{k: os.environ.get(k) for k in {list(record_env)!r}}}}}\n"
207
+ f"open({str(log)!r}, 'a', encoding='utf-8').write(json.dumps(record) + '\\n')\n"
208
+ f"sys.stdout.write({stdout!r})\n"
209
+ f"sys.exit({exit_code})\n",
210
+ encoding="utf-8",
211
+ newline="\n",
212
+ )
213
+ shim.chmod(0o755)
214
+ return StubCommand(shim, log)
215
+
216
+ # -- running ----------------------------------------------------------
217
+
218
+ def env(self, extra: dict[str, str] | None = None) -> dict[str, str]:
219
+ env = {
220
+ key: value
221
+ for key, value in os.environ.items()
222
+ if not key.startswith("TOOLRACK_") and not key.endswith("_COMPLETE")
223
+ }
224
+ env.update(
225
+ TOOLRACK_CLI_NAME=self.cli_name,
226
+ TOOLRACK_REPO_ROOT=str(self.root),
227
+ TOOLRACK_SCRIPTS_ROOT=str(self.scripts),
228
+ TOOLRACK_REGISTRY_FILE=str(self.registry),
229
+ PYTHONIOENCODING="utf-8",
230
+ )
231
+ stubs = self.root / ".toolrack-test-bin"
232
+ if stubs.is_dir():
233
+ env["PATH"] = str(stubs) + os.pathsep + env.get("PATH", "")
234
+ env.update(extra or {})
235
+ return env
236
+
237
+ def run(
238
+ self,
239
+ *args: str,
240
+ input: str | None = None,
241
+ env: dict[str, str] | None = None,
242
+ timeout: float = 60,
243
+ ) -> RunResult:
244
+ """Run ``toolrack <args>`` against this repository."""
245
+ completed = subprocess.run(
246
+ [sys.executable, "-m", "toolrack", *args],
247
+ input=input,
248
+ capture_output=True,
249
+ text=True,
250
+ encoding="utf-8",
251
+ errors="replace",
252
+ env=self.env(env),
253
+ cwd=self.root,
254
+ timeout=timeout,
255
+ check=False,
256
+ )
257
+ return RunResult(args, completed.returncode, completed.stdout, completed.stderr)
258
+
259
+ def validate(self, strict: bool = False) -> RunResult:
260
+ """Run ``core validate``; raise AssertionError listing problems if it fails."""
261
+ args = ["core", "validate", *(["--strict"] if strict else [])]
262
+ return self.run(*args).check()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toolrack-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Turn a folder of small scripts plus YAML sidecars into one typed, completable CLI
5
5
  Author: Jan Chimiak
6
6
  License-Expression: MIT
@@ -33,6 +33,9 @@ and builds a typed Click CLI at runtime.
33
33
  If you want the starter repository that uses this engine, see
34
34
  [`toolrack-template`](https://github.com/anfit/toolrack-template).
35
35
 
36
+ [![PyPI](https://img.shields.io/pypi/v/toolrack-cli)](https://pypi.org/project/toolrack-cli/)
37
+ [![Unit tests](https://github.com/anfit/toolrack/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/anfit/toolrack/actions/workflows/unit-tests.yml)
38
+
36
39
  ![Animated toolrack walkthrough: register, discover, and run scripts](docs/assets/toolrack-demo.gif)
37
40
 
38
41
  ## Quickstart
@@ -127,10 +130,72 @@ Which also makes the AI prompt much nicer:
127
130
 
128
131
  > Hey AI, make me one like this, but mauve.
129
132
 
133
+ ## Testing Your Tools
134
+
135
+ Installing `toolrack-cli` also gives your test suite a pytest plugin. It runs the
136
+ real CLI in a subprocess against a repository, so tests cover what users run:
137
+ sidecar parsing, argument mapping, exit codes and the script itself. Python,
138
+ Bash and SQL scripts are all supported.
139
+
140
+ **Check the whole repo in CI.** `core validate` is read-only and exits non-zero
141
+ on a missing script or sidecar, an invalid sidecar, a bad `aliases.cfg`, or two
142
+ scripts that map to the same command. Unregistered sidecar pairs and a stale
143
+ cache are warnings; `--strict` makes them failures.
144
+
145
+ ```sh
146
+ my core validate --strict
147
+ ```
148
+
149
+ **Or from pytest**, against your real repo (`toolrack_project`) or a throwaway
150
+ one (`toolrack_repo`):
151
+
152
+ ```python
153
+ def test_repo_is_valid(toolrack_project):
154
+ toolrack_project.validate(strict=True) # AssertionError lists every problem
155
+
156
+ def test_python_tool(toolrack_repo):
157
+ toolrack_repo.add_script(
158
+ "greet/hello.py",
159
+ "import sys\nprint('hi', sys.argv[2])\n",
160
+ {"description": "Say hi.", "args": [{"name": "name", "required": True}]},
161
+ )
162
+ assert toolrack_repo.run("greet", "hello", "--name", "Ada").check().stdout == "hi Ada\n"
163
+
164
+ def test_bash_tool(toolrack_repo, toolrack_require_bash): # skips without bash
165
+ toolrack_repo.add_script("ops/fail.sh", "exit 3\n", {"description": "Fails."})
166
+ assert toolrack_repo.run("ops", "fail").returncode == 3
167
+
168
+ def test_sql_tool(toolrack_repo, toolrack_require_posix): # no database needed
169
+ psql = toolrack_repo.stub_executable("psql", record_env=("PGDATABASE",))
170
+ script = toolrack_repo.add_script("db/ping.sql", "SELECT 1;", {"description": "Ping."})
171
+ toolrack_repo.run("db", "ping", env={"PGDATABASE": "dev"}).check()
172
+ assert psql.calls()[0]["argv"] == ["-f", str(script)]
173
+ ```
174
+
175
+ SQL scripts run as `psql -f <file>`, so `stub_executable` puts a recording fake
176
+ `psql` first on `PATH` instead of needing a database. It works on POSIX hosts
177
+ only. `ToolrackRepo` is in `toolrack.testing` if you prefer to use it without
178
+ the fixtures.
179
+
180
+ ## Migrating From a Checked-Out toolrack
181
+
182
+ If you installed the engine editable from a clone (`pip install -e path/to/toolrack`,
183
+ distribution name `toolrack`), switch your scripts repo to the PyPI release:
184
+
185
+ ```sh
186
+ python3 ./sync_toolrack.py # refresh setup_toolrack.py
187
+ python3 ./setup_toolrack.py --yes --force --cli-name <your-command>
188
+ ```
189
+
190
+ Setup removes the old editable install, installs `toolrack-cli`, rewrites the
191
+ wrappers, and regenerates completion. Full steps, plus the manual route, are in
192
+ the [toolrack-template README](https://github.com/anfit/toolrack-template#migrating-from-a-checked-out-toolrack).
193
+
130
194
  ## Deployment Model
131
195
 
132
- `toolrack` is meant to be installed into a scripts repository and launched
133
- through a repo-local wrapper in `bin/`.
196
+ `toolrack` is meant to be installed into a scripts repository's virtualenv
197
+ (`pip install toolrack-cli`) and launched through a repo-local wrapper in `bin/`.
198
+ The PyPI distribution is `toolrack-cli`; the package and command are `toolrack`.
134
199
 
135
200
  The wrapper is responsible for:
136
201
 
@@ -6,7 +6,9 @@ src/toolrack/__main__.py
6
6
  src/toolrack/cli.py
7
7
  src/toolrack/completion.py
8
8
  src/toolrack/entrypoint.py
9
+ src/toolrack/pytest_plugin.py
9
10
  src/toolrack/runtime.py
11
+ src/toolrack/testing.py
10
12
  src/toolrack_cli.egg-info/PKG-INFO
11
13
  src/toolrack_cli.egg-info/SOURCES.txt
12
14
  src/toolrack_cli.egg-info/dependency_links.txt
@@ -15,4 +17,6 @@ src/toolrack_cli.egg-info/requires.txt
15
17
  src/toolrack_cli.egg-info/top_level.txt
16
18
  tests/test_cli.py
17
19
  tests/test_completion.py
18
- tests/test_cross_shell.py
20
+ tests/test_cross_shell.py
21
+ tests/test_testing.py
22
+ tests/test_validate.py
@@ -1,2 +1,5 @@
1
1
  [console_scripts]
2
2
  toolrack = toolrack.entrypoint:main
3
+
4
+ [pytest11]
5
+ toolrack = toolrack.pytest_plugin
@@ -0,0 +1,130 @@
1
+ """The public test helpers: ToolrackRepo (real CLI in a subprocess) and its fixtures."""
2
+
3
+ import pytest
4
+
5
+ from toolrack.testing import RunResult, ToolrackRepo
6
+
7
+
8
+ def test_add_script_registers_and_runs(tmp_path):
9
+ repo = ToolrackRepo.create(tmp_path)
10
+ repo.add_script(
11
+ "greet/hello.py",
12
+ "import sys\nprint('hello,', sys.argv[2])\n",
13
+ {"description": "Say hi.", "args": [{"name": "name", "required": True}]},
14
+ )
15
+
16
+ assert repo.registered() == ["scripts/greet/hello.py"]
17
+ result = repo.run("greet", "hello", "--name", "Ada").check()
18
+ assert result.stdout.strip() == "hello, Ada"
19
+
20
+
21
+ def test_run_returns_script_exit_code(tmp_path):
22
+ repo = ToolrackRepo.create(tmp_path)
23
+ repo.add_script("t/fail.py", "raise SystemExit(7)\n", {"description": "fails"})
24
+ assert repo.run("t", "fail").returncode == 7
25
+
26
+
27
+ def test_validate_passes_then_fails_with_listing(tmp_path):
28
+ repo = ToolrackRepo.create(tmp_path)
29
+ repo.add_script("t/ok.py", sidecar={"description": "ok"})
30
+ repo.run("core", "refresh-cache").check()
31
+ repo.validate(strict=True)
32
+
33
+ repo.add_script("t/nosidecar.py")
34
+ with pytest.raises(AssertionError) as info:
35
+ repo.validate()
36
+ assert "scripts/t/nosidecar.py" in str(info.value)
37
+
38
+
39
+ def test_environment_is_isolated_from_the_callers(tmp_path, monkeypatch):
40
+ other = tmp_path / "other"
41
+ other.mkdir()
42
+ monkeypatch.setenv("TOOLRACK_REPO_ROOT", str(other))
43
+ repo = ToolrackRepo.create(tmp_path / "mine")
44
+ repo.add_script("t/x.py", sidecar={"description": "x"})
45
+ assert "scripts/t/x.py" in repo.run("core", "list").stdout
46
+
47
+
48
+ def test_write_aliases(tmp_path):
49
+ repo = ToolrackRepo.create(tmp_path)
50
+ repo.add_script("long_group_name/x.py", sidecar={"description": "x"})
51
+ repo.write_aliases({"long_group_name": "lg"})
52
+ assert repo.run("lg", "x").ok
53
+
54
+
55
+ def test_discover_finds_enclosing_repo(tmp_path):
56
+ repo = ToolrackRepo.create(tmp_path)
57
+ nested = repo.scripts / "deep"
58
+ nested.mkdir()
59
+ assert ToolrackRepo.discover(nested).root == repo.root
60
+
61
+
62
+ def test_run_result_check_includes_output():
63
+ failed = RunResult(("x",), 2, "out", "err")
64
+ with pytest.raises(AssertionError, match="exited 2"):
65
+ failed.check()
66
+
67
+
68
+ def test_default_content_per_script_kind(tmp_path):
69
+ repo = ToolrackRepo.create(tmp_path)
70
+ assert "print" in repo.add_script("a/p.py").read_text()
71
+ assert "echo" in repo.add_script("a/s.sh").read_text()
72
+ assert "SELECT" in repo.add_script("a/q.sql").read_text()
73
+ assert b"\r" not in repo.add_script("a/s.sh").read_bytes()
74
+
75
+
76
+ def test_bash_script_runs_with_typed_args(toolrack_repo, toolrack_require_bash):
77
+ toolrack_repo.add_script(
78
+ "ops/say.sh",
79
+ 'echo "got: $2 $3"\n',
80
+ {
81
+ "description": "Echo args.",
82
+ "args": [
83
+ {"name": "name", "required": True},
84
+ {"name": "loud", "flag": True},
85
+ ],
86
+ },
87
+ )
88
+ out = toolrack_repo.run("ops", "say", "--name", "Ada", "--loud").check().stdout
89
+ assert "got: Ada --loud" in out
90
+
91
+
92
+ def test_bash_exit_code_propagates(toolrack_repo, toolrack_require_bash):
93
+ toolrack_repo.add_script("ops/fail.sh", "exit 3\n", {"description": "fails"})
94
+ assert toolrack_repo.run("ops", "fail").returncode == 3
95
+
96
+
97
+ def test_sql_script_is_run_through_psql(toolrack_repo, toolrack_require_posix):
98
+ psql = toolrack_repo.stub_executable("psql", stdout="1\n", record_env=("PGDATABASE",))
99
+ script = toolrack_repo.add_script(
100
+ "db/ping.sql",
101
+ "SELECT 1;\n",
102
+ {"description": "Ping.", "env": [{"name": "PGDATABASE", "required": True}]},
103
+ )
104
+
105
+ result = toolrack_repo.run("db", "ping", env={"PGDATABASE": "analytics"}).check()
106
+
107
+ assert result.stdout == "1\n"
108
+ (call,) = psql.calls()
109
+ assert call["argv"] == ["-f", str(script)]
110
+ assert call["env"] == {"PGDATABASE": "analytics"}
111
+
112
+
113
+ def test_sql_sidecar_with_args_fails_validation(toolrack_repo):
114
+ toolrack_repo.add_script(
115
+ "db/bad.sql", sidecar={"description": "x", "args": [{"name": "p", "positional": True}]}
116
+ )
117
+ with pytest.raises(AssertionError, match="SQL scripts must not declare"):
118
+ toolrack_repo.validate()
119
+
120
+
121
+ def test_stub_executable_is_rejected_on_windows(tmp_path, monkeypatch):
122
+ monkeypatch.setattr("toolrack.testing.os.name", "nt")
123
+ with pytest.raises(NotImplementedError):
124
+ ToolrackRepo.create(tmp_path).stub_executable("psql")
125
+
126
+
127
+ def test_pytest_fixtures_are_provided_by_the_plugin(toolrack_repo, toolrack_project):
128
+ toolrack_repo.add_script("t/ok.py", sidecar={"description": "ok"})
129
+ assert toolrack_repo.run("t", "ok").ok
130
+ assert toolrack_project.root.is_dir()
@@ -0,0 +1,106 @@
1
+ """`core validate`: CI-oriented, read-only repository check."""
2
+
3
+ from toolrack import cli
4
+
5
+
6
+ def _validate(runner, *extra):
7
+ return runner.invoke(cli.cli, ["core", "validate", *extra])
8
+
9
+
10
+ def _register(repo, *entries):
11
+ repo["registry"].write_text("".join(f"{e}\n" for e in entries), encoding="utf-8")
12
+
13
+
14
+ def test_validate_passes_on_clean_repo(repo, runner, make_script, make_sidecar):
15
+ make_script("tools/hello.py")
16
+ make_sidecar("tools/hello.py", {"description": "hi"})
17
+ _register(repo, "scripts/tools/hello.py")
18
+ cli._refresh_cache()
19
+
20
+ result = _validate(runner, "--strict")
21
+
22
+ assert result.exit_code == 0, result.output
23
+ assert "1 ok, 0 errors, 0 warnings" in result.output
24
+
25
+
26
+ def test_validate_empty_repo_is_ok(repo, runner):
27
+ assert _validate(runner).exit_code == 0
28
+
29
+
30
+ def test_validate_fails_on_missing_script(repo, runner):
31
+ _register(repo, "scripts/gone.py")
32
+ result = _validate(runner)
33
+ assert result.exit_code == 1
34
+ assert "registered script not found" in result.output
35
+
36
+
37
+ def test_validate_fails_on_missing_sidecar(repo, runner, make_script):
38
+ make_script("tools/bare.py")
39
+ _register(repo, "scripts/tools/bare.py")
40
+ result = _validate(runner)
41
+ assert result.exit_code == 1
42
+ assert "sidecar missing" in result.output
43
+
44
+
45
+ def test_validate_fails_on_invalid_sidecar(repo, runner, make_script, make_sidecar):
46
+ make_script("tools/bad.py")
47
+ make_sidecar("tools/bad.py", {"args": [{"name": "x", "variadic": True}]})
48
+ _register(repo, "scripts/tools/bad.py")
49
+ result = _validate(runner)
50
+ assert result.exit_code == 1
51
+ assert "variadic" in result.output
52
+
53
+
54
+ def test_validate_fails_on_malformed_yaml(repo, runner, make_script):
55
+ make_script("tools/broken.py")
56
+ (repo["scripts"] / "tools" / "broken.yml").write_text("description: [unclosed\n", encoding="utf-8")
57
+ _register(repo, "scripts/tools/broken.py")
58
+ result = _validate(runner)
59
+ assert result.exit_code == 1
60
+ assert "scripts/tools/broken.py" in result.output
61
+
62
+
63
+ def test_validate_reports_every_problem_not_just_the_first(repo, runner):
64
+ _register(repo, "scripts/a.py", "scripts/b.py")
65
+ result = _validate(runner)
66
+ assert "scripts/a.py" in result.output and "scripts/b.py" in result.output
67
+ assert "0 ok, 2 errors" in result.output
68
+
69
+
70
+ def test_validate_fails_on_alias_collision(repo, runner, aliases_cfg):
71
+ aliases_cfg({"one": "x", "two": "x"})
72
+ result = _validate(runner)
73
+ assert result.exit_code == 1
74
+ assert "collision" in result.output
75
+
76
+
77
+ def test_validate_fails_on_duplicate_command_names(repo, runner, make_script, make_sidecar):
78
+ for name in ("hello_world.py", "hello-world.py"):
79
+ make_script(f"tools/{name}")
80
+ make_sidecar(f"tools/{name}", {"description": "x"})
81
+ _register(repo, "scripts/tools/hello_world.py", "scripts/tools/hello-world.py")
82
+ result = _validate(runner)
83
+ assert result.exit_code == 1
84
+ assert "already provided by" in result.output
85
+
86
+
87
+ def test_validate_warns_about_unregistered_pair(repo, runner, make_script, make_sidecar):
88
+ make_script("tools/loose.py")
89
+ make_sidecar("tools/loose.py", {"description": "x"})
90
+ result = _validate(runner)
91
+ assert result.exit_code == 0
92
+ assert "not registered" in result.output
93
+ assert _validate(runner, "--strict").exit_code == 1
94
+
95
+
96
+ def test_validate_warns_on_stale_cache_but_does_not_write(repo, runner, make_script, make_sidecar):
97
+ make_script("tools/hello.py")
98
+ make_sidecar("tools/hello.py", {"description": "hi"})
99
+ _register(repo, "scripts/tools/hello.py")
100
+
101
+ result = _validate(runner)
102
+
103
+ assert result.exit_code == 0
104
+ assert "cache is missing or stale" in result.output
105
+ assert not repo["cache"].exists()
106
+ assert _validate(runner, "--strict").exit_code == 1
File without changes
File without changes