prodc 0.3.3__tar.gz → 0.3.4__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.3
3
+ Version: 0.3.4
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.3"
3
+ version = "0.3.4"
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"
@@ -11,6 +11,7 @@ import argparse
11
11
  import json
12
12
  import shutil
13
13
  import sys
14
+ from dataclasses import asdict, dataclass
14
15
  from pathlib import Path
15
16
 
16
17
  from . import __version__
@@ -54,15 +55,21 @@ def _warrant(need: Need) -> str:
54
55
  return strongest.value
55
56
 
56
57
 
58
+ def _provenance_counts(results: list[NeedResult]) -> dict[str, int]:
59
+ """How many needs carry each warrant strength (interview/artifact/internal/opinion)."""
60
+ counts = {"interview": 0, "artifact": 0, "internal": 0, "opinion": 0}
61
+ for r in results:
62
+ counts[_warrant(r.need).split(":", 1)[0]] += 1
63
+ return counts
64
+
65
+
57
66
  def _provenance_line(results: list[NeedResult]) -> str:
58
67
  """Per-persona provenance mix — makes internal-only ('0 OPINION' but unvalidated) visible.
59
68
 
60
69
  OPINION (no source) stays distinct from a weak INTERNAL source; the headline a PM
61
70
  wants is how many needs trace to a real user (interview), not just 'has any source'.
62
71
  """
63
- counts = {"interview": 0, "artifact": 0, "internal": 0, "opinion": 0}
64
- for r in results:
65
- counts[_warrant(r.need).split(":", 1)[0]] += 1
72
+ counts = _provenance_counts(results)
66
73
  return "provenance: " + " · ".join(f"{counts[k]} {k}" for k in counts)
67
74
 
68
75
 
@@ -301,6 +308,66 @@ def _cmd_show(args: argparse.Namespace) -> int:
301
308
  return 0
302
309
 
303
310
 
311
+ @dataclass(frozen=True)
312
+ class _RepoRow:
313
+ """One repo's line in the portfolio roll-up."""
314
+
315
+ repo: str
316
+ delivered: int
317
+ total: int
318
+ must_hole: int
319
+ broken: int
320
+ coverage: float
321
+ provenance: dict[str, int]
322
+ error: str | None = None
323
+
324
+
325
+ def _repo_row(config_path: str) -> _RepoRow:
326
+ p = Path(config_path)
327
+ name = p.parent.name or str(p)
328
+ try:
329
+ cfg = Config.load(p)
330
+ needs = load_needs(cfg)
331
+ except (ConfigError, LoaderError, OSError) as exc:
332
+ return _RepoRow(name, 0, 0, 0, 0, 0.0, {}, error=str(exc))
333
+ results = resolve(needs, cfg.adapters)
334
+ s = summarize(results)
335
+ return _RepoRow(
336
+ repo=cfg.root.name,
337
+ delivered=s.delivered,
338
+ total=s.total,
339
+ must_hole=s.must_hole,
340
+ broken=s.broken,
341
+ coverage=round(s.coverage, 4),
342
+ provenance=_provenance_counts(results),
343
+ )
344
+
345
+
346
+ def _render_portfolio(rows: list[_RepoRow]) -> str:
347
+ out = [f" {'repo':<18} {'cov':>4} {'must-HOLE':>9} {'BROKEN':>6} prov intv/art/int/op"]
348
+ for row in rows:
349
+ if row.error is not None:
350
+ out.append(f" {row.repo:<18} error: {row.error}")
351
+ continue
352
+ p = row.provenance
353
+ prov = f"{p['interview']}/{p['artifact']}/{p['internal']}/{p['opinion']}"
354
+ cov = f"{round(row.coverage * 100)}%"
355
+ out.append(
356
+ f" {row.repo:<18} {cov:>4} {row.must_hole:>9} {row.broken:>6} {prov}"
357
+ )
358
+ return "\n".join(out)
359
+
360
+
361
+ def _cmd_portfolio(args: argparse.Namespace) -> int:
362
+ """Roll up many repos into one scoreboard — coverage, must-HOLEs, provenance per repo."""
363
+ rows = [_repo_row(path) for path in args.configs]
364
+ if args.json:
365
+ print(json.dumps([asdict(r) for r in rows], indent=2))
366
+ else:
367
+ print(_render_portfolio(rows))
368
+ return 2 if any(r.error is not None for r in rows) else 0
369
+
370
+
304
371
  def _git_rev(root: Path) -> str | None:
305
372
  head = root / ".git" / "HEAD"
306
373
  try:
@@ -353,6 +420,11 @@ def main(argv: list[str] | None = None) -> int:
353
420
  sh.add_argument("--json", action="store_true", help="machine-readable output")
354
421
  sh.set_defaults(func=_cmd_show)
355
422
 
423
+ pf = sub.add_parser("portfolio", help="roll up many repos into one scoreboard")
424
+ pf.add_argument("configs", nargs="+", metavar="PRODC_TOML", help="one or more prodc.toml paths")
425
+ pf.add_argument("--json", action="store_true", help="machine-readable output")
426
+ pf.set_defaults(func=_cmd_portfolio)
427
+
356
428
  args = parser.parse_args(argv)
357
429
  return int(args.func(args))
358
430
 
@@ -54,8 +54,8 @@ Feature: orders
54
54
 
55
55
 
56
56
  def _project(tmp_path: Path) -> Path:
57
- (tmp_path / "needs").mkdir()
58
- (tmp_path / "features").mkdir()
57
+ (tmp_path / "needs").mkdir(parents=True, exist_ok=True)
58
+ (tmp_path / "features").mkdir(parents=True, exist_ok=True)
59
59
  (tmp_path / "prodc.toml").write_text(_TOML, encoding="utf-8")
60
60
  (tmp_path / "needs" / "a.yaml").write_text(_NEEDS, encoding="utf-8")
61
61
  (tmp_path / "features" / "orders.feature").write_text(_FEATURE, encoding="utf-8")
@@ -150,3 +150,30 @@ def test_show_unknown_need_exits_2(tmp_path: Path, capsys: pytest.CaptureFixture
150
150
  code = main(["show", "NOPE", "--config", str(_project(tmp_path))])
151
151
  capsys.readouterr()
152
152
  assert code == 2
153
+
154
+
155
+ def test_portfolio_rolls_up_many_repos(
156
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
157
+ ) -> None:
158
+ a = _project(tmp_path / "alpha")
159
+ b = _project(tmp_path / "beta")
160
+ code = main(["portfolio", str(a), str(b)])
161
+ out = capsys.readouterr().out
162
+ assert code == 0
163
+ assert "alpha" in out and "beta" in out
164
+ assert "33%" in out # each repo is 1/3 delivered
165
+
166
+
167
+ def test_portfolio_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
168
+ import json
169
+
170
+ main(["portfolio", str(_project(tmp_path / "alpha")), "--json"])
171
+ rows = json.loads(capsys.readouterr().out)
172
+ assert rows[0]["repo"] == "alpha" and rows[0]["total"] == 3
173
+ assert rows[0]["provenance"]["interview"] == 1
174
+
175
+
176
+ def test_portfolio_bad_config_exits_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
177
+ code = main(["portfolio", str(tmp_path / "nope.toml")])
178
+ capsys.readouterr()
179
+ 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
File without changes
File without changes
File without changes