prodc 0.3.2__tar.gz → 0.3.3__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.3}/PKG-INFO +1 -1
- {prodc-0.3.2 → prodc-0.3.3}/pyproject.toml +1 -1
- {prodc-0.3.2 → prodc-0.3.3}/src/prodc/cli.py +93 -3
- {prodc-0.3.2 → prodc-0.3.3}/tests/test_cli.py +24 -0
- {prodc-0.3.2 → prodc-0.3.3}/.gitignore +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/CHANGELOG.md +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/LICENSE +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/README.md +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/src/prodc/__init__.py +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/src/prodc/adapters.py +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/src/prodc/config.py +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/src/prodc/loader.py +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/src/prodc/model.py +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/src/prodc/py.typed +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/src/prodc/status.py +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/tests/test_adapters.py +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/tests/test_loader.py +0 -0
- {prodc-0.3.2 → prodc-0.3.3}/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.3
|
|
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
|
|
@@ -168,17 +168,25 @@ def _to_json(results: list[NeedResult], summary: Summary, orphan_map: dict[str,
|
|
|
168
168
|
return json.dumps(payload, indent=2)
|
|
169
169
|
|
|
170
170
|
|
|
171
|
-
def
|
|
171
|
+
def _load_or_report(args: argparse.Namespace) -> tuple[Config, list[Need]] | None:
|
|
172
|
+
"""Load config + needs, or print the error to stderr and return None."""
|
|
172
173
|
cfg_path = Path(args.config)
|
|
173
174
|
if not cfg_path.exists():
|
|
174
175
|
print(f"prodc: no config at {cfg_path}", file=sys.stderr)
|
|
175
|
-
return
|
|
176
|
+
return None
|
|
176
177
|
try:
|
|
177
178
|
cfg = Config.load(cfg_path)
|
|
178
|
-
|
|
179
|
+
return cfg, load_needs(cfg)
|
|
179
180
|
except (ConfigError, LoaderError) as exc:
|
|
180
181
|
print(f"prodc: {exc}", file=sys.stderr)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
186
|
+
loaded = _load_or_report(args)
|
|
187
|
+
if loaded is None:
|
|
181
188
|
return 2
|
|
189
|
+
cfg, needs = loaded
|
|
182
190
|
results = resolve(needs, cfg.adapters)
|
|
183
191
|
summary = summarize(results)
|
|
184
192
|
orphan_map = orphans(needs, cfg.adapters)
|
|
@@ -217,6 +225,82 @@ def _exit_code(args: argparse.Namespace, summary: Summary, orphan_total: int) ->
|
|
|
217
225
|
return 0
|
|
218
226
|
|
|
219
227
|
|
|
228
|
+
def _render_show(r: NeedResult) -> str:
|
|
229
|
+
"""One need as a citable answer — the artifact you drop into a thread."""
|
|
230
|
+
need = r.need
|
|
231
|
+
who = (need.who + (f" ({need.label})" if need.label else "")) if need.who else "(unassigned)"
|
|
232
|
+
nm = f" {r.delivered}/{r.total}" if r.total else ""
|
|
233
|
+
lines = [
|
|
234
|
+
f"{need.id}{' !!' if need.must else ''} {need.text}",
|
|
235
|
+
f" who: {who}",
|
|
236
|
+
f" grounds: {_LABEL[r.state]}{nm} warrant: {_warrant(need)}",
|
|
237
|
+
]
|
|
238
|
+
if need.sources:
|
|
239
|
+
lines.append(" sources:")
|
|
240
|
+
for s in need.sources:
|
|
241
|
+
bits = [s.kind.value]
|
|
242
|
+
bits += [f"{k}={v}" for k, v in (("who", s.who), ("date", s.date),
|
|
243
|
+
("locator", s.locator)) if v]
|
|
244
|
+
line = " - " + " ".join(bits)
|
|
245
|
+
lines.append(line + (f' "{s.quote}"' if s.quote else ""))
|
|
246
|
+
else:
|
|
247
|
+
lines.append(" sources: (none — opinion)")
|
|
248
|
+
if need.children:
|
|
249
|
+
lines.append(f" children: {', '.join(need.children)}")
|
|
250
|
+
if need.proofs:
|
|
251
|
+
lines.append(" proofs:")
|
|
252
|
+
lines += [f" - {d}" for d in r.detail]
|
|
253
|
+
elif not need.children:
|
|
254
|
+
lines.append(" proofs: (none — HOLE)")
|
|
255
|
+
lines += [f" ⚠ {w}" for w in r.warnings]
|
|
256
|
+
return "\n".join(lines)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _show_json(r: NeedResult) -> str:
|
|
260
|
+
need = r.need
|
|
261
|
+
return json.dumps(
|
|
262
|
+
{
|
|
263
|
+
"id": need.id,
|
|
264
|
+
"text": need.text,
|
|
265
|
+
"who": need.who,
|
|
266
|
+
"label": need.label,
|
|
267
|
+
"must": need.must,
|
|
268
|
+
"state": r.state.value,
|
|
269
|
+
"grounds": r.state.value,
|
|
270
|
+
"warrant": _warrant(need),
|
|
271
|
+
"delivered": r.delivered,
|
|
272
|
+
"total": r.total,
|
|
273
|
+
"declared": r.declared,
|
|
274
|
+
"opinion": need.is_opinion,
|
|
275
|
+
"only_internal": need.only_internal,
|
|
276
|
+
"sources": [
|
|
277
|
+
{"kind": s.kind.value, "who": s.who, "date": s.date,
|
|
278
|
+
"quote": s.quote, "locator": s.locator}
|
|
279
|
+
for s in need.sources
|
|
280
|
+
],
|
|
281
|
+
"proofs": list(need.proofs),
|
|
282
|
+
"children": list(need.children),
|
|
283
|
+
"detail": r.detail,
|
|
284
|
+
"warnings": r.warnings,
|
|
285
|
+
},
|
|
286
|
+
indent=2,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _cmd_show(args: argparse.Namespace) -> int:
|
|
291
|
+
loaded = _load_or_report(args)
|
|
292
|
+
if loaded is None:
|
|
293
|
+
return 2
|
|
294
|
+
cfg, needs = loaded
|
|
295
|
+
by_id = {r.need.id: r for r in resolve(needs, cfg.adapters)}
|
|
296
|
+
r = by_id.get(args.need_id)
|
|
297
|
+
if r is None:
|
|
298
|
+
print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
|
|
299
|
+
return 2
|
|
300
|
+
print(_show_json(r) if args.json else _render_show(r))
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
220
304
|
def _git_rev(root: Path) -> str | None:
|
|
221
305
|
head = root / ".git" / "HEAD"
|
|
222
306
|
try:
|
|
@@ -263,6 +347,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
263
347
|
)
|
|
264
348
|
st.set_defaults(func=_cmd_status)
|
|
265
349
|
|
|
350
|
+
sh = sub.add_parser("show", help="show one need as a citable answer")
|
|
351
|
+
sh.add_argument("need_id", help="the need id to show, e.g. N-1")
|
|
352
|
+
sh.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
|
|
353
|
+
sh.add_argument("--json", action="store_true", help="machine-readable output")
|
|
354
|
+
sh.set_defaults(func=_cmd_show)
|
|
355
|
+
|
|
266
356
|
args = parser.parse_args(argv)
|
|
267
357
|
return int(args.func(args))
|
|
268
358
|
|
|
@@ -126,3 +126,27 @@ 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
|
|
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
|