prodc 0.3.0__tar.gz → 0.3.2__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.
- {prodc-0.3.0 → prodc-0.3.2}/PKG-INFO +1 -1
- {prodc-0.3.0 → prodc-0.3.2}/pyproject.toml +1 -1
- {prodc-0.3.0 → prodc-0.3.2}/src/prodc/adapters.py +30 -4
- {prodc-0.3.0 → prodc-0.3.2}/src/prodc/cli.py +24 -2
- {prodc-0.3.0 → prodc-0.3.2}/src/prodc/config.py +7 -2
- prodc-0.3.2/tests/test_adapters.py +54 -0
- {prodc-0.3.0 → prodc-0.3.2}/tests/test_cli.py +3 -0
- {prodc-0.3.0 → prodc-0.3.2}/.gitignore +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/CHANGELOG.md +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/LICENSE +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/README.md +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/src/prodc/__init__.py +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/src/prodc/loader.py +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/src/prodc/model.py +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/src/prodc/py.typed +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/src/prodc/status.py +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/tests/test_loader.py +0 -0
- {prodc-0.3.0 → prodc-0.3.2}/tests/test_status.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Product management as code: personas, flows, and features traced to evidence.
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
|
|
6
6
|
Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
|
|
@@ -201,13 +201,21 @@ class PytestAdapter:
|
|
|
201
201
|
return out
|
|
202
202
|
|
|
203
203
|
def resolve(self, payload: str) -> ProofResult:
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
# Node id: file[::Class[::Nested]]::test_name, each segment may carry [params].
|
|
205
|
+
parts = [p.split("[")[0] for p in payload.split("::")]
|
|
206
|
+
file_part = parts[0]
|
|
207
|
+
node = parts[1:]
|
|
208
|
+
func = node[-1] if node else ""
|
|
209
|
+
classes = node[:-1] # class path for class-based tests (file::TestX::test_y)
|
|
206
210
|
fp = self._root / file_part
|
|
207
211
|
if not fp.exists():
|
|
208
212
|
return ProofResult(ProofState.BROKEN, False, f"no file '{file_part}'")
|
|
209
|
-
|
|
210
|
-
|
|
213
|
+
text = fp.read_text(encoding="utf-8")
|
|
214
|
+
missing = [f"class {c}" for c in classes if f"class {c}" not in text]
|
|
215
|
+
if func and f"def {func}" not in text:
|
|
216
|
+
missing.append(f"def {func}")
|
|
217
|
+
if missing:
|
|
218
|
+
return ProofResult(ProofState.BROKEN, False, f"no {', '.join(missing)} in {file_part}")
|
|
211
219
|
results = self._load_results()
|
|
212
220
|
if func in results:
|
|
213
221
|
ok = results[func]
|
|
@@ -217,3 +225,21 @@ class PytestAdapter:
|
|
|
217
225
|
"junit: passed" if ok else "junit: failing",
|
|
218
226
|
)
|
|
219
227
|
return ProofResult(ProofState.DELIVERED, True, "test exists")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class FilesAdapter:
|
|
231
|
+
"""Resolves ``path`` to a verification script's existence.
|
|
232
|
+
|
|
233
|
+
For proofs that are executable models/scripts, not pytest tests. Existence is a
|
|
234
|
+
*declared* DELIVERED — a script existing is not a script passing; a run/result
|
|
235
|
+
marker (confirmed) is left for later, mirroring the gherkin/pytest results split.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(self, root: Path) -> None:
|
|
239
|
+
self._root = root
|
|
240
|
+
|
|
241
|
+
def resolve(self, payload: str) -> ProofResult:
|
|
242
|
+
fp = self._root / payload
|
|
243
|
+
if not fp.exists():
|
|
244
|
+
return ProofResult(ProofState.BROKEN, False, f"no file '{payload}'")
|
|
245
|
+
return ProofResult(ProofState.DELIVERED, True, "script exists")
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
11
|
import json
|
|
12
|
+
import shutil
|
|
12
13
|
import sys
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
@@ -53,6 +54,18 @@ def _warrant(need: Need) -> str:
|
|
|
53
54
|
return strongest.value
|
|
54
55
|
|
|
55
56
|
|
|
57
|
+
def _provenance_line(results: list[NeedResult]) -> str:
|
|
58
|
+
"""Per-persona provenance mix — makes internal-only ('0 OPINION' but unvalidated) visible.
|
|
59
|
+
|
|
60
|
+
OPINION (no source) stays distinct from a weak INTERNAL source; the headline a PM
|
|
61
|
+
wants is how many needs trace to a real user (interview), not just 'has any source'.
|
|
62
|
+
"""
|
|
63
|
+
counts = {"interview": 0, "artifact": 0, "internal": 0, "opinion": 0}
|
|
64
|
+
for r in results:
|
|
65
|
+
counts[_warrant(r.need).split(":", 1)[0]] += 1
|
|
66
|
+
return "provenance: " + " · ".join(f"{counts[k]} {k}" for k in counts)
|
|
67
|
+
|
|
68
|
+
|
|
56
69
|
def _summary_line(s: Summary) -> str:
|
|
57
70
|
"""The leading gap-count line: what needs attention, and overall coverage."""
|
|
58
71
|
parts: list[str] = []
|
|
@@ -75,10 +88,18 @@ def _render(results: list[NeedResult]) -> str:
|
|
|
75
88
|
for r in results:
|
|
76
89
|
groups.setdefault(r.need.who or "(unassigned)", []).append(r)
|
|
77
90
|
|
|
91
|
+
# Size the need column to the terminal; ~54 cols go to the fixed id/state/nm
|
|
92
|
+
# prefix and the warrant suffix. Falls back to 100 cols when there is no tty.
|
|
93
|
+
width = max(30, shutil.get_terminal_size(fallback=(100, 24)).columns - 54)
|
|
94
|
+
|
|
78
95
|
out: list[str] = []
|
|
79
96
|
# The Toulmin split: GROUNDS (does a test prove it?) vs WARRANT (user signal it
|
|
80
97
|
# matters?). A need can be green on one and empty on the other — that gap is the point.
|
|
81
|
-
out.append(f" {'':2} {'id':<9} {'GROUNDS':<10}{'':5} {'need':<
|
|
98
|
+
out.append(f" {'':2} {'id':<9} {'GROUNDS':<10}{'':5} {'need':<{width}} WARRANT")
|
|
99
|
+
out.append(
|
|
100
|
+
" legend: * declared (no results artifact) · !! must · "
|
|
101
|
+
"GROUNDS = test proof · WARRANT = evidence source"
|
|
102
|
+
)
|
|
82
103
|
for who in sorted(groups):
|
|
83
104
|
rs = sorted(groups[who], key=_priority)
|
|
84
105
|
label = next((x.need.label for x in rs if x.need.label), None)
|
|
@@ -86,13 +107,14 @@ def _render(results: list[NeedResult]) -> str:
|
|
|
86
107
|
head = " · ".join(f"{counts[s]} {_LABEL[s]}" for s in NeedState if counts[s])
|
|
87
108
|
title = f"{who}" + (f" ({label})" if label else "")
|
|
88
109
|
out.append(f"\n{title} {head}")
|
|
110
|
+
out.append(f" {_provenance_line(rs)}")
|
|
89
111
|
for r in rs:
|
|
90
112
|
bang = "!!" if r.need.must else " "
|
|
91
113
|
star = "*" if r.declared and r.state is NeedState.DELIVERED else " "
|
|
92
114
|
nm = f"{r.delivered}/{r.total}" if r.total else ""
|
|
93
115
|
out.append(
|
|
94
116
|
f" {bang} {r.need.id:<9} {_LABEL[r.state]:<9}{star} {nm:<5} "
|
|
95
|
-
f"{r.need.text[:
|
|
117
|
+
f"{r.need.text[:width]:<{width}} {_warrant(r.need)}"
|
|
96
118
|
)
|
|
97
119
|
for w in r.warnings:
|
|
98
120
|
out.append(f" ⚠ {w}")
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import cast
|
|
9
9
|
|
|
10
|
-
from .adapters import Adapter, GherkinAdapter, PytestAdapter
|
|
10
|
+
from .adapters import Adapter, FilesAdapter, GherkinAdapter, PytestAdapter
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class ConfigError(Exception):
|
|
@@ -70,6 +70,11 @@ class Config:
|
|
|
70
70
|
root=root / (sub if isinstance(sub, str) else "."),
|
|
71
71
|
results=results_s,
|
|
72
72
|
)
|
|
73
|
+
elif kind == "files":
|
|
74
|
+
sub = tbl.get("root", ".")
|
|
75
|
+
adapters[name] = FilesAdapter(root=root / (sub if isinstance(sub, str) else "."))
|
|
73
76
|
else:
|
|
74
|
-
raise ConfigError(
|
|
77
|
+
raise ConfigError(
|
|
78
|
+
f"[proofs.{name}]: unknown kind {kind!r} (gherkin|pytest|files)"
|
|
79
|
+
)
|
|
75
80
|
return cls(root=root, need_globs=need_globs, adapters=adapters)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Adapters: pytest class-based node ids (#6) and the files adapter (#7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from prodc.adapters import FilesAdapter, PytestAdapter
|
|
8
|
+
from prodc.model import ProofState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _pytest_file(tmp_path: Path) -> PytestAdapter:
|
|
12
|
+
(tmp_path / "test_x.py").write_text(
|
|
13
|
+
"class TestX:\n"
|
|
14
|
+
" def test_y(self):\n"
|
|
15
|
+
" assert True\n"
|
|
16
|
+
"\n"
|
|
17
|
+
"def test_z():\n"
|
|
18
|
+
" assert True\n",
|
|
19
|
+
encoding="utf-8",
|
|
20
|
+
)
|
|
21
|
+
return PytestAdapter(root=tmp_path, results=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_pytest_resolves_class_based_node_id(tmp_path: Path) -> None:
|
|
25
|
+
adapter = _pytest_file(tmp_path)
|
|
26
|
+
r = adapter.resolve("test_x.py::TestX::test_y")
|
|
27
|
+
assert r.state is ProofState.DELIVERED and r.declared # existence only
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_pytest_resolves_plain_function(tmp_path: Path) -> None:
|
|
31
|
+
assert _pytest_file(tmp_path).resolve("test_x.py::test_z").state is ProofState.DELIVERED
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_pytest_missing_method_in_class_is_broken(tmp_path: Path) -> None:
|
|
35
|
+
assert _pytest_file(tmp_path).resolve("test_x.py::TestX::test_nope").state is ProofState.BROKEN
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_pytest_missing_class_is_broken(tmp_path: Path) -> None:
|
|
39
|
+
assert _pytest_file(tmp_path).resolve("test_x.py::TestNope::test_y").state is ProofState.BROKEN
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_pytest_strips_parametrisation(tmp_path: Path) -> None:
|
|
43
|
+
r = _pytest_file(tmp_path).resolve("test_x.py::TestX::test_y[case1]")
|
|
44
|
+
assert r.state is ProofState.DELIVERED
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_files_adapter_existence_is_declared(tmp_path: Path) -> None:
|
|
48
|
+
(tmp_path / "verify.py").write_text("# a model\n", encoding="utf-8")
|
|
49
|
+
r = FilesAdapter(root=tmp_path).resolve("verify.py")
|
|
50
|
+
assert r.state is ProofState.DELIVERED and r.declared # existence != passing
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_files_adapter_missing_is_broken(tmp_path: Path) -> None:
|
|
54
|
+
assert FilesAdapter(root=tmp_path).resolve("gone.py").state is ProofState.BROKEN
|
|
@@ -70,6 +70,9 @@ def test_board_shows_summary_and_warrant(tmp_path: Path, capsys: pytest.CaptureF
|
|
|
70
70
|
assert "WARRANT" in out # the warrant column header (#3)
|
|
71
71
|
assert "interview:John" in out # strongest warrant for D-1
|
|
72
72
|
assert "opinion" in out # O-1's warrant
|
|
73
|
+
assert "legend:" in out # glyph legend (#10)
|
|
74
|
+
# provenance mix (#11): 1 interview, 1 artifact, 0 internal, 1 opinion
|
|
75
|
+
assert "provenance:" in out and "1 interview" in out and "1 artifact" in out
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
def test_json_has_summary_and_grounds_warrant(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|