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.
- snakemake_contracts/__init__.py +1 -0
- snakemake_contracts/cli.py +67 -0
- snakemake_contracts/contracts.py +75 -0
- snakemake_contracts-0.1.0.dist-info/METADATA +121 -0
- snakemake_contracts-0.1.0.dist-info/RECORD +7 -0
- snakemake_contracts-0.1.0.dist-info/WHEEL +4 -0
- snakemake_contracts-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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,,
|