prodc 0.3.72__tar.gz → 0.3.73__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.
Files changed (58) hide show
  1. {prodc-0.3.72 → prodc-0.3.73}/PKG-INFO +1 -1
  2. {prodc-0.3.72 → prodc-0.3.73}/pyproject.toml +1 -1
  3. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/agent.md +3 -2
  4. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/app.py +13 -2
  5. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/commands.py +34 -5
  6. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/common.py +77 -0
  7. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/render.py +12 -2
  8. {prodc-0.3.72 → prodc-0.3.73}/tests/test_cli.py +47 -3
  9. {prodc-0.3.72 → prodc-0.3.73}/.gitignore +0 -0
  10. {prodc-0.3.72 → prodc-0.3.73}/CHANGELOG.md +0 -0
  11. {prodc-0.3.72 → prodc-0.3.73}/LICENSE +0 -0
  12. {prodc-0.3.72 → prodc-0.3.73}/README.md +0 -0
  13. {prodc-0.3.72 → prodc-0.3.73}/examples/quickstart/features/orders.feature +0 -0
  14. {prodc-0.3.72 → prodc-0.3.73}/examples/quickstart/needs/jordan.yaml +0 -0
  15. {prodc-0.3.72 → prodc-0.3.73}/examples/quickstart/prodc.toml +0 -0
  16. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/__init__.py +0 -0
  17. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/adapters.py +0 -0
  18. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/board.py +0 -0
  19. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/callbacks.py +0 -0
  20. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/__init__.py +0 -0
  21. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/serialize.py +0 -0
  22. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/config.py +0 -0
  23. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/dedup.py +0 -0
  24. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/loader.py +0 -0
  25. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/model.py +0 -0
  26. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/py.typed +0 -0
  27. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/report.py +0 -0
  28. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/report_html.py +0 -0
  29. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/status.py +0 -0
  30. {prodc-0.3.72 → prodc-0.3.73}/src/prodc/writer.py +0 -0
  31. {prodc-0.3.72 → prodc-0.3.73}/tests/test_adapters.py +0 -0
  32. {prodc-0.3.72 → prodc-0.3.73}/tests/test_answer.py +0 -0
  33. {prodc-0.3.72 → prodc-0.3.73}/tests/test_callbacks.py +0 -0
  34. {prodc-0.3.72 → prodc-0.3.73}/tests/test_citation.py +0 -0
  35. {prodc-0.3.72 → prodc-0.3.73}/tests/test_claim_gating.py +0 -0
  36. {prodc-0.3.72 → prodc-0.3.73}/tests/test_command_hook.py +0 -0
  37. {prodc-0.3.72 → prodc-0.3.73}/tests/test_dedup.py +0 -0
  38. {prodc-0.3.72 → prodc-0.3.73}/tests/test_description.py +0 -0
  39. {prodc-0.3.72 → prodc-0.3.73}/tests/test_drafts.py +0 -0
  40. {prodc-0.3.72 → prodc-0.3.73}/tests/test_evalgate.py +0 -0
  41. {prodc-0.3.72 → prodc-0.3.73}/tests/test_external_adapter.py +0 -0
  42. {prodc-0.3.72 → prodc-0.3.73}/tests/test_gitlab_source.py +0 -0
  43. {prodc-0.3.72 → prodc-0.3.73}/tests/test_importance.py +0 -0
  44. {prodc-0.3.72 → prodc-0.3.73}/tests/test_loader.py +0 -0
  45. {prodc-0.3.72 → prodc-0.3.73}/tests/test_models.py +0 -0
  46. {prodc-0.3.72 → prodc-0.3.73}/tests/test_needref.py +0 -0
  47. {prodc-0.3.72 → prodc-0.3.73}/tests/test_report.py +0 -0
  48. {prodc-0.3.72 → prodc-0.3.73}/tests/test_report_html.py +0 -0
  49. {prodc-0.3.72 → prodc-0.3.73}/tests/test_roles.py +0 -0
  50. {prodc-0.3.72 → prodc-0.3.73}/tests/test_sources.py +0 -0
  51. {prodc-0.3.72 → prodc-0.3.73}/tests/test_staleness.py +0 -0
  52. {prodc-0.3.72 → prodc-0.3.73}/tests/test_status.py +0 -0
  53. {prodc-0.3.72 → prodc-0.3.73}/tests/test_suggest.py +0 -0
  54. {prodc-0.3.72 → prodc-0.3.73}/tests/test_variants.py +0 -0
  55. {prodc-0.3.72 → prodc-0.3.73}/tests/test_who_variants.py +0 -0
  56. {prodc-0.3.72 → prodc-0.3.73}/tests/test_withdrawn.py +0 -0
  57. {prodc-0.3.72 → prodc-0.3.73}/tests/test_writer.py +0 -0
  58. {prodc-0.3.72 → prodc-0.3.73}/tests/test_writer_comments.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.3.72
