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.
- {toolrack_cli-0.1.0/src/toolrack_cli.egg-info → toolrack_cli-0.2.0}/PKG-INFO +68 -3
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/README.md +67 -2
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/pyproject.toml +4 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/__init__.py +1 -1
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/cli.py +116 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/completion.py +11 -0
- toolrack_cli-0.2.0/src/toolrack/pytest_plugin.py +55 -0
- toolrack_cli-0.2.0/src/toolrack/testing.py +262 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0/src/toolrack_cli.egg-info}/PKG-INFO +68 -3
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/SOURCES.txt +5 -1
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/entry_points.txt +3 -0
- toolrack_cli-0.2.0/tests/test_testing.py +130 -0
- toolrack_cli-0.2.0/tests/test_validate.py +106 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/LICENSE +0 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/setup.cfg +0 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/__main__.py +0 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/entrypoint.py +0 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack/runtime.py +0 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/dependency_links.txt +0 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/requires.txt +0 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/src/toolrack_cli.egg-info/top_level.txt +0 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/tests/test_cli.py +0 -0
- {toolrack_cli-0.1.0 → toolrack_cli-0.2.0}/tests/test_completion.py +0 -0
- {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.
|
|
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
|
+
[](https://pypi.org/project/toolrack-cli/)
|
|
37
|
+
[](https://github.com/anfit/toolrack/actions/workflows/unit-tests.yml)
|
|
38
|
+
|
|
36
39
|

|
|
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
|
|
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
|
+
[](https://pypi.org/project/toolrack-cli/)
|
|
11
|
+
[](https://github.com/anfit/toolrack/actions/workflows/unit-tests.yml)
|
|
12
|
+
|
|
10
13
|

|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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
|
+
[](https://pypi.org/project/toolrack-cli/)
|
|
37
|
+
[](https://github.com/anfit/toolrack/actions/workflows/unit-tests.yml)
|
|
38
|
+
|
|
36
39
|

|
|
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
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|