runspec 0.76.2__tar.gz → 0.77.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.
- {runspec-0.76.2 → runspec-0.77.0}/PKG-INFO +4 -4
- {runspec-0.76.2 → runspec-0.77.0}/pyproject.toml +4 -4
- {runspec-0.76.2 → runspec-0.77.0}/runspec/cli.py +4 -2
- runspec-0.77.0/runspec/compose.py +245 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/loader.py +31 -1
- {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/PKG-INFO +4 -4
- {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/SOURCES.txt +2 -0
- runspec-0.77.0/tests/test_compose.py +418 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/__init__.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/ansiblegen.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/auditlog.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/become.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/denylist.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/discovery.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/emitutil.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/env.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/errors.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/execute.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/finder.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/inbox.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/inference.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/jump.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/logging_setup.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/logs.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/mock.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/models.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/parser.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/py.typed +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/rebrand.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/rundeckgen.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/runexec.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/runsdb.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/runspec.toml +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/schemacheck.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/serve.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/stubgen.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/testing.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/types.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec/validator.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/dependency_links.txt +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/entry_points.txt +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/requires.txt +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/top_level.txt +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/setup.cfg +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/__init__.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/conftest.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_agent.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_ansiblegen.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_audit.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_become.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_cli_discover.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_cli_emit.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_cli_init.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_cli_test.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_denylist.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_discovery.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_env.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_exec.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_finder.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_inbox.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_inference.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_jump.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_loader.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_log_resilience.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_logging_setup.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_logs.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_mock.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_models.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_parser.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_password.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_rebrand.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_run_as_enforce.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_run_summary.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_rundeckgen.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_runsdb.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_runsdb_events.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_runsdb_sync.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_schemacheck.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_secret_argv.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_serve.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_serve_e2e.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_stubgen.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_testing.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_types.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_typing.py +0 -0
- {runspec-0.76.2 → runspec-0.77.0}/tests/test_validator.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runspec
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.77.0
|
|
4
4
|
Summary: A language-agnostic, TOML-based interface specification for anything runnable
|
|
5
5
|
Author-email: Jason Finestone <jason@finestone.dev>
|
|
6
6
|
Project-URL: Homepage, https://runspec.app
|
|
7
7
|
Project-URL: Documentation, https://runspec.app
|
|
8
|
-
Project-URL: Repository, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
10
|
-
Project-URL: Changelog, https://github.com/
|
|
8
|
+
Project-URL: Repository, https://github.com/runspec-app/runspec
|
|
9
|
+
Project-URL: Issues, https://github.com/runspec-app/runspec/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/runspec-app/runspec/blob/main/CHANGELOG.md
|
|
11
11
|
Keywords: cli,argparse,arguments,agents,mcp,tools
|
|
12
12
|
Classifier: Development Status :: 3 - Alpha
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "runspec"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.77.0"
|
|
4
4
|
description = "A language-agnostic, TOML-based interface specification for anything runnable"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "../../LICENSE" }
|
|
@@ -41,9 +41,9 @@ runspec = "runspec.cli:main"
|
|
|
41
41
|
[project.urls]
|
|
42
42
|
Homepage = "https://runspec.app"
|
|
43
43
|
Documentation = "https://runspec.app"
|
|
44
|
-
Repository = "https://github.com/
|
|
45
|
-
Issues = "https://github.com/
|
|
46
|
-
Changelog = "https://github.com/
|
|
44
|
+
Repository = "https://github.com/runspec-app/runspec"
|
|
45
|
+
Issues = "https://github.com/runspec-app/runspec/issues"
|
|
46
|
+
Changelog = "https://github.com/runspec-app/runspec/blob/main/CHANGELOG.md"
|
|
47
47
|
|
|
48
48
|
[build-system]
|
|
49
49
|
requires = ["setuptools>=69.0.2", "wheel"]
|
|
@@ -288,8 +288,10 @@ def _emit_meta(source: str, runnable: str, key: str) -> tuple[dict[str, Any], di
|
|
|
288
288
|
Runnable-level `meta` is a pass-through the loader drops from the normalised
|
|
289
289
|
spec (loader `_normalise_script`), so the emitter reads it straight from the
|
|
290
290
|
raw TOML — keeping every platform-specific setting out of the core spec."""
|
|
291
|
+
from runspec.loader import read_resolved
|
|
292
|
+
|
|
291
293
|
try:
|
|
292
|
-
raw =
|
|
294
|
+
raw = read_resolved(Path(source))
|
|
293
295
|
except Exception:
|
|
294
296
|
return {}, {}
|
|
295
297
|
config_meta = (raw.get("config", {}) or {}).get("meta", {}) or {}
|
|
@@ -1878,7 +1880,7 @@ def _init_claude_md(project_root: Path, pkg_name: str) -> None:
|
|
|
1878
1880
|
path.write_text(
|
|
1879
1881
|
"# AI Context\n"
|
|
1880
1882
|
"\n"
|
|
1881
|
-
"This project uses [runspec](https://github.com/
|
|
1883
|
+
"This project uses [runspec](https://github.com/runspec-app/runspec) for CLI interfaces.\n"
|
|
1882
1884
|
"\n"
|
|
1883
1885
|
"## Key files\n"
|
|
1884
1886
|
f"- `{pkg_name}/runspec.toml` — defines runnables: args, types, autonomy levels\n"
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
compose.py — resolve ``include`` and ``shared-args`` in a parsed runspec.toml.
|
|
3
|
+
|
|
4
|
+
Two authoring features that let a big ``runspec.toml`` be split up and stop
|
|
5
|
+
repeating itself. Both are resolved here, on the raw TOML dict, *before* the
|
|
6
|
+
loader normalises it, so every later stage (inference, validation, ``--help``,
|
|
7
|
+
``serve``, stubs, emit, the console) sees an ordinary single-file spec.
|
|
8
|
+
|
|
9
|
+
``include``
|
|
10
|
+
A runnable or command table may say ``include = "path/to/file.toml"``. The
|
|
11
|
+
file holds that table's body (``description``, ``args``, ``commands``, …,
|
|
12
|
+
with no ``[runnable.commands.x]`` prefix) and is merged into the table. The
|
|
13
|
+
path is relative to the file that names it and must stay inside the
|
|
14
|
+
directory holding ``runspec.toml``. Included files may include others; a
|
|
15
|
+
cycle is an error, and the chain is capped at ``MAX_INCLUDE_DEPTH`` files.
|
|
16
|
+
A key set both in the table and in the included file is an error.
|
|
17
|
+
|
|
18
|
+
``shared-args`` / ``use``
|
|
19
|
+
``[config.shared-args]``, ``[<runnable>.shared-args]`` and
|
|
20
|
+
``[<…>.commands.<c>.shared-args]`` define args once. An arg written as
|
|
21
|
+
``project = {use = "project"}`` takes that definition; any other keys next
|
|
22
|
+
to ``use`` override it. The nearest definition wins (the command's own,
|
|
23
|
+
then each parent up to the runnable, then ``[config]``). A definition can't
|
|
24
|
+
itself contain ``use``.
|
|
25
|
+
|
|
26
|
+
The functions are pure: file access goes through a ``reader`` callback that
|
|
27
|
+
takes a path relative to the ``runspec.toml`` directory (POSIX ``/``
|
|
28
|
+
separators) and returns the file's text, or ``None`` when it doesn't exist —
|
|
29
|
+
so the console can resolve a spec it fetched over SSH without a filesystem.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import copy
|
|
35
|
+
import difflib
|
|
36
|
+
import posixpath
|
|
37
|
+
import sys
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
if sys.version_info >= (3, 11):
|
|
43
|
+
import tomllib
|
|
44
|
+
else:
|
|
45
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
46
|
+
|
|
47
|
+
Reader = Callable[[str], "str | None"]
|
|
48
|
+
|
|
49
|
+
ROOT_FILE = "runspec.toml"
|
|
50
|
+
MAX_INCLUDE_DEPTH = 8
|
|
51
|
+
SHARED_ARGS_KEY = "shared-args"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve(data: dict[str, Any], reader: Reader | None = None, source: str = ROOT_FILE) -> dict[str, Any]:
|
|
55
|
+
"""Return a copy of ``data`` with every ``include`` and ``use`` resolved and
|
|
56
|
+
every ``shared-args`` table removed.
|
|
57
|
+
|
|
58
|
+
A file that uses neither feature comes back equal to ``data``.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
data: the parsed ``runspec.toml``.
|
|
62
|
+
reader: reads an included file by its package-relative POSIX path;
|
|
63
|
+
returns ``None`` when the file doesn't exist. Without a reader
|
|
64
|
+
an ``include`` is an error.
|
|
65
|
+
source: the package-relative name of the file ``data`` came from.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: a bad include path, a missing or malformed included file,
|
|
69
|
+
a cycle, a key defined twice, or an unknown ``use`` name.
|
|
70
|
+
"""
|
|
71
|
+
out = copy.deepcopy(data)
|
|
72
|
+
for name, table in out.items():
|
|
73
|
+
if name == "config" or not isinstance(table, dict):
|
|
74
|
+
continue
|
|
75
|
+
_resolve_includes(table, f"[{name}]", source, reader, (source,))
|
|
76
|
+
_resolve_uses(out)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def has_include(text: str) -> bool:
|
|
81
|
+
"""Cheap textual check: could this runspec.toml text use ``include``?
|
|
82
|
+
|
|
83
|
+
Used by the console to decide whether a scanned file needs its fragments.
|
|
84
|
+
False positives are harmless (a key named ``include`` inside ``args`` …).
|
|
85
|
+
"""
|
|
86
|
+
for line in text.splitlines():
|
|
87
|
+
stripped = line.lstrip()
|
|
88
|
+
if stripped.startswith("include") and stripped[len("include") :].lstrip().startswith("="):
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fs_reader(root: Path) -> Reader:
|
|
94
|
+
"""A reader over the real filesystem, rooted at the ``runspec.toml`` directory.
|
|
95
|
+
|
|
96
|
+
Refuses a path that resolves outside ``root`` (a symlink pointing out of the
|
|
97
|
+
package), so a wheel can only include its own files.
|
|
98
|
+
"""
|
|
99
|
+
base = root.resolve()
|
|
100
|
+
|
|
101
|
+
def read(rel: str) -> str | None:
|
|
102
|
+
target = (base / rel).resolve()
|
|
103
|
+
if target != base and base not in target.parents:
|
|
104
|
+
raise ValueError(f"✗ include '{rel}' resolves outside the package directory ({base}).")
|
|
105
|
+
if not target.is_file():
|
|
106
|
+
return None
|
|
107
|
+
return target.read_text(encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
return read
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── include ────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _include_path(value: Any, cur_file: str, where: str) -> str:
|
|
116
|
+
"""The package-relative POSIX path an ``include`` value points at."""
|
|
117
|
+
if not isinstance(value, str) or not value.strip():
|
|
118
|
+
raise ValueError(f'✗ {where} include must be a path string, e.g. include = "commands/deploy.toml".')
|
|
119
|
+
raw = value.strip()
|
|
120
|
+
if "\\" in raw:
|
|
121
|
+
raise ValueError(f"✗ {where} include '{raw}': use '/' in include paths, not '\\'.")
|
|
122
|
+
if raw.startswith("/") or (len(raw) > 1 and raw[1] == ":"):
|
|
123
|
+
raise ValueError(f"✗ {where} include '{raw}' must be a relative path inside the package directory.")
|
|
124
|
+
if not raw.endswith(".toml"):
|
|
125
|
+
raise ValueError(f"✗ {where} include '{raw}' must name a .toml file.")
|
|
126
|
+
rel = posixpath.normpath(posixpath.join(posixpath.dirname(cur_file), raw))
|
|
127
|
+
if rel == ".." or rel.startswith("../"):
|
|
128
|
+
raise ValueError(f"✗ {where} include '{raw}' (in {cur_file}) points outside the package directory.")
|
|
129
|
+
return rel
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _read_fragment(rel: str, reader: Reader | None, where: str, cur_file: str) -> dict[str, Any]:
|
|
133
|
+
if reader is None:
|
|
134
|
+
raise ValueError(f"✗ {where} include '{rel}' can't be read here (no file access).")
|
|
135
|
+
text = reader(rel)
|
|
136
|
+
if text is None:
|
|
137
|
+
raise ValueError(f"✗ {where} include '{rel}' (named in {cur_file}) was not found.\n Is the file shipped in the package?")
|
|
138
|
+
try:
|
|
139
|
+
frag = tomllib.loads(text)
|
|
140
|
+
except tomllib.TOMLDecodeError as e:
|
|
141
|
+
raise ValueError(f"✗ {rel} is not valid TOML: {e}") from e
|
|
142
|
+
return frag
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _resolve_includes(
|
|
146
|
+
table: dict[str, Any],
|
|
147
|
+
where: str,
|
|
148
|
+
cur_file: str,
|
|
149
|
+
reader: Reader | None,
|
|
150
|
+
stack: tuple[str, ...],
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Merge every ``include`` into ``table`` (in place), then recurse into its commands.
|
|
153
|
+
|
|
154
|
+
``cur_file`` is the file ``table`` was written in; ``stack`` is the chain of
|
|
155
|
+
files from ``runspec.toml`` down to here, for cycle detection.
|
|
156
|
+
"""
|
|
157
|
+
origin = dict.fromkeys(table, cur_file) # key → file it came from
|
|
158
|
+
file = cur_file
|
|
159
|
+
while "include" in table:
|
|
160
|
+
rel = _include_path(table.pop("include"), file, where)
|
|
161
|
+
if rel in stack:
|
|
162
|
+
chain = " → ".join((*stack[stack.index(rel) :], rel))
|
|
163
|
+
raise ValueError(f"✗ {where} include cycle: {chain}")
|
|
164
|
+
if len(stack) > MAX_INCLUDE_DEPTH:
|
|
165
|
+
raise ValueError(f"✗ {where} includes are nested more than {MAX_INCLUDE_DEPTH} files deep: {' → '.join((*stack, rel))}")
|
|
166
|
+
frag = _read_fragment(rel, reader, where, file)
|
|
167
|
+
dupes = sorted(k for k in frag if k in table)
|
|
168
|
+
if dupes:
|
|
169
|
+
keys = ", ".join(dupes)
|
|
170
|
+
raise ValueError(f"✗ {where} sets {keys} both in {origin[dupes[0]]} and in its include {rel}.\n Set each key in one place.")
|
|
171
|
+
table.update(frag)
|
|
172
|
+
origin.update(dict.fromkeys(frag, rel))
|
|
173
|
+
stack = (*stack, rel)
|
|
174
|
+
file = rel
|
|
175
|
+
|
|
176
|
+
commands = table.get("commands")
|
|
177
|
+
if isinstance(commands, dict):
|
|
178
|
+
cmd_file = origin.get("commands", file)
|
|
179
|
+
for name, sub in commands.items():
|
|
180
|
+
if isinstance(sub, dict):
|
|
181
|
+
_resolve_includes(sub, f"{where[:-1]}.commands.{name}]", cmd_file, reader, stack)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── shared-args / use ──────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _shared_defs(raw: Any, where: str) -> dict[str, dict[str, Any]]:
|
|
188
|
+
if raw is None:
|
|
189
|
+
return {}
|
|
190
|
+
if not isinstance(raw, dict):
|
|
191
|
+
raise ValueError(f"✗ {where} must be a table of arg definitions.")
|
|
192
|
+
defs: dict[str, dict[str, Any]] = {}
|
|
193
|
+
for name, value in raw.items():
|
|
194
|
+
definition = value if isinstance(value, dict) else {"default": value}
|
|
195
|
+
if "use" in definition:
|
|
196
|
+
raise ValueError(f"✗ {where} {name}: a shared arg can't itself use another one.")
|
|
197
|
+
defs[name] = definition
|
|
198
|
+
return defs
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _resolve_uses(data: dict[str, Any]) -> None:
|
|
202
|
+
config = data.get("config")
|
|
203
|
+
scopes: list[dict[str, dict[str, Any]]] = []
|
|
204
|
+
if isinstance(config, dict):
|
|
205
|
+
scopes.append(_shared_defs(config.pop(SHARED_ARGS_KEY, None), f"[config.{SHARED_ARGS_KEY}]"))
|
|
206
|
+
for name, table in data.items():
|
|
207
|
+
if name == "config" or not isinstance(table, dict):
|
|
208
|
+
continue
|
|
209
|
+
_resolve_uses_table(table, f"[{name}]", scopes)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _resolve_uses_table(table: dict[str, Any], where: str, scopes: list[dict[str, dict[str, Any]]]) -> None:
|
|
213
|
+
if SHARED_ARGS_KEY in table:
|
|
214
|
+
scopes = [*scopes, _shared_defs(table.pop(SHARED_ARGS_KEY), f"{where[:-1]}.{SHARED_ARGS_KEY}]")]
|
|
215
|
+
args = table.get("args")
|
|
216
|
+
if isinstance(args, dict):
|
|
217
|
+
for arg_name, value in args.items():
|
|
218
|
+
if isinstance(value, dict) and "use" in value:
|
|
219
|
+
args[arg_name] = _use(value, arg_name, where, scopes)
|
|
220
|
+
commands = table.get("commands")
|
|
221
|
+
if isinstance(commands, dict):
|
|
222
|
+
for name, sub in commands.items():
|
|
223
|
+
if isinstance(sub, dict):
|
|
224
|
+
_resolve_uses_table(sub, f"{where[:-1]}.commands.{name}]", scopes)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _use(value: dict[str, Any], arg_name: str, where: str, scopes: list[dict[str, dict[str, Any]]]) -> dict[str, Any]:
|
|
228
|
+
ref = value["use"]
|
|
229
|
+
if not isinstance(ref, str) or not ref:
|
|
230
|
+
raise ValueError(f'✗ {where} arg {arg_name}: use must name a shared arg, e.g. {{use = "{arg_name}"}}.')
|
|
231
|
+
for defs in reversed(scopes):
|
|
232
|
+
if ref in defs:
|
|
233
|
+
merged: dict[str, Any] = copy.deepcopy(defs[ref])
|
|
234
|
+
merged.update({k: v for k, v in value.items() if k != "use"})
|
|
235
|
+
return merged
|
|
236
|
+
known = sorted({n for defs in scopes for n in defs})
|
|
237
|
+
hint = ""
|
|
238
|
+
close = difflib.get_close_matches(ref, known, n=1)
|
|
239
|
+
if close:
|
|
240
|
+
hint = f"\n Did you mean '{close[0]}'?"
|
|
241
|
+
elif known:
|
|
242
|
+
hint = f"\n Shared args in scope: {', '.join(known)}"
|
|
243
|
+
else:
|
|
244
|
+
hint = f"\n No [{SHARED_ARGS_KEY}] table is in scope here."
|
|
245
|
+
raise ValueError(f"✗ {where} arg {arg_name}: unknown shared arg '{ref}'.{hint}")
|
|
@@ -20,11 +20,17 @@ if sys.version_info >= (3, 11):
|
|
|
20
20
|
else:
|
|
21
21
|
import tomli as tomllib # type: ignore[no-redef]
|
|
22
22
|
|
|
23
|
+
from runspec.compose import Reader, fs_reader, resolve
|
|
24
|
+
|
|
23
25
|
|
|
24
26
|
def load_raw(config_path: Path) -> dict[str, Any]:
|
|
25
27
|
"""
|
|
26
28
|
Read runspec.toml and return the normalised spec dict.
|
|
27
29
|
|
|
30
|
+
``include`` and ``shared-args`` / ``use`` are resolved first (see
|
|
31
|
+
:mod:`runspec.compose`), so the result looks the same as if the spec had
|
|
32
|
+
been written as one file.
|
|
33
|
+
|
|
28
34
|
Args:
|
|
29
35
|
config_path: Path to runspec.toml
|
|
30
36
|
|
|
@@ -32,12 +38,36 @@ def load_raw(config_path: Path) -> dict[str, Any]:
|
|
|
32
38
|
Normalised dict with keys: config, runnables
|
|
33
39
|
|
|
34
40
|
Raises:
|
|
35
|
-
ValueError: if the runspec section is malformed
|
|
41
|
+
ValueError: if the runspec section is malformed, or an include / use
|
|
42
|
+
can't be resolved
|
|
36
43
|
FileNotFoundError: if config_path does not exist
|
|
37
44
|
"""
|
|
45
|
+
return normalise(read_resolved(config_path))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_resolved(config_path: Path) -> dict[str, Any]:
|
|
49
|
+
"""Read runspec.toml as a raw TOML dict with ``include`` and ``use`` resolved.
|
|
50
|
+
|
|
51
|
+
For callers that need keys the normaliser drops (e.g. ``meta`` tables).
|
|
52
|
+
"""
|
|
53
|
+
config_path = Path(config_path)
|
|
38
54
|
with open(config_path, "rb") as f:
|
|
39
55
|
data = tomllib.load(f)
|
|
56
|
+
return resolve(data, fs_reader(config_path.parent), config_path.name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_text(text: str, reader: Reader | None = None) -> dict[str, Any]:
|
|
60
|
+
""":func:`load_raw` for runspec.toml *text*.
|
|
61
|
+
|
|
62
|
+
``reader`` reads an included file by its path relative to the runspec.toml
|
|
63
|
+
directory (``None`` when missing); without one, a spec that uses
|
|
64
|
+
``include`` raises ``ValueError``.
|
|
65
|
+
"""
|
|
66
|
+
return normalise(resolve(tomllib.loads(text), reader))
|
|
67
|
+
|
|
40
68
|
|
|
69
|
+
def normalise(data: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
"""Normalise a parsed (and resolved) runspec.toml dict."""
|
|
41
71
|
runnables_raw = {key: value for key, value in data.items() if key != "config" and isinstance(value, dict)}
|
|
42
72
|
|
|
43
73
|
return {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runspec
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.77.0
|
|
4
4
|
Summary: A language-agnostic, TOML-based interface specification for anything runnable
|
|
5
5
|
Author-email: Jason Finestone <jason@finestone.dev>
|
|
6
6
|
Project-URL: Homepage, https://runspec.app
|
|
7
7
|
Project-URL: Documentation, https://runspec.app
|
|
8
|
-
Project-URL: Repository, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
10
|
-
Project-URL: Changelog, https://github.com/
|
|
8
|
+
Project-URL: Repository, https://github.com/runspec-app/runspec
|
|
9
|
+
Project-URL: Issues, https://github.com/runspec-app/runspec/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/runspec-app/runspec/blob/main/CHANGELOG.md
|
|
11
11
|
Keywords: cli,argparse,arguments,agents,mcp,tools
|
|
12
12
|
Classifier: Development Status :: 3 - Alpha
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -4,6 +4,7 @@ runspec/ansiblegen.py
|
|
|
4
4
|
runspec/auditlog.py
|
|
5
5
|
runspec/become.py
|
|
6
6
|
runspec/cli.py
|
|
7
|
+
runspec/compose.py
|
|
7
8
|
runspec/denylist.py
|
|
8
9
|
runspec/discovery.py
|
|
9
10
|
runspec/emitutil.py
|
|
@@ -48,6 +49,7 @@ tests/test_cli_discover.py
|
|
|
48
49
|
tests/test_cli_emit.py
|
|
49
50
|
tests/test_cli_init.py
|
|
50
51
|
tests/test_cli_test.py
|
|
52
|
+
tests/test_compose.py
|
|
51
53
|
tests/test_denylist.py
|
|
52
54
|
tests/test_discovery.py
|
|
53
55
|
tests/test_env.py
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for compose.py — ``include`` and ``shared-args`` / ``use``.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from runspec.compose import has_include, resolve
|
|
13
|
+
from runspec.loader import load_raw, load_text, normalise, read_resolved
|
|
14
|
+
from runspec.parser import parse
|
|
15
|
+
|
|
16
|
+
if sys.version_info >= (3, 11):
|
|
17
|
+
import tomllib
|
|
18
|
+
else:
|
|
19
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
20
|
+
|
|
21
|
+
REPO = Path(__file__).parents[4]
|
|
22
|
+
FIXTURES = REPO / "tests" / "integration" / "fixtures"
|
|
23
|
+
COMPOSED = FIXTURES / "composed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write(root: Path, files: dict[str, str]) -> Path:
|
|
27
|
+
for rel, text in files.items():
|
|
28
|
+
p = root / rel
|
|
29
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
p.write_text(text, encoding="utf-8")
|
|
31
|
+
return root / "runspec.toml"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _repo_specs() -> list[Path]:
|
|
35
|
+
skip = {"node_modules", ".venv", "dist", "build"}
|
|
36
|
+
return sorted(p for p in REPO.glob("**/runspec.toml") if not skip & set(p.parts) and COMPOSED not in p.parents)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Existing specs are untouched ──────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestExistingSpecsUnchanged:
|
|
43
|
+
"""Every runspec.toml in the repo uses neither feature, so resolving it must
|
|
44
|
+
be a no-op and loading it must give exactly what the old loader gave."""
|
|
45
|
+
|
|
46
|
+
def test_found_specs(self):
|
|
47
|
+
assert len(_repo_specs()) > 20
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("path", _repo_specs(), ids=lambda p: str(p.relative_to(REPO)))
|
|
50
|
+
def test_resolve_is_identity(self, path):
|
|
51
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
assert resolve(data) == data
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("path", _repo_specs(), ids=lambda p: str(p.relative_to(REPO)))
|
|
55
|
+
def test_load_raw_matches_plain_normalise(self, path):
|
|
56
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
57
|
+
try:
|
|
58
|
+
expected = normalise(data)
|
|
59
|
+
except ValueError:
|
|
60
|
+
pytest.skip("deliberately invalid fixture")
|
|
61
|
+
assert load_raw(path) == expected
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestComplianceFixture:
|
|
65
|
+
"""Shared with the Node pack: the composed fixture equals its flat twin."""
|
|
66
|
+
|
|
67
|
+
def test_composed_equals_flat(self):
|
|
68
|
+
assert load_raw(COMPOSED / "runspec.toml") == load_raw(FIXTURES / "composed_flat.toml")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── include ───────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
BASH_SCRIPTS = {
|
|
74
|
+
"runspec.toml": """
|
|
75
|
+
[bash-scripts]
|
|
76
|
+
description = "Collection of bash scripts"
|
|
77
|
+
require-command = true
|
|
78
|
+
|
|
79
|
+
[bash-scripts.shared-args]
|
|
80
|
+
cluster = {type = "str", default = "main", description = "Cluster to act on"}
|
|
81
|
+
|
|
82
|
+
[bash-scripts.commands.europe]
|
|
83
|
+
include = "scripts/europe/europe.toml"
|
|
84
|
+
""",
|
|
85
|
+
"scripts/europe/europe.toml": """
|
|
86
|
+
description = "Scripts for the European estate"
|
|
87
|
+
require-command = true
|
|
88
|
+
|
|
89
|
+
[commands.find-nodes]
|
|
90
|
+
include = "find-nodes.toml"
|
|
91
|
+
|
|
92
|
+
[commands.delete-nodes]
|
|
93
|
+
include = "delete-nodes.toml"
|
|
94
|
+
""",
|
|
95
|
+
"scripts/europe/find-nodes.toml": """
|
|
96
|
+
description = "Find nodes matching a pattern"
|
|
97
|
+
autonomy = "autonomous"
|
|
98
|
+
|
|
99
|
+
[args]
|
|
100
|
+
pattern = {description = "Node name glob"}
|
|
101
|
+
cluster = {use = "cluster"}
|
|
102
|
+
""",
|
|
103
|
+
"scripts/europe/delete-nodes.toml": """
|
|
104
|
+
description = "Delete nodes"
|
|
105
|
+
autonomy = "confirm"
|
|
106
|
+
|
|
107
|
+
[args]
|
|
108
|
+
pattern = {description = "Node name glob"}
|
|
109
|
+
cluster = {use = "cluster", required = true}
|
|
110
|
+
""",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestInclude:
|
|
115
|
+
def test_nested_includes_resolve(self, tmp_path):
|
|
116
|
+
spec = load_raw(_write(tmp_path, BASH_SCRIPTS))["runnables"]["bash-scripts"]
|
|
117
|
+
europe = spec["commands"]["europe"]
|
|
118
|
+
assert europe["description"] == "Scripts for the European estate"
|
|
119
|
+
assert europe["require_command"] is True
|
|
120
|
+
find = europe["commands"]["find-nodes"]
|
|
121
|
+
assert find["autonomy"] == "autonomous"
|
|
122
|
+
assert find["args"]["cluster"]["default"] == "main"
|
|
123
|
+
assert find["args"]["cluster"]["description"] == "Cluster to act on"
|
|
124
|
+
delete = europe["commands"]["delete-nodes"]
|
|
125
|
+
assert delete["args"]["cluster"]["required"] is True
|
|
126
|
+
assert delete["args"]["cluster"]["default"] == "main"
|
|
127
|
+
|
|
128
|
+
def test_same_as_single_file(self, tmp_path):
|
|
129
|
+
split = load_raw(_write(tmp_path / "split", BASH_SCRIPTS))
|
|
130
|
+
single = load_raw(
|
|
131
|
+
_write(
|
|
132
|
+
tmp_path / "single",
|
|
133
|
+
{
|
|
134
|
+
"runspec.toml": """
|
|
135
|
+
[bash-scripts]
|
|
136
|
+
description = "Collection of bash scripts"
|
|
137
|
+
require-command = true
|
|
138
|
+
|
|
139
|
+
[bash-scripts.commands.europe]
|
|
140
|
+
description = "Scripts for the European estate"
|
|
141
|
+
require-command = true
|
|
142
|
+
|
|
143
|
+
[bash-scripts.commands.europe.commands.find-nodes]
|
|
144
|
+
description = "Find nodes matching a pattern"
|
|
145
|
+
autonomy = "autonomous"
|
|
146
|
+
|
|
147
|
+
[bash-scripts.commands.europe.commands.find-nodes.args]
|
|
148
|
+
pattern = {description = "Node name glob"}
|
|
149
|
+
cluster = {type = "str", default = "main", description = "Cluster to act on"}
|
|
150
|
+
|
|
151
|
+
[bash-scripts.commands.europe.commands.delete-nodes]
|
|
152
|
+
description = "Delete nodes"
|
|
153
|
+
autonomy = "confirm"
|
|
154
|
+
|
|
155
|
+
[bash-scripts.commands.europe.commands.delete-nodes.args]
|
|
156
|
+
pattern = {description = "Node name glob"}
|
|
157
|
+
cluster = {type = "str", default = "main", description = "Cluster to act on", required = true}
|
|
158
|
+
"""
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
assert split == single
|
|
163
|
+
|
|
164
|
+
def test_parse_runs_through_includes(self, tmp_path):
|
|
165
|
+
cfg = _write(tmp_path, BASH_SCRIPTS)
|
|
166
|
+
args = parse(
|
|
167
|
+
script_name="bash-scripts",
|
|
168
|
+
argv=["europe", "delete-nodes", "--pattern", "web*", "--cluster", "eu-1"],
|
|
169
|
+
config_path=cfg,
|
|
170
|
+
)
|
|
171
|
+
assert args.runspec_command_path == ["europe", "delete-nodes"]
|
|
172
|
+
assert args.pattern.value == "web*"
|
|
173
|
+
assert args.cluster.value == "eu-1"
|
|
174
|
+
|
|
175
|
+
def test_runnable_level_include(self, tmp_path):
|
|
176
|
+
cfg = _write(
|
|
177
|
+
tmp_path,
|
|
178
|
+
{
|
|
179
|
+
"runspec.toml": '[disk-usage]\ninclude = "runnables/disk-usage.toml"\n',
|
|
180
|
+
"runnables/disk-usage.toml": 'description = "Show disk usage"\n[args]\npath = {default = "/"}\n',
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
spec = load_raw(cfg)["runnables"]["disk-usage"]
|
|
184
|
+
assert spec["description"] == "Show disk usage"
|
|
185
|
+
assert spec["args"]["path"]["default"] == "/"
|
|
186
|
+
|
|
187
|
+
def test_paths_relative_to_including_file(self, tmp_path):
|
|
188
|
+
cfg = _write(
|
|
189
|
+
tmp_path,
|
|
190
|
+
{
|
|
191
|
+
"runspec.toml": '[r]\n[r.commands.a]\ninclude = "a/a.toml"\n',
|
|
192
|
+
"a/a.toml": '[commands.b]\ninclude = "../b/b.toml"\n',
|
|
193
|
+
"b/b.toml": 'description = "from b"\n',
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
assert load_raw(cfg)["runnables"]["r"]["commands"]["a"]["commands"]["b"]["description"] == "from b"
|
|
197
|
+
|
|
198
|
+
def test_chained_include(self, tmp_path):
|
|
199
|
+
cfg = _write(
|
|
200
|
+
tmp_path,
|
|
201
|
+
{
|
|
202
|
+
"runspec.toml": '[r]\ninclude = "one.toml"\n',
|
|
203
|
+
"one.toml": 'description = "one"\ninclude = "two.toml"\n',
|
|
204
|
+
"two.toml": 'autonomy = "autonomous"\n',
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
spec = load_raw(cfg)["runnables"]["r"]
|
|
208
|
+
assert (spec["description"], spec["autonomy"]) == ("one", "autonomous")
|
|
209
|
+
|
|
210
|
+
def test_meta_survives_for_emit(self, tmp_path):
|
|
211
|
+
cfg = _write(
|
|
212
|
+
tmp_path,
|
|
213
|
+
{
|
|
214
|
+
"runspec.toml": '[r]\ninclude = "r.toml"\n',
|
|
215
|
+
"r.toml": '[meta.rundeck]\nschedule = "0 2 * * *"\n',
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
assert read_resolved(cfg)["r"]["meta"]["rundeck"]["schedule"] == "0 2 * * *"
|
|
219
|
+
|
|
220
|
+
def test_cycle_is_an_error(self, tmp_path):
|
|
221
|
+
cfg = _write(
|
|
222
|
+
tmp_path,
|
|
223
|
+
{
|
|
224
|
+
"runspec.toml": '[r]\n[r.commands.a]\ninclude = "a.toml"\n',
|
|
225
|
+
"a.toml": '[commands.b]\ninclude = "b.toml"\n',
|
|
226
|
+
"b.toml": '[commands.c]\ninclude = "a.toml"\n',
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
with pytest.raises(ValueError, match=r"cycle: a\.toml → b\.toml → a\.toml"):
|
|
230
|
+
load_raw(cfg)
|
|
231
|
+
|
|
232
|
+
def test_including_runspec_toml_is_a_cycle(self, tmp_path):
|
|
233
|
+
cfg = _write(tmp_path, {"runspec.toml": '[r]\ninclude = "runspec.toml"\n'})
|
|
234
|
+
with pytest.raises(ValueError, match="cycle"):
|
|
235
|
+
load_raw(cfg)
|
|
236
|
+
|
|
237
|
+
def test_depth_cap(self, tmp_path):
|
|
238
|
+
files = {"runspec.toml": '[r]\ninclude = "f1.toml"\n'}
|
|
239
|
+
for i in range(1, 11):
|
|
240
|
+
files[f"f{i}.toml"] = f'include = "f{i + 1}.toml"\n'
|
|
241
|
+
files["f11.toml"] = 'description = "deep"\n'
|
|
242
|
+
with pytest.raises(ValueError, match="more than 8 files deep"):
|
|
243
|
+
load_raw(_write(tmp_path, files))
|
|
244
|
+
|
|
245
|
+
def test_duplicate_key_is_an_error(self, tmp_path):
|
|
246
|
+
cfg = _write(
|
|
247
|
+
tmp_path,
|
|
248
|
+
{
|
|
249
|
+
"runspec.toml": '[r]\ndescription = "here"\ninclude = "r.toml"\n',
|
|
250
|
+
"r.toml": 'description = "there"\n',
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
with pytest.raises(ValueError, match="sets description both in runspec.toml and in its include r.toml"):
|
|
254
|
+
load_raw(cfg)
|
|
255
|
+
|
|
256
|
+
def test_missing_file(self, tmp_path):
|
|
257
|
+
cfg = _write(tmp_path, {"runspec.toml": '[r]\ninclude = "nope.toml"\n'})
|
|
258
|
+
with pytest.raises(ValueError, match="nope.toml.*not found"):
|
|
259
|
+
load_raw(cfg)
|
|
260
|
+
|
|
261
|
+
def test_invalid_toml_in_fragment(self, tmp_path):
|
|
262
|
+
cfg = _write(tmp_path, {"runspec.toml": '[r]\ninclude = "bad.toml"\n', "bad.toml": "x = = 1\n"})
|
|
263
|
+
with pytest.raises(ValueError, match="bad.toml is not valid TOML"):
|
|
264
|
+
load_raw(cfg)
|
|
265
|
+
|
|
266
|
+
@pytest.mark.parametrize(
|
|
267
|
+
"path, message",
|
|
268
|
+
[
|
|
269
|
+
("../outside.toml", "outside the package"),
|
|
270
|
+
("a/../../outside.toml", "outside the package"),
|
|
271
|
+
("/etc/x.toml", "relative path"),
|
|
272
|
+
("C:/x.toml", "relative path"),
|
|
273
|
+
("a\\\\b.toml", "use '/'"),
|
|
274
|
+
("notes.txt", "must name a .toml file"),
|
|
275
|
+
],
|
|
276
|
+
)
|
|
277
|
+
def test_bad_paths(self, tmp_path, path, message):
|
|
278
|
+
cfg = _write(tmp_path, {"runspec.toml": f'[r]\ninclude = "{path}"\n'})
|
|
279
|
+
with pytest.raises(ValueError, match=message):
|
|
280
|
+
load_raw(cfg)
|
|
281
|
+
|
|
282
|
+
def test_non_string_include(self, tmp_path):
|
|
283
|
+
cfg = _write(tmp_path, {"runspec.toml": "[r]\ninclude = 3\n"})
|
|
284
|
+
with pytest.raises(ValueError, match="must be a path string"):
|
|
285
|
+
load_raw(cfg)
|
|
286
|
+
|
|
287
|
+
def test_symlink_out_of_package_refused(self, tmp_path):
|
|
288
|
+
outside = tmp_path / "outside.toml"
|
|
289
|
+
outside.write_text('description = "x"\n')
|
|
290
|
+
pkg = tmp_path / "pkg"
|
|
291
|
+
cfg = _write(pkg, {"runspec.toml": '[r]\ninclude = "link.toml"\n'})
|
|
292
|
+
try:
|
|
293
|
+
(pkg / "link.toml").symlink_to(outside)
|
|
294
|
+
except OSError:
|
|
295
|
+
pytest.skip("symlinks not available")
|
|
296
|
+
with pytest.raises(ValueError, match="outside the package directory"):
|
|
297
|
+
load_raw(cfg)
|
|
298
|
+
|
|
299
|
+
def test_load_text_with_reader(self):
|
|
300
|
+
files = {"cmd/x.toml": 'description = "x"\n'}
|
|
301
|
+
spec = load_text('[r]\n[r.commands.x]\ninclude = "cmd/x.toml"\n', files.get)
|
|
302
|
+
assert spec["runnables"]["r"]["commands"]["x"]["description"] == "x"
|
|
303
|
+
|
|
304
|
+
def test_load_text_without_reader(self):
|
|
305
|
+
with pytest.raises(ValueError, match="no file access"):
|
|
306
|
+
load_text('[r]\ninclude = "x.toml"\n')
|
|
307
|
+
|
|
308
|
+
def test_has_include(self):
|
|
309
|
+
assert has_include('[r]\ninclude = "x.toml"\n')
|
|
310
|
+
assert has_include('[r]\n include="x.toml"\n')
|
|
311
|
+
assert not has_include('[r]\ndescription = "include = x"\n')
|
|
312
|
+
assert not has_include("[r]\nincluded = 1\n")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ── shared-args / use ─────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class TestSharedArgs:
|
|
319
|
+
def test_runnable_level(self):
|
|
320
|
+
spec = load_text(
|
|
321
|
+
"""
|
|
322
|
+
[gitlab.shared-args]
|
|
323
|
+
project = {type = "str", required = false, env = "GITLAB_PROJECT", description = "Project id or path"}
|
|
324
|
+
|
|
325
|
+
[gitlab.commands.get-issue.args]
|
|
326
|
+
project = {use = "project"}
|
|
327
|
+
|
|
328
|
+
[gitlab.commands.create-issue.args]
|
|
329
|
+
project = {use = "project", required = true}
|
|
330
|
+
source-project = {use = "project"}
|
|
331
|
+
"""
|
|
332
|
+
)["runnables"]["gitlab"]
|
|
333
|
+
get_issue = spec["commands"]["get-issue"]["args"]["project"]
|
|
334
|
+
assert get_issue["required"] is False
|
|
335
|
+
assert get_issue["env"] == ["GITLAB_PROJECT"]
|
|
336
|
+
create = spec["commands"]["create-issue"]["args"]
|
|
337
|
+
assert create["project"]["required"] is True
|
|
338
|
+
assert create["project"]["description"] == "Project id or path"
|
|
339
|
+
assert create["source-project"]["name"] == "source-project"
|
|
340
|
+
assert create["source-project"]["env"] == ["GITLAB_PROJECT"]
|
|
341
|
+
|
|
342
|
+
def test_config_level_across_runnables(self):
|
|
343
|
+
spec = load_text(
|
|
344
|
+
"""
|
|
345
|
+
[config.shared-args]
|
|
346
|
+
proxy = {required = false, description = "HTTP proxy"}
|
|
347
|
+
|
|
348
|
+
[cert-check.args]
|
|
349
|
+
proxy = {use = "proxy"}
|
|
350
|
+
|
|
351
|
+
[http-check.args]
|
|
352
|
+
proxy = {use = "proxy"}
|
|
353
|
+
"""
|
|
354
|
+
)
|
|
355
|
+
for name in ("cert-check", "http-check"):
|
|
356
|
+
assert spec["runnables"][name]["args"]["proxy"]["description"] == "HTTP proxy"
|
|
357
|
+
|
|
358
|
+
def test_nearest_definition_wins(self):
|
|
359
|
+
spec = load_text(
|
|
360
|
+
"""
|
|
361
|
+
[config.shared-args]
|
|
362
|
+
limit = 10
|
|
363
|
+
|
|
364
|
+
[r.shared-args]
|
|
365
|
+
limit = 20
|
|
366
|
+
|
|
367
|
+
[r.commands.a.shared-args]
|
|
368
|
+
limit = 30
|
|
369
|
+
|
|
370
|
+
[r.commands.a.args]
|
|
371
|
+
limit = {use = "limit"}
|
|
372
|
+
|
|
373
|
+
[r.commands.b.args]
|
|
374
|
+
limit = {use = "limit"}
|
|
375
|
+
"""
|
|
376
|
+
)["runnables"]["r"]["commands"]
|
|
377
|
+
assert spec["a"]["args"]["limit"]["default"] == 30
|
|
378
|
+
assert spec["b"]["args"]["limit"]["default"] == 20
|
|
379
|
+
|
|
380
|
+
def test_runnable_args_can_use(self):
|
|
381
|
+
spec = load_text('[r.shared-args]\nverbose = false\n[r.args]\nverbose = {use = "verbose"}\n')
|
|
382
|
+
assert spec["runnables"]["r"]["args"]["verbose"]["default"] is False
|
|
383
|
+
|
|
384
|
+
def test_shared_args_table_removed(self):
|
|
385
|
+
data = tomllib.loads('[config.shared-args]\nx = 1\n[r.shared-args]\ny = 2\n[r.args]\nx = {use = "x"}\n')
|
|
386
|
+
out = resolve(data)
|
|
387
|
+
assert "shared-args" not in out["config"]
|
|
388
|
+
assert "shared-args" not in out["r"]
|
|
389
|
+
assert out["r"]["args"]["x"] == {"default": 1}
|
|
390
|
+
|
|
391
|
+
def test_unused_definitions_are_fine(self):
|
|
392
|
+
spec = load_text("[r.shared-args]\nx = 1\n[r.args]\ny = 2\n")
|
|
393
|
+
assert list(spec["runnables"]["r"]["args"]) == ["y"]
|
|
394
|
+
|
|
395
|
+
def test_unknown_name_suggests(self):
|
|
396
|
+
with pytest.raises(ValueError, match="(?s)unknown shared arg 'projet'.*Did you mean 'project'"):
|
|
397
|
+
load_text('[r.shared-args]\nproject = "x"\n[r.args]\np = {use = "projet"}\n')
|
|
398
|
+
|
|
399
|
+
def test_unknown_name_without_any_table(self):
|
|
400
|
+
with pytest.raises(ValueError, match=r"No \[shared-args\] table"):
|
|
401
|
+
load_text('[r.args]\np = {use = "project"}\n')
|
|
402
|
+
|
|
403
|
+
def test_sibling_scope_not_visible(self):
|
|
404
|
+
with pytest.raises(ValueError, match="unknown shared arg 'x'"):
|
|
405
|
+
load_text('[r.commands.a.shared-args]\nx = 1\n[r.commands.b.args]\nx = {use = "x"}\n')
|
|
406
|
+
|
|
407
|
+
def test_definition_cannot_use(self):
|
|
408
|
+
with pytest.raises(ValueError, match="can't itself use"):
|
|
409
|
+
load_text('[r.shared-args]\na = 1\nb = {use = "a"}\n')
|
|
410
|
+
|
|
411
|
+
def test_use_must_be_a_name(self):
|
|
412
|
+
with pytest.raises(ValueError, match="use must name a shared arg"):
|
|
413
|
+
load_text("[r.shared-args]\na = 1\n[r.args]\na = {use = 3}\n")
|
|
414
|
+
|
|
415
|
+
def test_definitions_not_shared_between_uses(self):
|
|
416
|
+
spec = load_text('[r.shared-args]\nm = {options = ["a", "b"]}\n[r.commands.x.args]\nm = {use = "m"}\n[r.commands.y.args]\nm = {use = "m"}\n')["runnables"]["r"]["commands"]
|
|
417
|
+
spec["x"]["args"]["m"]["options"].append("c")
|
|
418
|
+
assert spec["y"]["args"]["m"]["options"] == ["a", "b"]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|