prodc 0.3.1__tar.gz → 0.3.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.3.1
3
+ Version: 0.3.3
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.1"
3
+ version = "0.3.3"
4
4
  description = "Product management as code: personas, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -201,13 +201,21 @@ class PytestAdapter:
201
201
  return out
202
202
 
203
203
  def resolve(self, payload: str) -> ProofResult:
204
- file_part, _, node = payload.partition("::")
205
- func = node.split("[")[0] # strip parametrisation
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
- if func and f"def {func}" not in fp.read_text(encoding="utf-8"):
210
- return ProofResult(ProofState.BROKEN, False, f"no test '{func}' in {file_part}")
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")
@@ -168,17 +168,25 @@ def _to_json(results: list[NeedResult], summary: Summary, orphan_map: dict[str,
168
168
  return json.dumps(payload, indent=2)
169
169
 
170
170
 
171
- def _cmd_status(args: argparse.Namespace) -> int:
171
+ def _load_or_report(args: argparse.Namespace) -> tuple[Config, list[Need]] | None:
172
+ """Load config + needs, or print the error to stderr and return None."""
172
173
  cfg_path = Path(args.config)
173
174
  if not cfg_path.exists():
174
175
  print(f"prodc: no config at {cfg_path}", file=sys.stderr)
175
- return 2
176
+ return None
176
177
  try:
177
178
  cfg = Config.load(cfg_path)
178
- needs = load_needs(cfg)
179
+ return cfg, load_needs(cfg)
179
180
  except (ConfigError, LoaderError) as exc:
180
181
  print(f"prodc: {exc}", file=sys.stderr)
182
+ return None
183
+
184
+
185
+ def _cmd_status(args: argparse.Namespace) -> int:
186
+ loaded = _load_or_report(args)
187
+ if loaded is None:
181
188
  return 2
189
+ cfg, needs = loaded
182
190
  results = resolve(needs, cfg.adapters)
183
191
  summary = summarize(results)
184
192
  orphan_map = orphans(needs, cfg.adapters)
@@ -217,6 +225,82 @@ def _exit_code(args: argparse.Namespace, summary: Summary, orphan_total: int) ->
217
225
  return 0
218
226
 
219
227
 
228
+ def _render_show(r: NeedResult) -> str:
229
+ """One need as a citable answer — the artifact you drop into a thread."""
230
+ need = r.need
231
+ who = (need.who + (f" ({need.label})" if need.label else "")) if need.who else "(unassigned)"
232
+ nm = f" {r.delivered}/{r.total}" if r.total else ""
233
+ lines = [
234
+ f"{need.id}{' !!' if need.must else ''} {need.text}",
235
+ f" who: {who}",
236
+ f" grounds: {_LABEL[r.state]}{nm} warrant: {_warrant(need)}",
237
+ ]
238
+ if need.sources:
239
+ lines.append(" sources:")
240
+ for s in need.sources:
241
+ bits = [s.kind.value]
242
+ bits += [f"{k}={v}" for k, v in (("who", s.who), ("date", s.date),
243
+ ("locator", s.locator)) if v]
244
+ line = " - " + " ".join(bits)
245
+ lines.append(line + (f' "{s.quote}"' if s.quote else ""))
246
+ else:
247
+ lines.append(" sources: (none — opinion)")
248
+ if need.children:
249
+ lines.append(f" children: {', '.join(need.children)}")
250
+ if need.proofs:
251
+ lines.append(" proofs:")
252
+ lines += [f" - {d}" for d in r.detail]
253
+ elif not need.children:
254
+ lines.append(" proofs: (none — HOLE)")
255
+ lines += [f" ⚠ {w}" for w in r.warnings]
256
+ return "\n".join(lines)
257
+
258
+
259
+ def _show_json(r: NeedResult) -> str:
260
+ need = r.need
261
+ return json.dumps(
262
+ {
263
+ "id": need.id,
264
+ "text": need.text,
265
+ "who": need.who,
266
+ "label": need.label,
267
+ "must": need.must,
268
+ "state": r.state.value,
269
+ "grounds": r.state.value,
270
+ "warrant": _warrant(need),
271
+ "delivered": r.delivered,
272
+ "total": r.total,
273
+ "declared": r.declared,
274
+ "opinion": need.is_opinion,
275
+ "only_internal": need.only_internal,
276
+ "sources": [
277
+ {"kind": s.kind.value, "who": s.who, "date": s.date,
278
+ "quote": s.quote, "locator": s.locator}
279
+ for s in need.sources
280
+ ],
281
+ "proofs": list(need.proofs),
282
+ "children": list(need.children),
283
+ "detail": r.detail,
284
+ "warnings": r.warnings,
285
+ },
286
+ indent=2,
287
+ )
288
+
289
+
290
+ def _cmd_show(args: argparse.Namespace) -> int:
291
+ loaded = _load_or_report(args)
292
+ if loaded is None:
293
+ return 2
294
+ cfg, needs = loaded
295
+ by_id = {r.need.id: r for r in resolve(needs, cfg.adapters)}
296
+ r = by_id.get(args.need_id)
297
+ if r is None:
298
+ print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
299
+ return 2
300
+ print(_show_json(r) if args.json else _render_show(r))
301
+ return 0
302
+
303
+
220
304
  def _git_rev(root: Path) -> str | None:
221
305
  head = root / ".git" / "HEAD"
222
306
  try:
@@ -263,6 +347,12 @@ def main(argv: list[str] | None = None) -> int:
263
347
  )
264
348
  st.set_defaults(func=_cmd_status)
265
349
 
350
+ sh = sub.add_parser("show", help="show one need as a citable answer")
351
+ sh.add_argument("need_id", help="the need id to show, e.g. N-1")
352
+ sh.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
353
+ sh.add_argument("--json", action="store_true", help="machine-readable output")
354
+ sh.set_defaults(func=_cmd_show)
355
+
266
356
  args = parser.parse_args(argv)
267
357
  return int(args.func(args))
268
358
 
@@ -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(f"[proofs.{name}]: unknown kind {kind!r} (gherkin|pytest)")
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
@@ -126,3 +126,27 @@ def test_orphans_in_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) ->
126
126
  payload = json.loads(capsys.readouterr().out)
127
127
  assert payload["summary"]["orphans"] == 1
128
128
  assert payload["orphans"]["web"] == ["orders.feature#Unclaimed extra"]
129
+
130
+
131
+ def test_show_single_need(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
132
+ code = main(["show", "D-1", "--config", str(_project(tmp_path))])
133
+ out = capsys.readouterr().out
134
+ assert code == 0
135
+ assert "D-1" in out and "a delivered need" in out
136
+ assert "grounds: DELIVERED" in out and "interview:John" in out
137
+ assert "I need this" in out # the source quote
138
+
139
+
140
+ def test_show_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
141
+ import json
142
+
143
+ main(["show", "D-1", "--config", str(_project(tmp_path)), "--json"])
144
+ p = json.loads(capsys.readouterr().out)
145
+ assert p["id"] == "D-1" and p["warrant"] == "interview:John"
146
+ assert p["sources"][0]["kind"] == "interview" and p["sources"][0]["who"] == "John"
147
+
148
+
149
+ def test_show_unknown_need_exits_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
150
+ code = main(["show", "NOPE", "--config", str(_project(tmp_path))])
151
+ capsys.readouterr()
152
+ assert code == 2
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