snakemake-contracts 0.1.0__py3-none-any.whl

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
+ 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,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,7 @@
1
+ snakemake_contracts/__init__.py,sha256=YlDffLM2jrfYFELcyxkVhPVwEtTUJvoPmZR940an28E,108
2
+ snakemake_contracts/cli.py,sha256=pfKXWX7QnXSo5ypf3WFU-N7k9VIYRufc5BT0rE_mPPY,1688
3
+ snakemake_contracts/contracts.py,sha256=KpNobLzfXGedcnIJAOnhu095uuTm2S8l1EODhnOhR_o,2386
4
+ snakemake_contracts-0.1.0.dist-info/entry_points.txt,sha256=MucvaXCixO2EncluCLB3cQWqGwkuBdUgmXBnZCnwLpU,68
5
+ snakemake_contracts-0.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
6
+ snakemake_contracts-0.1.0.dist-info/METADATA,sha256=U-uDQF_YKd-aWM4xYnYhR3Ba4_QBKZ0j5XZ4-w8XgDk,4238
7
+ snakemake_contracts-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ snakemake-contracts=snakemake_contracts.cli:main
3
+