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.
- {prodc-0.3.2 → prodc-0.3.4}/PKG-INFO +1 -1
- {prodc-0.3.2 → prodc-0.3.4}/pyproject.toml +1 -1
- {prodc-0.3.2 → prodc-0.3.4}/src/prodc/cli.py +168 -6
- {prodc-0.3.2 → prodc-0.3.4}/tests/test_cli.py +53 -2
- {prodc-0.3.2 → prodc-0.3.4}/.gitignore +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/CHANGELOG.md +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/LICENSE +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/README.md +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/src/prodc/__init__.py +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/src/prodc/adapters.py +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/src/prodc/config.py +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/src/prodc/loader.py +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/src/prodc/model.py +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/src/prodc/py.typed +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/src/prodc/status.py +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/tests/test_adapters.py +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/tests/test_loader.py +0 -0
- {prodc-0.3.2 → prodc-0.3.4}/tests/test_status.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.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
|
|
@@ -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 =
|
|
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
|
|
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
|
|
183
|
+
return None
|
|
176
184
|
try:
|
|
177
185
|
cfg = Config.load(cfg_path)
|
|
178
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|