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 +21 -0
- specreq-0.1.0/PKG-INFO +15 -0
- specreq-0.1.0/README.md +6 -0
- specreq-0.1.0/pyproject.toml +31 -0
- specreq-0.1.0/setup.cfg +4 -0
- specreq-0.1.0/src/specreq/__init__.py +93 -0
- specreq-0.1.0/src/specreq/cli.py +117 -0
- specreq-0.1.0/src/specreq.egg-info/PKG-INFO +15 -0
- specreq-0.1.0/src/specreq.egg-info/SOURCES.txt +13 -0
- specreq-0.1.0/src/specreq.egg-info/dependency_links.txt +1 -0
- specreq-0.1.0/src/specreq.egg-info/entry_points.txt +2 -0
- specreq-0.1.0/src/specreq.egg-info/requires.txt +5 -0
- specreq-0.1.0/src/specreq.egg-info/top_level.txt +1 -0
- specreq-0.1.0/tests/test_cli.py +151 -0
- specreq-0.1.0/tests/test_requirement.py +230 -0
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
|
specreq-0.1.0/README.md
ADDED
|
@@ -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"]
|
specreq-0.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"}
|