3
+ Version: 0.3.73
4
4
  Summary: Product management as code: roles, 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.72"
3
+ version = "0.3.73"
4
4
  description = "Product management as code: roles, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -80,8 +80,9 @@ Read first, then act, then check.
80
80
  | act on a need going green (notify a tracker, etc.) | `prodc sync` — runs the `[callbacks]` commands for needs whose state changed since the last sync (`--dry-run` to preview) |
81
81
  | gate in CI | `prodc lint --strict` |
82
82
 
83
- Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <toml>…`
84
- (roll up many repos into one scoreboard), and `prodc diff <snapshot.json>` (what changed since a
83
+ Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <path>…`
84
+ (roll up many boards into one scoreboard + totals; a path is a prodc.toml or a dir to discover
85
+ boards under), and `prodc diff <snapshot.json>` (what changed since a
85
86
  saved `status --json`). `prodc status` also takes `--goal <id>`, `--columns id,state,text,warrant`,
86
87
  `--wide`, `--include-drafts`, and `--stale <days>`.
87
88
 
@@ -324,8 +324,19 @@ def main(argv: list[str] | None = None) -> int:
324
324
  )
325
325
  ri.set_defaults(func=cmd_reinstate)
326
326
 
327
- pf = sub.add_parser("portfolio", help="roll up many repos into one scoreboard")
328
- pf.add_argument("configs", nargs="+", metavar="PRODC_TOML", help="one or more prodc.toml paths")
327
+ pf = sub.add_parser("portfolio", help="roll up many boards into one scoreboard")
328
+ pf.add_argument(
329
+ "configs",
330
+ nargs="+",
331
+ metavar="PATH",
332
+ help="prodc.toml paths and/or directories to discover boards under",
333
+ )
334
+ pf.add_argument(
335
+ "--max-depth",
336
+ type=int,
337
+ default=4,
338
+ help="how deep to descend when discovering boards under a directory (default 4)",
339
+ )
329
340
  pf.add_argument("--json", action="store_true", help="machine-readable output")
330
341
  pf.set_defaults(func=cmd_portfolio)
331
342
 
@@ -56,7 +56,15 @@ from ..writer import (
56
56
  set_importance,
57
57
  withdraw,
58
58
  )
