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.
- {prodc-0.3.72 → prodc-0.3.73}/PKG-INFO +1 -1
- {prodc-0.3.72 → prodc-0.3.73}/pyproject.toml +1 -1
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/agent.md +3 -2
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/app.py +13 -2
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/commands.py +34 -5
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/common.py +77 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/render.py +12 -2
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_cli.py +47 -3
- {prodc-0.3.72 → prodc-0.3.73}/.gitignore +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/CHANGELOG.md +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/LICENSE +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/README.md +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/__init__.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/adapters.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/board.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/callbacks.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/__init__.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/cli/serialize.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/config.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/dedup.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/loader.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/model.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/py.typed +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/report.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/report_html.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/status.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/src/prodc/writer.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_adapters.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_answer.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_callbacks.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_citation.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_claim_gating.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_command_hook.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_dedup.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_description.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_drafts.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_evalgate.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_importance.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_loader.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_models.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_needref.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_report.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_report_html.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_roles.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_sources.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_staleness.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_status.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_suggest.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_variants.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_who_variants.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_withdrawn.py +0 -0
- {prodc-0.3.72 → prodc-0.3.73}/tests/test_writer.py +0 -0
- {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.
|
|
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
|
|
@@ -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 <
|
|
84
|
-
(roll up many
|
|
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
|
|
328
|
-
pf.add_argument(
|
|
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
|
|
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
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
assert
|
|
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
|
|
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
|