prodc 0.3.2__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.2
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.2"
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
 
@@ -168,17 +175,25 @@ def _to_json(results: list[NeedResult], summary: Summary, orphan_map: dict[str,
168
175
  return json.dumps(payload, indent=2)
169
176
 
170
177
 
171
- def _cmd_status(args: argparse.Namespace) -> int:
178
+ def _load_or_report(args: argparse.Namespace) -> tuple[Config, list[Need]] | None:
179
+ """Load config + needs, or print the error to stderr and return None."""
172
180
  cfg_path = Path(args.config)
173
181
  if not cfg_path.exists():
174
182
  print(f"prodc: no config at {cfg_path}", file=sys.stderr)
175
- return 2
183
+ return None
176
184
  try:
177
185
  cfg = Config.load(cfg_path)
178
- needs = load_needs(cfg)
186
+ return cfg, load_needs(cfg)
179
187
  except (ConfigError, LoaderError) as exc:
180
188
  print(f"prodc: {exc}", file=sys.stderr)
189
+ return None
190
+
191
+
192
+ def _cmd_status(args: argparse.Namespace) -> int:
193
+ loaded = _load_or_report(args)
194
+ if loaded is None:
181
195
  return 2
196
+ cfg, needs = loaded
182
197
  results = resolve(needs, cfg.adapters)
183
198
  summary = summarize(results)
184
199
  orphan_map = orphans(needs, cfg.adapters)
@@ -217,6 +232,142 @@ def _exit_code(args: argparse.Namespace, summary: Summary, orphan_total: int) ->
217
232
  return 0
218
233
 
219
234
 
235
+ def _render_show(r: NeedResult) -> str:
236
+ """One need as a citable answer — the artifact you drop into a thread."""
237
+ need = r.need
238
+ who = (need.who + (f" ({need.label})" if need.label else "")) if need.who else "(unassigned)"
239
+ nm = f" {r.delivered}/{r.total}" if r.total else ""
240
+ lines = [
241
+ f"{need.id}{' !!' if need.must else ''} {need.text}",
242
+ f" who: {who}",
243
+ f" grounds: {_LABEL[r.state]}{nm} warrant: {_warrant(need)}",
244
+ ]
245
+ if need.sources:
246
+ lines.append(" sources:")
247
+ for s in need.sources:
248
+ bits = [s.kind.value]
249
+ bits += [f"{k}={v}" for k, v in (("who", s.who), ("date", s.date),
250
+ ("locator", s.locator)) if v]
251
+ line = " - " + " ".join(bits)
252
+ lines.append(line + (f' "{s.quote}"' if s.quote else ""))
253
+ else:
254
+ lines.append(" sources: (none — opinion)")
255
+ if need.children:
256
+ lines.append(f" children: {', '.join(need.children)}")
257
+ if need.proofs:
258
+ lines.append(" proofs:")
259
+ lines += [f" - {d}" for d in r.detail]
260
+ elif not need.children:
261
+ lines.append(" proofs: (none — HOLE)")
262
+ lines += [f" ⚠ {w}" for w in r.warnings]
263
+ return "\n".join(lines)
264
+
265
+
266
+ def _show_json(r: NeedResult) -> str:
267
+ need = r.need
268
+ return json.dumps(
269
+ {
270
+ "id": need.id,
271
+ "text": need.text,
272
+ "who": need.who,
273
+ "label": need.label,
274
+ "must": need.must,
275
+ "state": r.state.value,
276
+ "grounds": r.state.value,
277
+ "warrant": _warrant(need),
278
+ "delivered": r.delivered,
279
+ "total": r.total,
280
+ "declared": r.declared,
281
+ "opinion": need.is_opinion,
282
+ "only_internal": need.only_internal,
283
+ "sources": [
284
+ {"kind": s.kind.value, "who": s.who, "date": s.date,
285
+ "quote": s.quote, "locator": s.locator}
286
+ for s in need.sources
287
+ ],
288
+ "proofs": list(need.proofs),
289
+ "children": list(need.children),
290
+ "detail": r.detail,
291
+ "warnings": r.warnings,
292
+ },
293
+ indent=2,
294
+ )
295
+
296
+
297
+ def _cmd_show(args: argparse.Namespace) -> int:
298
+ loaded = _load_or_report(args)
299
+ if loaded is None:
300
+ return 2
301
+ cfg, needs = loaded
302
+ by_id = {r.need.id: r for r in resolve(needs, cfg.adapters)}
303
+ r = by_id.get(args.need_id)
304
+ if r is None:
305
+ print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
306
+ return 2
307
+ print(_show_json(r) if args.json else _render_show(r))
308
+ return 0
309
+
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
+
220
371
  def _git_rev(root: Path) -> str | None:
221
372
  head = root / ".git" / "HEAD"
222
373
  try:
@@ -263,6 +414,17 @@ def main(argv: list[str] | None = None) -> int:
263
414
  )
264
415
  st.set_defaults(func=_cmd_status)
265
416
 
417
+ sh = sub.add_parser("show", help="show one need as a citable answer")
418
+ sh.add_argument("need_id", help="the need id to show, e.g. N-1")
419
+ sh.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
420
+ sh.add_argument("--json", action="store_true", help="machine-readable output")
421
+ sh.set_defaults(func=_cmd_show)
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
+
266
428
  args = parser.parse_args(argv)
267
429
  return int(args.func(args))
268
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")
@@ -126,3 +126,54 @@ 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
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