59
- from .common import RepoRow, emit_edit, exit_code, git_rev, load_or_report
59
+ from .common import (
60
+ RepoRow,
61
+ discover_configs,
62
+ emit_edit,
63
+ exit_code,
64
+ git_rev,
65
+ load_or_report,
66
+ portfolio_totals,
67
+ )
60
68
  from .render import (
61
69
  COLUMNS,
62
70
  provenance_counts,
@@ -582,12 +590,33 @@ def _repo_row(config_path: str) -> RepoRow:
582
590
 
583
591
 
584
592
  def cmd_portfolio(args: argparse.Namespace) -> int:
585
- """Roll up many repos into one scoreboard — coverage, must-HOLEs, provenance per repo."""
586
- rows = [_repo_row(path) for path in args.configs]
593
+ """Roll up many boards into one scoreboard — coverage, must-HOLEs, provenance per board.
594
+
595
+ Each argument is a ``prodc.toml`` path or a directory to discover boards under (every
596
+ ``prodc.toml`` beneath it, pruning VCS/vendor/build and hidden dirs, capped at ``--max-depth``).
597
+ Boards stay separate rows; a totals footer aggregates the clean ones. Never a merged board.
598
+ """
599
+ paths: list[str] = []
600
+ for arg in args.configs:
601
+ p = Path(arg)
602
+ if p.is_dir():
603
+ paths.extend(str(c) for c in discover_configs(p, max_depth=args.max_depth))
604
+ else:
605
+ paths.append(arg)
606
+ seen: set[str] = set()
607
+ unique: list[str] = []
608
+ for path in paths:
609
+ key = str(Path(path).resolve())
610
+ if key not in seen:
611
+ seen.add(key)
612
+ unique.append(path)
613
+ rows = [_repo_row(path) for path in unique]
614
+ totals = portfolio_totals(rows)
587
615
  if args.json:
588
- print(json.dumps([asdict(r) for r in rows], indent=2))
616
+ payload = {"boards": [asdict(r) for r in rows], "totals": asdict(totals)}
617
+ print(json.dumps(payload, indent=2))
589
618
  else:
590
- print(render_portfolio(rows))
619
+ print(render_portfolio(rows, totals))
591
620
  return 2 if any(r.error is not None for r in rows) else 0
592
621
 
593
622
 
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import argparse
12
12
  import json
13
+ import os
13
14
  import sys
14
15
  from dataclasses import dataclass
15
16
  from pathlib import Path
@@ -100,3 +101,79 @@ class RepoRow:
100
101
  coverage: float
101
102
  provenance: dict[str, int]
102
103
  error: str | None = None
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class PortfolioTotals:
108
+ """The aggregate across every board in a portfolio — a footer, not a merged board.
109
+
110
+ Error rows are excluded (a board that would not load contributes no counts). ``coverage`` is
111
+ the pooled ``delivered / total``, which is not the mean of the per-board percentages.
112
+ """
113
+
114
+ boards: int # how many boards loaded cleanly and are summed here
115
+ delivered: int
116
+ total: int
117
+ must_hole: int
118
+ broken: int
119
+ coverage: float
120
+
121
+
122
+ def portfolio_totals(rows: list[RepoRow]) -> PortfolioTotals:
123
+ """Sum the clean rows into a :class:`PortfolioTotals` footer."""
124
+ ok = [r for r in rows if r.error is None]
125
+ delivered = sum(r.delivered for r in ok)
126
+ total = sum(r.total for r in ok)
127
+ return PortfolioTotals(
128
+ boards=len(ok),
129
+ delivered=delivered,
130
+ total=total,
131
+ must_hole=sum(r.must_hole for r in ok),
132
+ broken=sum(r.broken for r in ok),
133
+ coverage=round(delivered / total, 4) if total else 0.0,
134
+ )
135
+
136
+
137
+ # Directory names never descended into during board discovery: VCS, virtualenvs, dependency and
138
+ # build trees. Any name starting with "." is pruned too (handled in the walk), so a vendored or
139
+ # example prodc.toml buried in one of these is never mistaken for a board.
140
+ _PRUNE_DIRS = frozenset(
141
+ {
142
+ ".git",
143
+ "node_modules",
144
+ ".venv",
145
+ "venv",
146
+ "__pycache__",
147
+ "dist",
148
+ "build",
149
+ "target",
150
+ ".tox",
151
+ ".mypy_cache",
152
+ ".ruff_cache",
153
+ "site-packages",
154
+ "vendor",
155
+ }
156
+ )
157
+
158
+
159
+ def discover_configs(root: Path, *, max_depth: int = 4) -> list[Path]:
160
+ """Every ``prodc.toml`` under ``root``, for the portfolio roll-up — bounded so it can't run away.
161
+
162
+ A plain recursive scan is a footgun: it descends into ``.git``/``node_modules``/build trees and
163
+ picks up vendored or example configs. So this prunes those (and every ``.``-prefixed dir) and
164
+ caps the descent at ``max_depth`` directories below ``root``. Returns sorted, de-duplicated
165
+ paths; a board is the directory that holds the ``prodc.toml``.
166
+ """
167
+ root = root.resolve()
168
+ found: set[Path] = set()
169
+ for dirpath, dirnames, filenames in os.walk(root):
170
+ if "prodc.toml" in filenames:
171
+ found.add(Path(dirpath) / "prodc.toml")
172
+ depth = len(Path(dirpath).relative_to(root).parts)
173
+ # Prune in place so os.walk never descends into the skipped dirs, then stop at the depth cap.
174
+ dirnames[:] = (
175
+ []
176
+ if depth >= max_depth
177
+ else [d for d in dirnames if d not in _PRUNE_DIRS and not d.startswith(".")]
178
+ )
179
+ return sorted(found)
@@ -22,7 +22,7 @@ from ..model import (
22
22
  ears_shape,
23
23
  )
24
24
  from ..status import Handoff, Recurrence, Summary, rank, state_priority
25
- from .common import RepoRow, brief_open
25
+ from .common import PortfolioTotals, RepoRow, brief_open
26
26
 
27
27
  _LABEL = {
28
28
  NeedState.DELIVERED: "DELIVERED",
@@ -380,7 +380,9 @@ def render_argument(model: Model, defects: list[str]) -> str:
380
380
  return "\n".join(out)
381
381
 
382
382
 
383
- def render_portfolio(rows: list[RepoRow]) -> str:
383
+ def render_portfolio(rows: list[RepoRow], totals: PortfolioTotals | None = None) -> str:
384
+ if not rows:
385
+ return " no boards found"
384
386
  out = [f" {'repo':<18} {'cov':>4} {'must-HOLE':>9} {'BROKEN':>6} prov prim/sec/asm/op"]
385
387
  for row in rows:
386
388
  if row.error is not None:
@@ -390,4 +392,12 @@ def render_portfolio(rows: list[RepoRow]) -> str:
390
392
  prov = f"{p['primary']}/{p['secondary']}/{p['assumption']}/{p['opinion']}"
391
393
  cov = f"{round(row.coverage * 100)}%"
392
394
  out.append(f" {row.repo:<18} {cov:>4} {row.must_hole:>9} {row.broken:>6} {prov}")
395
+ if totals is not None:
396
+ cov = f"{round(totals.coverage * 100)}%"
397
+ label = f"TOTAL ({totals.boards} boards)"
398
+ out.append(f" {'─' * 48}")
399
+ out.append(
400
+ f" {label:<18} {cov:>4} {totals.must_hole:>9} {totals.broken:>6} "
401
+ f"{totals.delivered}/{totals.total} delivered"
402
+ )
393
403
  return "\n".join(out)
@@ -162,15 +162,18 @@ def test_portfolio_rolls_up_many_repos(tmp_path: Path, capsys: pytest.CaptureFix
162
162
  assert code == 0
163
163
  assert "alpha" in out and "beta" in out
164
164
  assert "33%" in out # each repo is 1/3 delivered
165
+ assert "TOTAL (2 boards)" in out and "2/6 delivered" in out # the aggregate footer
165
166
 
166
167
 
167
168
  def test_portfolio_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
168
169
  import json
169
170
 
170
171
  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"]["primary"] == 1
172
+ payload = json.loads(capsys.readouterr().out)
173
+ boards = payload["boards"]
174
+ assert boards[0]["repo"] == "alpha" and boards[0]["total"] == 3
175
+ assert boards[0]["provenance"]["primary"] == 1
176
+ assert payload["totals"]["boards"] == 1 and payload["totals"]["total"] == 3
174
177
 
175
178
 
176
179
  def test_portfolio_bad_config_exits_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
@@ -179,6 +182,47 @@ def test_portfolio_bad_config_exits_2(tmp_path: Path, capsys: pytest.CaptureFixt
179
182
  assert code == 2
180
183
 
181
184
 
185
+ def test_portfolio_discovers_boards_under_a_directory(
186
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
187
+ ) -> None:
188
+ # a projects/ folder with two boards, one nested a level deeper
189
+ root = tmp_path / "projects"
190
+ _project(root / "crm")
191
+ _project(root / "lingua" / "app")
192
+ code = main(["portfolio", str(root)])
193
+ out = capsys.readouterr().out
194
+ assert code == 0
195
+ assert "crm" in out and "app" in out # both discovered, each its own row
196
+ assert "TOTAL (2 boards)" in out
197
+
198
+
199
+ def test_portfolio_discovery_prunes_vcs_and_vendor_dirs(
200
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
201
+ ) -> None:
202
+ import json
203
+
204
+ root = tmp_path / "projects"
205
+ _project(root / "real")
206
+ # boards buried in dirs that must never be descended into
207
+ _project(root / ".git" / "hooks-board")
208
+ _project(root / "node_modules" / "dep")
209
+ _project(root / "real" / ".venv" / "pkg")
210
+ main(["portfolio", str(root), "--json"])
211
+ names = {b["repo"] for b in json.loads(capsys.readouterr().out)["boards"]}
212
+ assert names == {"real"} # the pruned boards are invisible to discovery
213
+
214
+
215
+ def test_portfolio_discovery_respects_max_depth(
216
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
217
+ ) -> None:
218
+ import json
219
+
220
+ root = tmp_path / "projects"
221
+ _project(root / "a" / "b" / "c" / "deep") # 4 dirs below root
222
+ main(["portfolio", str(root), "--max-depth", "2", "--json"])
223
+ assert json.loads(capsys.readouterr().out)["boards"] == [] # too deep to find
224
+
225
+
182
226
  def test_diff_no_changes(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
183
227
  toml = str(_project(tmp_path))
184
228
  main(["status", "--config", toml, "--json"])
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
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
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