snakemake-contracts 0.1.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.
@@ -0,0 +1 @@
1
+ **/__pycache__
@@ -0,0 +1,26 @@
1
+ stages:
2
+ - build
3
+ - publish
4
+
5
+ build:
6
+ stage: build
7
+ image: python:3.12
8
+ script:
9
+ - pip install flit
10
+ - flit build
11
+ artifacts:
12
+ paths:
13
+ - dist/
14
+
15
+ publish-pypi:
16
+ stage: publish
17
+ image: python:3.12
18
+ script:
19
+ - pip install flit
20
+ - flit publish
21
+ environment: pypi
22
+ rules:
23
+ - if: $CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/
24
+ variables:
25
+ FLIT_USERNAME: __token__
26
+ FLIT_PASSWORD: $PYPI_TOKEN
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: snakemake-contracts
3
+ Version: 0.1.0
4
+ Summary: Lightweight contract helpers for Snakemake modules
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: PyYAML>=6.0
8
+
9
+ # snakemake-contracts
10
+
11
+ A small helper library for Snakemake workflows that use
12
+ `provides` / `requires`-style contracts between modules.
13
+
14
+ ## Motivation
15
+
16
+ A lot of hidden complexities can stem from splitting a snakemake-based
17
+ project into multiple independent submodules: how do you handle
18
+ changing independent submodule outputs ?
19
+
20
+ This package exists for workflows that are split into many Snakemake submodules
21
+ and need a disciplined way to connect them.
22
+
23
+ In practice, the goal is not just to reuse code. It is to make module boundaries
24
+ explicit and stable:
25
+
26
+ - each module should declare what it consumes and what it exposes;
27
+ - each module should keep its own outputs defined in one place;
28
+ - the root workflow should not become the only place that knows where every
29
+ generated file lives.
30
+
31
+ That matters once a workflow grows beyond a single Snakefile. At that point the
32
+ top-level repo tends to accumulate accidental knowledge about other modules:
33
+ hard-coded file paths, config overrides for foreign outputs, and ad hoc
34
+ assumptions about directory layouts. Those shortcuts make the graph harder to
35
+ reason about and much easier to break when a module changes shape.
36
+
37
+ The contract model used here is intentionally close to the `requires` /
38
+ `provides` style found in systems like nREPL middleware chains: producers
39
+ declare the interface they offer, consumers ask for a named capability, and the
40
+ resolver stays small and mechanical. The point is to treat the dependency
41
+ boundary as a contract, not as an implicit convention spread across the whole
42
+ repository.
43
+
44
+ Using `provides.yaml` as the source of truth also has a practical benefit: the
45
+ module itself remains the authoritative owner of its exported outputs. The repo
46
+ root can still orchestrate modules, but it does not need to mirror every output
47
+ name or path in a central registry. That reduces duplication and keeps the
48
+ dependency metadata next to the module that owns it.
49
+
50
+ ## Scope
51
+
52
+ - resolve declared outputs by logical key
53
+ - locate module output directories
54
+ - validate `provides.yaml` files
55
+ - optionally compare declared contracts against a repo layout
56
+
57
+ ## Library Usage
58
+
59
+ The core API lives in `snakemake_contracts.contracts` and expects a repo layout
60
+ like:
61
+
62
+ ```text
63
+ repo/
64
+ modules/
65
+ my_module/
66
+ Snakefile
67
+ provides.yaml
68
+ out/
69
+ ```
70
+
71
+ Typical usage:
72
+
73
+ ```python
74
+ from snakemake_contracts.contracts import load_provides, provides, validate_repo
75
+
76
+ mapping = load_provides("my_module", repo_root="/path/to/repo")
77
+ report = provides("my_module", "report", repo_root="/path/to/repo")
78
+ problems = validate_repo("/path/to/repo")
79
+ ```
80
+
81
+ `provides.yaml` is the source of truth for logical output names, and `provides()`
82
+ resolves them relative to the module's `out/` directory.
83
+
84
+ ## Wrapper Layer
85
+
86
+ The repository now also includes a separate `wrapper/` package,
87
+ `snakemake_contracts_wrapper`, for Snakemake-facing command construction.
88
+ That layer is intentionally thin: it reads the named `input`, `output`, and
89
+ `params` objects already present in a rule and turns them into ordinary script
90
+ arguments.
91
+
92
+ The script itself still stays unaware of Snakemake. It only sees CLI flags.
93
+ The wrapper only removes the repetitive `--flag {input.foo}` wiring in the
94
+ Snakefile.
95
+
96
+ ## Current Surface
97
+
98
+ - `load_provides(module, repo_root=None)`
99
+ - `provides(module, key, repo_root=None)`
100
+ - `out_dir(module, repo_root=None)`
101
+ - `list_modules(repo_root=None)`
102
+ - `mangle(name)`
103
+ - `validate_repo(repo_root=None)`
104
+
105
+ Wrapper surface:
106
+
107
+ - `build_argv(command, input=None, output=None, params=None, exclude_input=("script",))`
108
+ - `render_shell(command, input=None, output=None, params=None, exclude_input=("script",))`
109
+
110
+ ## Tests
111
+
112
+ - `tests/test_contracts.py` exercises the library directly against a temporary
113
+ repo layout.
114
+ - `tests/test_wrapper.py` covers the wrapper command helpers.
115
+
116
+ ## Status
117
+
118
+ This is a small utility package, not a general Snakemake extension. It is meant
119
+ to support workflows that already use `provides.yaml`-style contracts and want a
120
+ single place to validate and resolve them.
121
+
@@ -0,0 +1,112 @@
1
+ # snakemake-contracts
2
+
3
+ A small helper library for Snakemake workflows that use
4
+ `provides` / `requires`-style contracts between modules.
5
+
6
+ ## Motivation
7
+
8
+ A lot of hidden complexities can stem from splitting a snakemake-based
9
+ project into multiple independent submodules: how do you handle
10
+ changing independent submodule outputs ?
11
+
12
+ This package exists for workflows that are split into many Snakemake submodules
13
+ and need a disciplined way to connect them.
14
+
15
+ In practice, the goal is not just to reuse code. It is to make module boundaries
16
+ explicit and stable:
17
+
18
+ - each module should declare what it consumes and what it exposes;
19
+ - each module should keep its own outputs defined in one place;
20
+ - the root workflow should not become the only place that knows where every
21
+ generated file lives.
22
+
23
+ That matters once a workflow grows beyond a single Snakefile. At that point the
24
+ top-level repo tends to accumulate accidental knowledge about other modules:
25
+ hard-coded file paths, config overrides for foreign outputs, and ad hoc
26
+ assumptions about directory layouts. Those shortcuts make the graph harder to
27
+ reason about and much easier to break when a module changes shape.
28
+
29
+ The contract model used here is intentionally close to the `requires` /
30
+ `provides` style found in systems like nREPL middleware chains: producers
31
+ declare the interface they offer, consumers ask for a named capability, and the
32
+ resolver stays small and mechanical. The point is to treat the dependency
33
+ boundary as a contract, not as an implicit convention spread across the whole
34
+ repository.
35
+
36
+ Using `provides.yaml` as the source of truth also has a practical benefit: the
37
+ module itself remains the authoritative owner of its exported outputs. The repo
38
+ root can still orchestrate modules, but it does not need to mirror every output
39
+ name or path in a central registry. That reduces duplication and keeps the
40
+ dependency metadata next to the module that owns it.
41
+
42
+ ## Scope
43
+
44
+ - resolve declared outputs by logical key
45
+ - locate module output directories
46
+ - validate `provides.yaml` files
47
+ - optionally compare declared contracts against a repo layout
48
+
49
+ ## Library Usage
50
+
51
+ The core API lives in `snakemake_contracts.contracts` and expects a repo layout
52
+ like:
53
+
54
+ ```text
55
+ repo/
56
+ modules/
57
+ my_module/
58
+ Snakefile
59
+ provides.yaml
60
+ out/
61
+ ```
62
+
63
+ Typical usage:
64
+
65
+ ```python
66
+ from snakemake_contracts.contracts import load_provides, provides, validate_repo
67
+
68
+ mapping = load_provides("my_module", repo_root="/path/to/repo")
69
+ report = provides("my_module", "report", repo_root="/path/to/repo")
70
+ problems = validate_repo("/path/to/repo")
71
+ ```
72
+
73
+ `provides.yaml` is the source of truth for logical output names, and `provides()`
74
+ resolves them relative to the module's `out/` directory.
75
+
76
+ ## Wrapper Layer
77
+
78
+ The repository now also includes a separate `wrapper/` package,
79
+ `snakemake_contracts_wrapper`, for Snakemake-facing command construction.
80
+ That layer is intentionally thin: it reads the named `input`, `output`, and
81
+ `params` objects already present in a rule and turns them into ordinary script
82
+ arguments.
83
+
84
+ The script itself still stays unaware of Snakemake. It only sees CLI flags.
85
+ The wrapper only removes the repetitive `--flag {input.foo}` wiring in the
86
+ Snakefile.
87
+
88
+ ## Current Surface
89
+
90
+ - `load_provides(module, repo_root=None)`
91
+ - `provides(module, key, repo_root=None)`
92
+ - `out_dir(module, repo_root=None)`
93
+ - `list_modules(repo_root=None)`
94
+ - `mangle(name)`
95
+ - `validate_repo(repo_root=None)`
96
+
97
+ Wrapper surface:
98
+
99
+ - `build_argv(command, input=None, output=None, params=None, exclude_input=("script",))`
100
+ - `render_shell(command, input=None, output=None, params=None, exclude_input=("script",))`
101
+
102
+ ## Tests
103
+
104
+ - `tests/test_contracts.py` exercises the library directly against a temporary
105
+ repo layout.
106
+ - `tests/test_wrapper.py` covers the wrapper command helpers.
107
+
108
+ ## Status
109
+
110
+ This is a small utility package, not a general Snakemake extension. It is meant
111
+ to support workflows that already use `provides.yaml`-style contracts and want a
112
+ single place to validate and resolve them.
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snakemake-contracts"
7
+ version = "0.1.0"
8
+ description = "Lightweight contract helpers for Snakemake modules"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = ["PyYAML>=6.0"]
12
+
13
+ [project.scripts]
14
+ snakemake-contracts = "snakemake_contracts.cli:main"
15
+
16
+ [tool.setuptools]
17
+ package-dir = {"" = "src", "snakemake_contracts_wrapper" = "wrapper/snakemake_contracts_wrapper"}
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src", "wrapper"]
@@ -0,0 +1 @@
1
+ from .contracts import ContractError, load_provides, out_dir, provides, list_modules, mangle, validate_repo
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ from .contracts import list_modules, load_provides, provides, validate_repo
8
+
9
+
10
+ def _cmd_list_modules(args: argparse.Namespace) -> int:
11
+ for module in list_modules(args.repo_root):
12
+ print(module)
13
+ return 0
14
+
15
+
16
+ def _cmd_list_keys(args: argparse.Namespace) -> int:
17
+ data = load_provides(args.module, args.repo_root)
18
+ for key in sorted(data):
19
+ print(f"{key}\t{data[key]}")
20
+ return 0
21
+
22
+
23
+ def _cmd_resolve(args: argparse.Namespace) -> int:
24
+ print(provides(args.module, args.key, args.repo_root))
25
+ return 0
26
+
27
+
28
+ def _cmd_validate(args: argparse.Namespace) -> int:
29
+ problems = validate_repo(args.repo_root)
30
+ if problems:
31
+ for problem in problems:
32
+ print(problem, file=sys.stderr)
33
+ return 1
34
+ return 0
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ parser = argparse.ArgumentParser(prog="snakemake-contracts")
39
+ parser.add_argument("--repo-root", type=Path, default=None)
40
+ sub = parser.add_subparsers(dest="cmd", required=True)
41
+
42
+ p = sub.add_parser("list-modules")
43
+ p.set_defaults(func=_cmd_list_modules)
44
+
45
+ p = sub.add_parser("list-keys")
46
+ p.add_argument("module")
47
+ p.set_defaults(func=_cmd_list_keys)
48
+
49
+ p = sub.add_parser("resolve")
50
+ p.add_argument("module")
51
+ p.add_argument("key")
52
+ p.set_defaults(func=_cmd_resolve)
53
+
54
+ p = sub.add_parser("validate")
55
+ p.set_defaults(func=_cmd_validate)
56
+
57
+ return parser
58
+
59
+
60
+ def main(argv: list[str] | None = None) -> int:
61
+ parser = build_parser()
62
+ args = parser.parse_args(argv)
63
+ return args.func(args)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ raise SystemExit(main())
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ContractError(Exception):
12
+ message: str
13
+
14
+ def __str__(self) -> str:
15
+ return self.message
16
+
17
+
18
+ def _repo_root(repo_root: str | Path | None = None) -> Path:
19
+ if repo_root is None:
20
+ return Path(__file__).resolve().parents[2]
21
+ return Path(repo_root)
22
+
23
+
24
+ def modules_dir(repo_root: str | Path | None = None) -> Path:
25
+ return _repo_root(repo_root) / "modules"
26
+
27
+
28
+ def load_provides(module: str, repo_root: str | Path | None = None) -> dict[str, str]:
29
+ path = modules_dir(repo_root) / module / "provides.yaml"
30
+ if not path.exists():
31
+ raise FileNotFoundError(f"{path} not found")
32
+ with path.open() as f:
33
+ data = yaml.safe_load(f) or {}
34
+ if not isinstance(data, dict):
35
+ raise ContractError(f"{path} must contain a mapping of logical keys to relative paths")
36
+ bad = [k for k, v in data.items() if not isinstance(k, str) or not isinstance(v, str)]
37
+ if bad:
38
+ raise ContractError(f"{path} contains non-string keys/values: {bad}")
39
+ return data
40
+
41
+
42
+ def out_dir(module: str, repo_root: str | Path | None = None) -> Path:
43
+ return modules_dir(repo_root) / module / "out"
44
+
45
+
46
+ def provides(module: str, key: str, repo_root: str | Path | None = None) -> str:
47
+ mapping = load_provides(module, repo_root=repo_root)
48
+ if key not in mapping:
49
+ raise KeyError(f"{module}:{key} not declared; available keys: {sorted(mapping)}")
50
+ return str(out_dir(module, repo_root=repo_root) / mapping[key])
51
+
52
+
53
+ def list_modules(repo_root: str | Path | None = None) -> list[str]:
54
+ root = modules_dir(repo_root)
55
+ if not root.exists():
56
+ return []
57
+ return sorted(p.name for p in root.iterdir() if p.is_dir() and (p / "Snakefile").exists())
58
+
59
+
60
+ def mangle(name: str) -> str:
61
+ return name.replace("-", "_").replace(".", "_")
62
+
63
+
64
+ def validate_repo(repo_root: str | Path | None = None) -> list[str]:
65
+ root = _repo_root(repo_root)
66
+ problems: list[str] = []
67
+ for module in list_modules(root):
68
+ p = modules_dir(root) / module / "provides.yaml"
69
+ if not p.exists():
70
+ continue
71
+ try:
72
+ load_provides(module, root)
73
+ except Exception as exc: # noqa: BLE001
74
+ problems.append(f"{module}: {exc}")
75
+ return problems
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(ROOT / "src"))
10
+
11
+ from snakemake_contracts.contracts import list_modules, load_provides, provides, validate_repo
12
+
13
+
14
+ class ContractLibraryTests(unittest.TestCase):
15
+ def test_load_provides_and_resolve_output_path(self) -> None:
16
+ with tempfile.TemporaryDirectory() as tmp:
17
+ repo_root = Path(tmp)
18
+ module_root = repo_root / "modules" / "alpha"
19
+ module_root.mkdir(parents=True)
20
+ (module_root / "Snakefile").write_text("rule all:\n pass\n")
21
+ (module_root / "provides.yaml").write_text("reads: reads.fastq\nreport: results/report.tsv\n")
22
+
23
+ self.assertEqual(
24
+ load_provides("alpha", repo_root),
25
+ {"reads": "reads.fastq", "report": "results/report.tsv"},
26
+ )
27
+ self.assertEqual(
28
+ provides("alpha", "report", repo_root),
29
+ str(module_root / "out" / "results/report.tsv"),
30
+ )
31
+
32
+ def test_list_modules_and_validate_repo_use_real_repo_layout(self) -> None:
33
+ with tempfile.TemporaryDirectory() as tmp:
34
+ repo_root = Path(tmp)
35
+
36
+ good = repo_root / "modules" / "good"
37
+ good.mkdir(parents=True)
38
+ (good / "Snakefile").write_text("rule all:\n pass\n")
39
+ (good / "provides.yaml").write_text("x: out/x.txt\n")
40
+
41
+ ignored = repo_root / "modules" / "ignored"
42
+ ignored.mkdir(parents=True)
43
+ (ignored / "provides.yaml").write_text("bad: 123\n")
44
+
45
+ self.assertEqual(list_modules(repo_root), ["good"])
46
+ self.assertEqual(validate_repo(repo_root), [])
47
+
48
+
49
+ if __name__ == "__main__":
50
+ unittest.main()
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(ROOT / "wrapper"))
9
+ sys.path.insert(0, str(ROOT / "src"))
10
+
11
+ from snakemake_contracts_wrapper import build_argv, namespace_flag, render_shell, snake_to_kebab
12
+
13
+
14
+ class WrapperCommandTests(unittest.TestCase):
15
+ def test_snake_to_kebab(self) -> None:
16
+ self.assertEqual(snake_to_kebab("rp2019_f09_87"), "rp2019-f09-87")
17
+
18
+ def test_namespace_flag(self) -> None:
19
+ self.assertEqual(namespace_flag("input", "rp_stock"), "--rp-stock")
20
+ self.assertEqual(namespace_flag("output", "m2_86_inv"), "--output-m2-86-inv")
21
+
22
+ def test_build_argv_excludes_script_and_expands_names(self) -> None:
23
+ argv = build_argv(
24
+ ["python3", "/tmp/build_joint.py"],
25
+ input={"script": "/tmp/build_joint.py", "rp_stock": "/data/rp_stock.csv"},
26
+ output={"f09": "/tmp/out.parquet"},
27
+ params={"mc_samples": 10},
28
+ )
29
+ self.assertEqual(
30
+ argv,
31
+ [
32
+ "python3",
33
+ "/tmp/build_joint.py",
34
+ "--rp-stock",
35
+ "/data/rp_stock.csv",
36
+ "--output-f09",
37
+ "/tmp/out.parquet",
38
+ "--mc-samples",
39
+ "10",
40
+ ],
41
+ )
42
+
43
+ def test_build_argv_handles_log_namespace(self) -> None:
44
+ argv = build_argv(
45
+ "python3",
46
+ input={"sample": "/tmp/sample.csv"},
47
+ output={"result": "/tmp/out.csv"},
48
+ log={"log": "/tmp/run.log"},
49
+ output_prefix="",
50
+ )
51
+ self.assertEqual(
52
+ argv,
53
+ [
54
+ "python3",
55
+ "--sample",
56
+ "/tmp/sample.csv",
57
+ "--result",
58
+ "/tmp/out.csv",
59
+ "--log",
60
+ "/tmp/run.log",
61
+ ],
62
+ )
63
+
64
+ def test_build_argv_expands_sequences(self) -> None:
65
+ argv = build_argv(
66
+ "python3",
67
+ input={"reads": [Path("/tmp/r1.fastq"), Path("/tmp/r2.fastq")]},
68
+ )
69
+ self.assertEqual(
70
+ argv,
71
+ [
72
+ "python3",
73
+ "--reads",
74
+ "/tmp/r1.fastq",
75
+ "--reads",
76
+ "/tmp/r2.fastq",
77
+ ],
78
+ )
79
+
80
+ def test_render_shell_quotes(self) -> None:
81
+ command = render_shell(
82
+ ["python3", "/tmp/build joint.py"],
83
+ input={"sample_name": "a b"},
84
+ )
85
+ self.assertIn("'/tmp/build joint.py'", command)
86
+ self.assertIn("--sample-name", command)
87
+ self.assertIn("'a b'", command)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ unittest.main()
@@ -0,0 +1,3 @@
1
+ from .command import build_argv, namespace_flag, render_shell, snake_to_kebab
2
+
3
+ __all__ = ["build_argv", "namespace_flag", "render_shell", "snake_to_kebab"]
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping, Sequence
4
+ from pathlib import Path
5
+ import shlex
6
+ from typing import Any
7
+
8
+
9
+ def snake_to_kebab(name: str) -> str:
10
+ return name.replace("_", "-")
11
+
12
+
13
+ def namespace_flag(namespace: str, name: str, *, output_prefix: str = "output-") -> str:
14
+ flag = snake_to_kebab(name)
15
+ if namespace == "output":
16
+ flag = f"{output_prefix}{flag}"
17
+ return f"--{flag}"
18
+
19
+
20
+ def _is_scalar(value: Any) -> bool:
21
+ return isinstance(value, (str, bytes, Path)) or not isinstance(value, Iterable)
22
+
23
+
24
+ def _iter_items(namespace: Any) -> list[tuple[str, Any]]:
25
+ if namespace is None:
26
+ return []
27
+ if isinstance(namespace, Mapping):
28
+ return list(namespace.items())
29
+ items = getattr(namespace, "items", None)
30
+ if callable(items):
31
+ return list(items())
32
+ keys = getattr(namespace, "keys", None)
33
+ if callable(keys):
34
+ return [(key, namespace[key]) for key in keys()]
35
+ raise TypeError(f"Unsupported namespace object: {type(namespace)!r}")
36
+
37
+
38
+ def _extend_namespace(
39
+ argv: list[str],
40
+ *,
41
+ namespace: str,
42
+ values: Any,
43
+ exclude: set[str] | None = None,
44
+ output_prefix: str = "output-",
45
+ ) -> None:
46
+ excluded = exclude or set()
47
+ for name, value in _iter_items(values):
48
+ if name in excluded:
49
+ continue
50
+ flag = namespace_flag(namespace, name, output_prefix=output_prefix)
51
+ if _is_scalar(value):
52
+ argv.extend([flag, str(value)])
53
+ continue
54
+ for item in value:
55
+ argv.extend([flag, str(item)])
56
+
57
+
58
+ def build_argv(
59
+ command: str | Sequence[Any],
60
+ *,
61
+ input: Any = None,
62
+ output: Any = None,
63
+ log: Any = None,
64
+ params: Any = None,
65
+ exclude_input: Iterable[str] = ("script",),
66
+ output_prefix: str = "output-",
67
+ ) -> list[str]:
68
+ argv: list[str] = []
69
+ if isinstance(command, str):
70
+ argv.append(command)
71
+ else:
72
+ argv.extend(str(item) for item in command)
73
+
74
+ _extend_namespace(
75
+ argv,
76
+ namespace="input",
77
+ values=input,
78
+ exclude=set(exclude_input),
79
+ output_prefix=output_prefix,
80
+ )
81
+ _extend_namespace(
82
+ argv,
83
+ namespace="output",
84
+ values=output,
85
+ output_prefix=output_prefix,
86
+ )
87
+ _extend_namespace(
88
+ argv,
89
+ namespace="log",
90
+ values=log,
91
+ output_prefix=output_prefix,
92
+ )
93
+ _extend_namespace(
94
+ argv,
95
+ namespace="params",
96
+ values=params,
97
+ output_prefix=output_prefix,
98
+ )
99
+ return argv
100
+
101
+
102
+ def render_shell(
103
+ command: str | Sequence[Any],
104
+ *,
105
+ input: Any = None,
106
+ output: Any = None,
107
+ log: Any = None,
108
+ params: Any = None,
109
+ exclude_input: Iterable[str] = ("script",),
110
+ output_prefix: str = "output-",
111
+ ) -> str:
112
+ return " ".join(
113
+ shlex.quote(arg)
114
+ for arg in build_argv(
115
+ command,
116
+ input=input,
117
+ output=output,
118
+ log=log,
119
+ params=params,
120
+ exclude_input=exclude_input,
121
+ output_prefix=output_prefix,
122
+ )
123
+ )