specreq 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.
specreq-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Hogben
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
specreq-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: specreq
3
+ Version: 0.1.0
4
+ Summary: Write product specs as Python code.
5
+ Author-email: William Hogben <will@willhogben.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://willhogben.com
8
+ Project-URL: Repository, https://github.com/whogben/specreq
9
+ Requires-Python: >=3.10
10
+ License-File: LICENSE
11
+ Requires-Dist: pydantic>=2.0
12
+ Requires-Dist: typer>=0.9
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7; extra == "dev"
15
+ Dynamic: license-file
@@ -0,0 +1,6 @@
1
+ # specreq
2
+
3
+ Write product specs as Python code.
4
+ 1. Define reusable elements by subclassing `Requirement` — override `validate(self, product: Path)` for each node, or `_validate` to control how children are validated.
5
+ 2. Instance reusable elements to create specific product specs.
6
+ 3. Run `specreq <spec> <product> [--save] [--strict]` — `--save` writes the requirement tree as JSON; `--strict` turns off exception catching inside `_validate` (fail fast). Default collects exceptions with tracebacks.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "specreq"
7
+ version = "0.1.0"
8
+ description = "Write product specs as Python code."
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "William Hogben", email = "will@willhogben.com" },
13
+ ]
14
+ urls.Homepage = "https://willhogben.com"
15
+ urls.Repository = "https://github.com/whogben/specreq"
16
+ dependencies = [
17
+ "pydantic>=2.0",
18
+ "typer>=0.9",
19
+ ]
20
+
21
+ [project.scripts]
22
+ specreq = "specreq.cli:app"
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=7"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,93 @@
1
+ """specreq — write product specs as Python code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import traceback
6
+ from pathlib import Path
7
+ from typing import TypeVar, Generic
8
+
9
+ from pydantic import BaseModel
10
+
11
+ T = TypeVar("T", bound=BaseModel)
12
+
13
+
14
+ class Requirement(Generic[T]):
15
+ """Subclass to define a reusable spec element. Create instances to build a spec tree."""
16
+
17
+ _instances: list[Requirement] = []
18
+
19
+ def __init__(self, config: T | None = None, parent: Requirement | None = None):
20
+ self.config = config
21
+ self.parent = parent
22
+ self.children: list[Requirement] = []
23
+ if parent is not None:
24
+ parent.children.append(self)
25
+ Requirement._instances.append(self)
26
+
27
+ @staticmethod
28
+ def _format_node_exception(node: Requirement, exc: BaseException) -> str:
29
+ fqn = f"{node.__class__.__module__}.{node.__class__.__qualname__}"
30
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
31
+ return f"exception at {fqn}: {type(exc).__name__}: {exc}\n{tb}"
32
+
33
+ def _validate(self, product: Path, *, survive_exceptions: bool = True) -> list[str]:
34
+ """Run ``_validate`` on each child, then ``validate`` on this node.
35
+
36
+ Override this when you need to choose which children validate or in what order.
37
+ Forward ``survive_exceptions`` if you call ``super()._validate`` from a subclass.
38
+
39
+ When ``survive_exceptions`` is True (default), child failures are recorded with a
40
+ full traceback string and validation continues. When False, the first exception
41
+ propagates (``validate`` is never wrapped — only this orchestration).
42
+ """
43
+ issues: list[str] = []
44
+ for child in self.children:
45
+ if survive_exceptions:
46
+ try:
47
+ issues.extend(
48
+ child._validate(
49
+ product, survive_exceptions=survive_exceptions
50
+ )
51
+ )
52
+ except Exception as e:
53
+ issues.append(self._format_node_exception(child, e))
54
+ else:
55
+ issues.extend(
56
+ child._validate(
57
+ product, survive_exceptions=survive_exceptions
58
+ )
59
+ )
60
+ if survive_exceptions:
61
+ try:
62
+ issues.extend(self.validate(product))
63
+ except Exception as e:
64
+ issues.append(self._format_node_exception(self, e))
65
+ else:
66
+ issues.extend(self.validate(product))
67
+ return issues
68
+
69
+ def validate(self, product: Path) -> list[str]:
70
+ """Return issues for this node only. Children are handled by ``_validate``."""
71
+ return []
72
+
73
+ def to_dict(self) -> dict:
74
+ d: dict = {
75
+ "class": f"{self.__class__.__module__}.{self.__class__.__qualname__}",
76
+ }
77
+ if self.config is not None:
78
+ d["config"] = self.config.model_dump()
79
+ if self.children:
80
+ d["children"] = [c.to_dict() for c in self.children]
81
+ return d
82
+
83
+ @classmethod
84
+ def export_roots(cls) -> list[dict]:
85
+ """Serialize all root instances (no parent) as a list of dicts."""
86
+ return [i.to_dict() for i in cls._instances if i.parent is None]
87
+
88
+ @classmethod
89
+ def reset(cls):
90
+ cls._instances.clear()
91
+
92
+
93
+ __all__ = ["Requirement"]
@@ -0,0 +1,117 @@
1
+ """specreq CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import sys
8
+ import traceback
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+ from specreq import Requirement
14
+
15
+ app = typer.Typer()
16
+
17
+
18
+ def _resolve_module_name(target: str) -> str:
19
+ """Top-level module name used by importlib / sys.modules for this target."""
20
+ path = Path(target)
21
+ if path.exists():
22
+ return path.name if path.is_dir() else path.stem
23
+ return target
24
+
25
+
26
+ def _import_target(target: str):
27
+ """Import a module by dotted name or file path. Returns the module."""
28
+ path = Path(target)
29
+ if path.exists():
30
+ # file or dir — add parent to sys.path, import by name
31
+ parent = str(path.parent.resolve())
32
+ if parent not in sys.path:
33
+ sys.path.insert(0, parent)
34
+ if path.is_dir():
35
+ name = path.name
36
+ else:
37
+ name = path.stem
38
+ return importlib.import_module(name)
39
+ else:
40
+ # treat as dotted module name
41
+ return importlib.import_module(target)
42
+
43
+
44
+ def _load_spec(target: str):
45
+ """Load or reload the spec module so import-time instances run after Requirement.reset()."""
46
+ name = _resolve_module_name(target)
47
+ if name in sys.modules:
48
+ return importlib.reload(sys.modules[name])
49
+ return _import_target(target)
50
+
51
+
52
+ @app.command()
53
+ def validate(
54
+ spec: Annotated[str, typer.Argument(help="Spec module: file path or dotted name")],
55
+ product: Annotated[str, typer.Argument(help="Path to the product directory")],
56
+ save: bool = typer.Option(False, "--save", help="Save spec tree as JSON on success"),
57
+ strict: bool = typer.Option(
58
+ False,
59
+ "--strict",
60
+ help="Propagate validator exceptions (no per-node catch inside _validate)",
61
+ ),
62
+ ):
63
+ """Validate a product against a spec."""
64
+ Requirement.reset()
65
+ try:
66
+ _load_spec(spec)
67
+ except ModuleNotFoundError as e:
68
+ typer.echo(f"Could not import spec: {e}")
69
+ raise typer.Exit(1)
70
+
71
+ instances = Requirement._instances
72
+ if not instances:
73
+ typer.echo("No Requirement instances found in spec.")
74
+ raise typer.Exit(1)
75
+
76
+ product_path_obj = Path(product).resolve()
77
+ if not product_path_obj.exists():
78
+ typer.echo(f"Product path does not exist: {product_path_obj}")
79
+ raise typer.Exit(1)
80
+
81
+ roots = [i for i in instances if i.parent is None]
82
+ if not roots:
83
+ typer.echo("No root Requirement instances found (every instance has a parent).")
84
+ raise typer.Exit(1)
85
+
86
+ survive = not strict
87
+ issues: list[str] = []
88
+ for root in roots:
89
+ tag = f"{root.__class__.__module__}.{root.__class__.__qualname__}"
90
+ try:
91
+ lines = root._validate(
92
+ product_path_obj, survive_exceptions=survive
93
+ )
94
+ issues.extend(f"[root {tag}] {line}" for line in lines)
95
+ except Exception as e:
96
+ tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
97
+ issues.append(
98
+ f"[root {tag}] exception escaped _validate tree: "
99
+ f"{type(e).__name__}: {e}\n{tb}"
100
+ )
101
+
102
+ if issues:
103
+ typer.echo(f"Issues ({len(issues)}):")
104
+ for i in issues:
105
+ typer.echo(f" - {i}")
106
+ raise typer.Exit(1)
107
+
108
+ typer.echo(f"Valid. {len(roots)} root(s) passed.")
109
+
110
+ if save:
111
+ out = Path(f"{Path(spec).stem}.json") if Path(spec).exists() else Path(f"{spec.split('.')[-1]}.json")
112
+ out.write_text(json.dumps(Requirement.export_roots(), indent=2))
113
+ typer.echo(f"Saved {out}")
114
+
115
+
116
+ if __name__ == "__main__":
117
+ app()
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: specreq
3
+ Version: 0.1.0
4
+ Summary: Write product specs as Python code.
5
+ Author-email: William Hogben <will@willhogben.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://willhogben.com
8
+ Project-URL: Repository, https://github.com/whogben/specreq
9
+ Requires-Python: >=3.10
10
+ License-File: LICENSE
11
+ Requires-Dist: pydantic>=2.0
12
+ Requires-Dist: typer>=0.9
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7; extra == "dev"
15
+ Dynamic: license-file
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/specreq/__init__.py
5
+ src/specreq/cli.py
6
+ src/specreq.egg-info/PKG-INFO
7
+ src/specreq.egg-info/SOURCES.txt
8
+ src/specreq.egg-info/dependency_links.txt
9
+ src/specreq.egg-info/entry_points.txt
10
+ src/specreq.egg-info/requires.txt
11
+ src/specreq.egg-info/top_level.txt
12
+ tests/test_cli.py
13
+ tests/test_requirement.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ specreq = specreq.cli:app
@@ -0,0 +1,5 @@
1
+ pydantic>=2.0
2
+ typer>=0.9
3
+
4
+ [dev]
5
+ pytest>=7
@@ -0,0 +1 @@
1
+ specreq
@@ -0,0 +1,151 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from typer.testing import CliRunner
5
+
6
+ from specreq.cli import app
7
+
8
+ runner = CliRunner()
9
+
10
+ SPEC_PY = '''\
11
+ from specreq import Requirement
12
+
13
+
14
+ class Root(Requirement):
15
+ pass
16
+
17
+
18
+ Root()
19
+ '''
20
+
21
+ SPEC_NO_INSTANCES = """\
22
+ from specreq import Requirement
23
+
24
+
25
+ class Root(Requirement):
26
+ pass
27
+ """
28
+
29
+ SPEC_ERROR = '''\
30
+ from pathlib import Path
31
+
32
+ from specreq import Requirement
33
+
34
+
35
+ class Root(Requirement):
36
+ def validate(self, product: Path) -> list[str]:
37
+ raise ValueError("spec boom")
38
+
39
+
40
+ Root()
41
+ '''
42
+
43
+ SPEC_TWO_ROOTS = '''\
44
+ from specreq import Requirement
45
+
46
+
47
+ class A(Requirement):
48
+ pass
49
+
50
+
51
+ class B(Requirement):
52
+ pass
53
+
54
+
55
+ A()
56
+ B()
57
+ '''
58
+
59
+
60
+ def _write_spec(tmp: Path, filename: str, content: str) -> Path:
61
+ p = tmp / filename
62
+ p.write_text(content)
63
+ return p
64
+
65
+
66
+ def test_validate_file_spec(tmp_path):
67
+ spec = _write_spec(tmp_path, "file_spec.py", SPEC_PY)
68
+ result = runner.invoke(app, [str(spec), str(tmp_path)])
69
+ assert result.exit_code == 0
70
+ assert "Valid." in result.stdout
71
+
72
+
73
+ def test_validate_twice_same_process_reloads_spec(tmp_path):
74
+ """Second invoke must rebuild instances after reset (sys.modules cache)."""
75
+ spec = _write_spec(tmp_path, "reload_spec.py", SPEC_PY)
76
+ for _ in range(2):
77
+ result = runner.invoke(app, [str(spec), str(tmp_path)])
78
+ assert result.exit_code == 0, result.stdout
79
+ assert "Valid." in result.stdout
80
+
81
+
82
+ def test_validate_no_instances(tmp_path):
83
+ spec = _write_spec(tmp_path, "empty_spec.py", SPEC_NO_INSTANCES)
84
+ result = runner.invoke(app, [str(spec), str(tmp_path)])
85
+ assert result.exit_code == 1
86
+ assert "No Requirement instances" in result.stdout
87
+
88
+
89
+ def test_validate_missing_product(tmp_path):
90
+ spec = _write_spec(tmp_path, "prod_spec.py", SPEC_PY)
91
+ missing = tmp_path / "nope"
92
+ result = runner.invoke(app, [str(spec), str(missing)])
93
+ assert result.exit_code == 1
94
+ assert "Product path does not exist" in result.stdout
95
+
96
+
97
+ def test_validate_missing_spec_module(tmp_path):
98
+ result = runner.invoke(
99
+ app, ["not_a_real_module_xyz_abc", str(tmp_path)]
100
+ )
101
+ assert result.exit_code == 1
102
+ assert "Could not import spec" in result.stdout
103
+
104
+
105
+ def test_validate_survive_includes_traceback(tmp_path):
106
+ spec = _write_spec(tmp_path, "boom_spec.py", SPEC_ERROR)
107
+ result = runner.invoke(app, [str(spec), str(tmp_path)])
108
+ assert result.exit_code == 1
109
+ assert "Traceback (most recent call last)" in result.stdout
110
+ assert "exception at" in result.stdout
111
+
112
+
113
+ def test_validate_strict_propagates(tmp_path):
114
+ spec = _write_spec(tmp_path, "strict_spec.py", SPEC_ERROR)
115
+ result = runner.invoke(app, [str(spec), str(tmp_path), "--strict"])
116
+ assert result.exit_code == 1
117
+ assert result.exception is not None
118
+
119
+
120
+ def test_validate_two_roots(tmp_path):
121
+ spec = _write_spec(tmp_path, "two_roots.py", SPEC_TWO_ROOTS)
122
+ result = runner.invoke(app, [str(spec), str(tmp_path)])
123
+ assert result.exit_code == 0
124
+ assert "2 root(s) passed." in result.stdout
125
+
126
+
127
+ def test_validate_save_writes_json(tmp_path, monkeypatch):
128
+ monkeypatch.chdir(tmp_path)
129
+ spec = _write_spec(tmp_path, "save_me.py", SPEC_PY)
130
+ result = runner.invoke(app, [str(spec), str(tmp_path), "--save"])
131
+ assert result.exit_code == 0
132
+ assert "Saved save_me.json" in result.stdout
133
+
134
+ out = tmp_path / "save_me.json"
135
+ assert out.is_file()
136
+ data = json.loads(out.read_text())
137
+ assert isinstance(data, list)
138
+ assert len(data) == 1
139
+ assert "class" in data[0]
140
+ assert data[0]["class"].endswith("Root")
141
+
142
+
143
+ def test_validate_save_two_roots_json(tmp_path, monkeypatch):
144
+ monkeypatch.chdir(tmp_path)
145
+ spec = _write_spec(tmp_path, "pair_spec.py", SPEC_TWO_ROOTS)
146
+ result = runner.invoke(app, [str(spec), str(tmp_path), "--save"])
147
+ assert result.exit_code == 0
148
+ data = json.loads((tmp_path / "pair_spec.json").read_text())
149
+ assert len(data) == 2
150
+ names = {entry["class"].split(".")[-1] for entry in data}
151
+ assert names == {"A", "B"}
@@ -0,0 +1,230 @@
1
+ """Unit tests for Requirement validation order, trees, and serialization."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from pydantic import BaseModel
7
+
8
+ from specreq import Requirement
9
+
10
+
11
+ def test_validate_runs_children_before_parent():
12
+ Requirement.reset()
13
+ order: list[str] = []
14
+
15
+ class Child(Requirement):
16
+ def validate(self, product: Path) -> list[str]:
17
+ order.append("child")
18
+ return []
19
+
20
+ class Parent(Requirement):
21
+ def validate(self, product: Path) -> list[str]:
22
+ order.append("parent")
23
+ return []
24
+
25
+ p = Parent()
26
+ Child(parent=p)
27
+
28
+ product = Path("/tmp")
29
+ p._validate(product)
30
+ assert order == ["child", "parent"]
31
+
32
+
33
+ def test_custom_validate_issues():
34
+ Requirement.reset()
35
+
36
+ class R(Requirement):
37
+ def validate(self, product: Path) -> list[str]:
38
+ return ["bad"]
39
+
40
+ r = R()
41
+ assert r._validate(Path("/tmp")) == ["bad"]
42
+
43
+
44
+ def test_override_validate_skips_default_child_walk():
45
+ Requirement.reset()
46
+
47
+ class Leaf(Requirement):
48
+ def validate(self, product: Path) -> list[str]:
49
+ return ["leaf"]
50
+
51
+ class Parent(Requirement):
52
+ def _validate(self, product: Path, *, survive_exceptions: bool = True) -> list[str]:
53
+ return ["custom"]
54
+
55
+ parent = Parent()
56
+ Leaf(parent=parent)
57
+ assert parent._validate(Path("/tmp"), survive_exceptions=True) == ["custom"]
58
+
59
+
60
+ def test_survive_exceptions_records_traceback():
61
+ Requirement.reset()
62
+
63
+ class Boom(Requirement):
64
+ def validate(self, product: Path) -> list[str]:
65
+ raise ValueError("nope")
66
+
67
+ b = Boom()
68
+ issues = b._validate(Path("/tmp"), survive_exceptions=True)
69
+ assert len(issues) == 1
70
+ assert "exception at" in issues[0]
71
+ assert "ValueError: nope" in issues[0]
72
+ assert "Traceback (most recent call last)" in issues[0]
73
+
74
+
75
+ def test_survive_exceptions_false_propagates():
76
+ Requirement.reset()
77
+
78
+ class Boom(Requirement):
79
+ def validate(self, product: Path) -> list[str]:
80
+ raise RuntimeError("boom")
81
+
82
+ b = Boom()
83
+ with pytest.raises(RuntimeError, match="boom"):
84
+ b._validate(Path("/tmp"), survive_exceptions=False)
85
+
86
+
87
+ def test_to_dict_includes_config_and_nested_children():
88
+ Requirement.reset()
89
+
90
+ class Cfg(BaseModel):
91
+ name: str
92
+ port: int = 9
93
+
94
+ class Leaf(Requirement):
95
+ pass
96
+
97
+ class Root(Requirement[Cfg]):
98
+ pass
99
+
100
+ root = Root(config=Cfg(name="svc", port=80))
101
+ Leaf(parent=root)
102
+
103
+ d = root.to_dict()
104
+ assert d["config"] == {"name": "svc", "port": 80}
105
+ assert d["class"].endswith("Root")
106
+ assert len(d["children"]) == 1
107
+ assert d["children"][0]["class"].endswith("Leaf")
108
+ assert "config" not in d["children"][0]
109
+
110
+ roots = Requirement.export_roots()
111
+ assert len(roots) == 1
112
+ assert roots[0] == d
113
+
114
+
115
+ def test_validate_reads_config():
116
+ Requirement.reset()
117
+
118
+ class Cfg(BaseModel):
119
+ env: str
120
+
121
+ class R(Requirement[Cfg]):
122
+ def validate(self, product: Path) -> list[str]:
123
+ if self.config is None or self.config.env != "prod":
124
+ return ["wrong env"]
125
+ return []
126
+
127
+ r = R(config=Cfg(env="prod"))
128
+ assert r._validate(Path("/tmp")) == []
129
+
130
+
131
+ def test_three_level_validate_order():
132
+ Requirement.reset()
133
+ order: list[str] = []
134
+
135
+ class L3(Requirement):
136
+ def validate(self, product: Path) -> list[str]:
137
+ order.append("l3")
138
+ return []
139
+
140
+ class L2(Requirement):
141
+ def validate(self, product: Path) -> list[str]:
142
+ order.append("l2")
143
+ return []
144
+
145
+ class L1(Requirement):
146
+ def validate(self, product: Path) -> list[str]:
147
+ order.append("l1")
148
+ return []
149
+
150
+ l1 = L1()
151
+ l2 = L2(parent=l1)
152
+ L3(parent=l2)
153
+
154
+ l1._validate(Path("/tmp"))
155
+ assert order == ["l3", "l2", "l1"]
156
+
157
+
158
+ def test_mixed_child_issues_parent_still_runs_all_children():
159
+ Requirement.reset()
160
+ order: list[str] = []
161
+
162
+ class Bad(Requirement):
163
+ def validate(self, product: Path) -> list[str]:
164
+ order.append("bad")
165
+ return ["from-bad"]
166
+
167
+ class Ok(Requirement):
168
+ def validate(self, product: Path) -> list[str]:
169
+ order.append("ok")
170
+ return []
171
+
172
+ class Parent(Requirement):
173
+ def validate(self, product: Path) -> list[str]:
174
+ order.append("parent")
175
+ return []
176
+
177
+ p = Parent()
178
+ Bad(parent=p)
179
+ Ok(parent=p)
180
+
181
+ issues = p._validate(Path("/tmp"), survive_exceptions=True)
182
+ assert order == ["bad", "ok", "parent"]
183
+ assert issues == ["from-bad"]
184
+
185
+
186
+ def test_mixed_child_raise_sibling_and_parent_still_run():
187
+ Requirement.reset()
188
+ order: list[str] = []
189
+
190
+ class Boom(Requirement):
191
+ def validate(self, product: Path) -> list[str]:
192
+ order.append("boom")
193
+ raise RuntimeError("child boom")
194
+
195
+ class Ok(Requirement):
196
+ def validate(self, product: Path) -> list[str]:
197
+ order.append("ok")
198
+ return []
199
+
200
+ class Parent(Requirement):
201
+ def validate(self, product: Path) -> list[str]:
202
+ order.append("parent")
203
+ return []
204
+
205
+ p = Parent()
206
+ Boom(parent=p)
207
+ Ok(parent=p)
208
+
209
+ issues = p._validate(Path("/tmp"), survive_exceptions=True)
210
+ assert order == ["boom", "ok", "parent"]
211
+ assert len(issues) == 1
212
+ assert "RuntimeError: child boom" in issues[0]
213
+ assert "Traceback" in issues[0]
214
+
215
+
216
+ def test_multi_root_export_roots():
217
+ Requirement.reset()
218
+
219
+ class A(Requirement):
220
+ pass
221
+
222
+ class B(Requirement):
223
+ pass
224
+
225
+ A()
226
+ B()
227
+ roots = Requirement.export_roots()
228
+ assert len(roots) == 2
229
+ classes = {r["class"].split(".")[-1] for r in roots}
230
+ assert classes == {"A", "B"}