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.
Files changed (86) hide show
  1. {runspec-0.76.2 → runspec-0.77.0}/PKG-INFO +4 -4
  2. {runspec-0.76.2 → runspec-0.77.0}/pyproject.toml +4 -4
  3. {runspec-0.76.2 → runspec-0.77.0}/runspec/cli.py +4 -2
  4. runspec-0.77.0/runspec/compose.py +245 -0
  5. {runspec-0.76.2 → runspec-0.77.0}/runspec/loader.py +31 -1
  6. {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/PKG-INFO +4 -4
  7. {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/SOURCES.txt +2 -0
  8. runspec-0.77.0/tests/test_compose.py +418 -0
  9. {runspec-0.76.2 → runspec-0.77.0}/runspec/__init__.py +0 -0
  10. {runspec-0.76.2 → runspec-0.77.0}/runspec/ansiblegen.py +0 -0
  11. {runspec-0.76.2 → runspec-0.77.0}/runspec/auditlog.py +0 -0
  12. {runspec-0.76.2 → runspec-0.77.0}/runspec/become.py +0 -0
  13. {runspec-0.76.2 → runspec-0.77.0}/runspec/denylist.py +0 -0
  14. {runspec-0.76.2 → runspec-0.77.0}/runspec/discovery.py +0 -0
  15. {runspec-0.76.2 → runspec-0.77.0}/runspec/emitutil.py +0 -0
  16. {runspec-0.76.2 → runspec-0.77.0}/runspec/env.py +0 -0
  17. {runspec-0.76.2 → runspec-0.77.0}/runspec/errors.py +0 -0
  18. {runspec-0.76.2 → runspec-0.77.0}/runspec/execute.py +0 -0
  19. {runspec-0.76.2 → runspec-0.77.0}/runspec/finder.py +0 -0
  20. {runspec-0.76.2 → runspec-0.77.0}/runspec/inbox.py +0 -0
  21. {runspec-0.76.2 → runspec-0.77.0}/runspec/inference.py +0 -0
  22. {runspec-0.76.2 → runspec-0.77.0}/runspec/jump.py +0 -0
  23. {runspec-0.76.2 → runspec-0.77.0}/runspec/logging_setup.py +0 -0
  24. {runspec-0.76.2 → runspec-0.77.0}/runspec/logs.py +0 -0
  25. {runspec-0.76.2 → runspec-0.77.0}/runspec/mock.py +0 -0
  26. {runspec-0.76.2 → runspec-0.77.0}/runspec/models.py +0 -0
  27. {runspec-0.76.2 → runspec-0.77.0}/runspec/parser.py +0 -0
  28. {runspec-0.76.2 → runspec-0.77.0}/runspec/py.typed +0 -0
  29. {runspec-0.76.2 → runspec-0.77.0}/runspec/rebrand.py +0 -0
  30. {runspec-0.76.2 → runspec-0.77.0}/runspec/rundeckgen.py +0 -0
  31. {runspec-0.76.2 → runspec-0.77.0}/runspec/runexec.py +0 -0
  32. {runspec-0.76.2 → runspec-0.77.0}/runspec/runsdb.py +0 -0
  33. {runspec-0.76.2 → runspec-0.77.0}/runspec/runspec.toml +0 -0
  34. {runspec-0.76.2 → runspec-0.77.0}/runspec/schemacheck.py +0 -0
  35. {runspec-0.76.2 → runspec-0.77.0}/runspec/serve.py +0 -0
  36. {runspec-0.76.2 → runspec-0.77.0}/runspec/stubgen.py +0 -0
  37. {runspec-0.76.2 → runspec-0.77.0}/runspec/testing.py +0 -0
  38. {runspec-0.76.2 → runspec-0.77.0}/runspec/types.py +0 -0
  39. {runspec-0.76.2 → runspec-0.77.0}/runspec/validator.py +0 -0
  40. {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/dependency_links.txt +0 -0
  41. {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/entry_points.txt +0 -0
  42. {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/requires.txt +0 -0
  43. {runspec-0.76.2 → runspec-0.77.0}/runspec.egg-info/top_level.txt +0 -0
  44. {runspec-0.76.2 → runspec-0.77.0}/setup.cfg +0 -0
  45. {runspec-0.76.2 → runspec-0.77.0}/tests/__init__.py +0 -0
  46. {runspec-0.76.2 → runspec-0.77.0}/tests/conftest.py +0 -0
  47. {runspec-0.76.2 → runspec-0.77.0}/tests/test_agent.py +0 -0
  48. {runspec-0.76.2 → runspec-0.77.0}/tests/test_ansiblegen.py +0 -0
  49. {runspec-0.76.2 → runspec-0.77.0}/tests/test_audit.py +0 -0
  50. {runspec-0.76.2 → runspec-0.77.0}/tests/test_become.py +0 -0
  51. {runspec-0.76.2 → runspec-0.77.0}/tests/test_cli_discover.py +0 -0
  52. {runspec-0.76.2 → runspec-0.77.0}/tests/test_cli_emit.py +0 -0
  53. {runspec-0.76.2 → runspec-0.77.0}/tests/test_cli_init.py +0 -0
  54. {runspec-0.76.2 → runspec-0.77.0}/tests/test_cli_test.py +0 -0
  55. {runspec-0.76.2 → runspec-0.77.0}/tests/test_denylist.py +0 -0
  56. {runspec-0.76.2 → runspec-0.77.0}/tests/test_discovery.py +0 -0
  57. {runspec-0.76.2 → runspec-0.77.0}/tests/test_env.py +0 -0
  58. {runspec-0.76.2 → runspec-0.77.0}/tests/test_exec.py +0 -0
  59. {runspec-0.76.2 → runspec-0.77.0}/tests/test_finder.py +0 -0
  60. {runspec-0.76.2 → runspec-0.77.0}/tests/test_inbox.py +0 -0
  61. {runspec-0.76.2 → runspec-0.77.0}/tests/test_inference.py +0 -0
  62. {runspec-0.76.2 → runspec-0.77.0}/tests/test_jump.py +0 -0
  63. {runspec-0.76.2 → runspec-0.77.0}/tests/test_loader.py +0 -0
  64. {runspec-0.76.2 → runspec-0.77.0}/tests/test_log_resilience.py +0 -0
  65. {runspec-0.76.2 → runspec-0.77.0}/tests/test_logging_setup.py +0 -0
  66. {runspec-0.76.2 → runspec-0.77.0}/tests/test_logs.py +0 -0
  67. {runspec-0.76.2 → runspec-0.77.0}/tests/test_mock.py +0 -0
  68. {runspec-0.76.2 → runspec-0.77.0}/tests/test_models.py +0 -0
  69. {runspec-0.76.2 → runspec-0.77.0}/tests/test_parser.py +0 -0
  70. {runspec-0.76.2 → runspec-0.77.0}/tests/test_password.py +0 -0
  71. {runspec-0.76.2 → runspec-0.77.0}/tests/test_rebrand.py +0 -0
  72. {runspec-0.76.2 → runspec-0.77.0}/tests/test_run_as_enforce.py +0 -0
  73. {runspec-0.76.2 → runspec-0.77.0}/tests/test_run_summary.py +0 -0
  74. {runspec-0.76.2 → runspec-0.77.0}/tests/test_rundeckgen.py +0 -0
  75. {runspec-0.76.2 → runspec-0.77.0}/tests/test_runsdb.py +0 -0
  76. {runspec-0.76.2 → runspec-0.77.0}/tests/test_runsdb_events.py +0 -0
  77. {runspec-0.76.2 → runspec-0.77.0}/tests/test_runsdb_sync.py +0 -0
  78. {runspec-0.76.2 → runspec-0.77.0}/tests/test_schemacheck.py +0 -0
  79. {runspec-0.76.2 → runspec-0.77.0}/tests/test_secret_argv.py +0 -0
  80. {runspec-0.76.2 → runspec-0.77.0}/tests/test_serve.py +0 -0
  81. {runspec-0.76.2 → runspec-0.77.0}/tests/test_serve_e2e.py +0 -0
  82. {runspec-0.76.2 → runspec-0.77.0}/tests/test_stubgen.py +0 -0
  83. {runspec-0.76.2 → runspec-0.77.0}/tests/test_testing.py +0 -0
  84. {runspec-0.76.2 → runspec-0.77.0}/tests/test_types.py +0 -0
  85. {runspec-0.76.2 → runspec-0.77.0}/tests/test_typing.py +0 -0
  86. {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.76.2
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/JasonFinestone/runspec
9
- Project-URL: Issues, https://github.com/JasonFinestone/runspec/issues
10
- Project-URL: Changelog, https://github.com/JasonFinestone/runspec/blob/main/CHANGELOG.md
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.76.2"
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/JasonFinestone/runspec"
45
- Issues = "https://github.com/JasonFinestone/runspec/issues"
46
- Changelog = "https://github.com/JasonFinestone/runspec/blob/main/CHANGELOG.md"
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 = _load_toml_file(Path(source))
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/JasonFinestone/runspec) for CLI interfaces.\n"
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.76.2
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/JasonFinestone/runspec
9
- Project-URL: Issues, https://github.com/JasonFinestone/runspec/issues
10
- Project-URL: Changelog, https://github.com/JasonFinestone/runspec/blob/main/CHANGELOG.md
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