prodc 0.4.1__tar.gz → 0.4.2__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 (65) hide show
  1. {prodc-0.4.1 → prodc-0.4.2}/PKG-INFO +1 -1
  2. {prodc-0.4.1 → prodc-0.4.2}/pyproject.toml +1 -1
  3. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/cli/render.py +4 -1
  4. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/cli/serialize.py +1 -1
  5. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/loader.py +20 -4
  6. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/model.py +16 -1
  7. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/status.py +6 -6
  8. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/writer.py +14 -1
  9. {prodc-0.4.1 → prodc-0.4.2}/tests/test_loader.py +20 -0
  10. {prodc-0.4.1 → prodc-0.4.2}/tests/test_models.py +2 -2
  11. {prodc-0.4.1 → prodc-0.4.2}/tests/test_status.py +7 -7
  12. {prodc-0.4.1 → prodc-0.4.2}/tests/test_writer.py +27 -0
  13. {prodc-0.4.1 → prodc-0.4.2}/.gitignore +0 -0
  14. {prodc-0.4.1 → prodc-0.4.2}/CHANGELOG.md +0 -0
  15. {prodc-0.4.1 → prodc-0.4.2}/LICENSE +0 -0
  16. {prodc-0.4.1 → prodc-0.4.2}/README.md +0 -0
  17. {prodc-0.4.1 → prodc-0.4.2}/examples/quickstart/features/orders.feature +0 -0
  18. {prodc-0.4.1 → prodc-0.4.2}/examples/quickstart/needs/jordan.yaml +0 -0
  19. {prodc-0.4.1 → prodc-0.4.2}/examples/quickstart/prodc.toml +0 -0
  20. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/__init__.py +0 -0
  21. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/adapters.py +0 -0
  22. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/agent.md +0 -0
  23. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/board.py +0 -0
  24. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/callbacks.py +0 -0
  25. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/cli/__init__.py +0 -0
  26. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/cli/app.py +0 -0
  27. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/cli/commands.py +0 -0
  28. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/cli/common.py +0 -0
  29. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/config.py +0 -0
  30. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/dedup.py +0 -0
  31. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/guide.md +0 -0
  32. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/py.typed +0 -0
  33. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/report.py +0 -0
  34. {prodc-0.4.1 → prodc-0.4.2}/src/prodc/report_html.py +0 -0
  35. {prodc-0.4.1 → prodc-0.4.2}/tests/test_adapters.py +0 -0
  36. {prodc-0.4.1 → prodc-0.4.2}/tests/test_answer.py +0 -0
  37. {prodc-0.4.1 → prodc-0.4.2}/tests/test_callbacks.py +0 -0
  38. {prodc-0.4.1 → prodc-0.4.2}/tests/test_citation.py +0 -0
  39. {prodc-0.4.1 → prodc-0.4.2}/tests/test_claim_gating.py +0 -0
  40. {prodc-0.4.1 → prodc-0.4.2}/tests/test_cli.py +0 -0
  41. {prodc-0.4.1 → prodc-0.4.2}/tests/test_command_hook.py +0 -0
  42. {prodc-0.4.1 → prodc-0.4.2}/tests/test_contradicts.py +0 -0
  43. {prodc-0.4.1 → prodc-0.4.2}/tests/test_decisions.py +0 -0
  44. {prodc-0.4.1 → prodc-0.4.2}/tests/test_dedup.py +0 -0
  45. {prodc-0.4.1 → prodc-0.4.2}/tests/test_description.py +0 -0
  46. {prodc-0.4.1 → prodc-0.4.2}/tests/test_drafts.py +0 -0
  47. {prodc-0.4.1 → prodc-0.4.2}/tests/test_evalgate.py +0 -0
  48. {prodc-0.4.1 → prodc-0.4.2}/tests/test_external_adapter.py +0 -0
  49. {prodc-0.4.1 → prodc-0.4.2}/tests/test_gitlab_source.py +0 -0
  50. {prodc-0.4.1 → prodc-0.4.2}/tests/test_importance.py +0 -0
  51. {prodc-0.4.1 → prodc-0.4.2}/tests/test_needref.py +0 -0
  52. {prodc-0.4.1 → prodc-0.4.2}/tests/test_principles.py +0 -0
  53. {prodc-0.4.1 → prodc-0.4.2}/tests/test_relink.py +0 -0
  54. {prodc-0.4.1 → prodc-0.4.2}/tests/test_report.py +0 -0
  55. {prodc-0.4.1 → prodc-0.4.2}/tests/test_report_html.py +0 -0
  56. {prodc-0.4.1 → prodc-0.4.2}/tests/test_review.py +0 -0
  57. {prodc-0.4.1 → prodc-0.4.2}/tests/test_roles.py +0 -0
  58. {prodc-0.4.1 → prodc-0.4.2}/tests/test_source_role.py +0 -0
  59. {prodc-0.4.1 → prodc-0.4.2}/tests/test_sources.py +0 -0
  60. {prodc-0.4.1 → prodc-0.4.2}/tests/test_staleness.py +0 -0
  61. {prodc-0.4.1 → prodc-0.4.2}/tests/test_suggest.py +0 -0
  62. {prodc-0.4.1 → prodc-0.4.2}/tests/test_variants.py +0 -0
  63. {prodc-0.4.1 → prodc-0.4.2}/tests/test_who_variants.py +0 -0
  64. {prodc-0.4.1 → prodc-0.4.2}/tests/test_withdrawn.py +0 -0
  65. {prodc-0.4.1 → prodc-0.4.2}/tests/test_writer_comments.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.4.1
3
+ Version: 0.4.2
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.4.1"
3
+ version = "0.4.2"
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"
@@ -382,7 +382,10 @@ def render_show(
382
382
  else:
383
383
  lines.append(" sources: (none — opinion)")
384
384
  if need.children:
385
- lines.append(f" children: {', '.join(need.children)}")
385
+ rendered = ", ".join(
386
+ c.id if c.step is None else f"{c.id}@{c.step}" for c in need.children
387
+ )
388
+ lines.append(f" children: {rendered}")
386
389
  if need.contradicts:
387
390
  lines.append(f" contradicts: {', '.join(need.contradicts)}")
388
391
  if need.seam:
@@ -160,7 +160,7 @@ def show_json(r: NeedResult, decisions: list[Decision] | None = None) -> str:
160
160
  for s in need.sources
161
161
  ],
162
162
  "proofs": list(need.proofs),
163
- "children": list(need.children),
163
+ "children": [{"id": c.id, "step": c.step} for c in need.children],
164
164
  "seam": list(need.seam),
165
165
  }
166
166
  )
@@ -21,6 +21,7 @@ from .model import (
21
21
  Answer,
22
22
  Attestation,
23
23
  AttestationKind,
24
+ Child,
24
25
  Decision,
25
26
  Goal,
26
27
  Model,
@@ -203,6 +204,21 @@ def _source(raw: object, where: str) -> Source:
203
204
  )
204
205
 
205
206
 
207
+ def _child(raw: object, where: str) -> Child:
208
+ """Parse one flow edge: a bare id string, or a ``{id, step}`` mapping (#90).
209
+
210
+ A bare ``US-050`` stays valid (``Child("US-050")`` with no step), so existing flows load
211
+ unchanged; a mapping adds the optional ``step`` the edge plays in the journey.
212
+ """
213
+ if isinstance(raw, str):
214
+ return Child(id=raw)
215
+ tbl = _as_mapping(raw, where)
216
+ return Child(
217
+ id=_as_str(tbl.get("id"), f"{where}.id"),
218
+ step=_opt_str(tbl.get("step"), f"{where}.step"),
219
+ )
220
+
221
+
206
222
  def _need(
207
223
  raw: object,
208
224
  where: str,
@@ -243,7 +259,7 @@ def _need(
243
259
  for i, p in enumerate(_as_list(tbl.get("proofs"), f"{where}.proofs"))
244
260
  )
245
261
  children = tuple(
246
- _as_str(c, f"{where}.children[{i}]")
262
+ _child(c, f"{where}.children[{i}]")
247
263
  for i, c in enumerate(_as_list(tbl.get("children"), f"{where}.children"))
248
264
  )
249
265
  seam = tuple(
@@ -564,9 +580,9 @@ def _check_children(needs: list[Need]) -> None:
564
580
  """Every id in a need's ``children`` must name a known need."""
565
581
  known = {n.id for n in needs}
566
582
  for need in needs:
567
- for cid in need.children:
568
- if cid not in known:
569
- raise LoaderError(f"need {need.id!r}: child {cid!r} is not a known need id")
583
+ for c in need.children:
584
+ if c.id not in known:
585
+ raise LoaderError(f"need {need.id!r}: child {c.id!r} is not a known need id")
570
586
 
571
587
 
572
588
  def _check_withdrawal_of(needs: list[Need]) -> None:
@@ -257,6 +257,21 @@ class Withdrawal:
257
257
  of: str | None = None # id of the need this one duplicates (required when kind is duplicate)
258
258
 
259
259
 
260
+ @dataclass(frozen=True)
261
+ class Child:
262
+ """One edge of a flow: the sub-need and, optionally, the ``step`` it plays in the journey (#90).
263
+
264
+ A flow's ``children`` are ordered, and a use case ("job") is just a flow with a ``role`` set and
265
+ a ``step`` on each edge (locate / execute / conclude). The step annotates the *edge*, not the
266
+ sub-need — the same need can be the ``execute`` step of one flow and the ``locate`` of another —
267
+ so it lives here, not on :class:`Need`. A bare ``US-050`` in YAML loads as ``Child("US-050")``
268
+ with no step, so every existing flow keeps working unchanged.
269
+ """
270
+
271
+ id: str
272
+ step: str | None = None
273
+
274
+
260
275
  @dataclass(frozen=True)
261
276
  class Need:
262
277
  """What a named person wants, with its provenance and its proofs."""
@@ -269,7 +284,7 @@ class Need:
269
284
  must: bool = False
270
285
  sources: tuple[Source, ...] = ()
271
286
  proofs: tuple[str, ...] = () # locators, "<adapter>:<payload>" (leaf needs)
272
- children: tuple[str, ...] = () # ids of sub-needs (a flow is a Need with children)
287
+ children: tuple[Child, ...] = () # ordered sub-need edges (a flow is a Need with children)
273
288
  seam: tuple[str, ...] = () # end-to-end proof locators for a flow's between-steps behaviour
274
289
  client: str | None = None # which client this instance is for; None = shared baseline
275
290
  variant_of: str | None = None # id of the baseline need this specialises; None = standalone
@@ -293,11 +293,11 @@ class Resolver:
293
293
  warnings: list[str] = []
294
294
  child_states: list[NeedState] = []
295
295
  declared = False
296
- for cid in need.children:
297
- if cid not in self._by_id:
298
- warnings.append(f"unknown child id '{cid}'")
296
+ for c in need.children:
297
+ if c.id not in self._by_id:
298
+ warnings.append(f"unknown child id '{c.id}'")
299
299
  continue
300
- cr = self.resolve(cid)
300
+ cr = self.resolve(c.id)
301
301
  child_states.append(cr.state)
302
302
  declared = declared or cr.declared
303
303
  delivered = sum(1 for s in child_states if s is NeedState.DELIVERED)
@@ -806,7 +806,7 @@ def handoffs(needs: list[Need]) -> list[Handoff]:
806
806
  for flow in needs:
807
807
  if not flow.children:
808
808
  continue
809
- steps = [by_id[c] for c in flow.children if c in by_id]
809
+ steps = [by_id[c.id] for c in flow.children if c.id in by_id]
810
810
  for a, b in pairwise(steps):
811
811
  if a.role and b.role and a.role != b.role:
812
812
  out.append(Handoff(flow, a, b, a.role, b.role))
@@ -844,7 +844,7 @@ def flow_orphan_warnings(needs: list[Need]) -> set[str]:
844
844
  return set()
845
845
  in_a_flow: set[str] = set()
846
846
  for flow in flows:
847
- in_a_flow.update(flow.children)
847
+ in_a_flow.update(c.id for c in flow.children)
848
848
  return {n.id for n in needs if not n.children and n.id not in in_a_flow}
849
849
 
850
850
 
@@ -365,6 +365,19 @@ def _coerce(value: str) -> object:
365
365
  return value
366
366
 
367
367
 
368
+ def _child_item(raw: str) -> object:
369
+ """Parse a ``children+=`` value into a flow edge: ``id`` stays a bare string, ``id@step`` a map.
370
+
371
+ Mirrors the loader's dual shape (#90): ``children+=US-050`` appends the bare id (unstepped),
372
+ ``children+=US-052@execute`` appends ``{id: US-052, step: execute}``. A bare id keeps the file's
373
+ existing plain-string style; the ``@`` suffix mirrors the ``need:A@yes`` proof-locator idiom.
374
+ """
375
+ head, sep, step = raw.partition("@")
376
+ if not sep:
377
+ return raw
378
+ return {"id": head.strip(), "step": step.strip()}
379
+
380
+
368
381
  def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> None:
369
382
  if "+=" in assignment:
370
383
  key, _, raw = assignment.partition("+=")
@@ -377,7 +390,7 @@ def _apply_assignment(need: dict[str, object], assignment: str, path: Path) -> N
377
390
  if not isinstance(current, list):
378
391
  raise WriteError(f"{path.name}: {key!r} is not a list")
379
392
  items = cast("list[object]", current)
380
- items.append(raw.strip())
393
+ items.append(_child_item(raw.strip()) if key == "children" else raw.strip())
381
394
  need[key] = items
382
395
  return
383
396
  key, sep, raw = assignment.partition("=")
@@ -109,6 +109,26 @@ def test_flow_with_proofs_raises(tmp_path: Path) -> None:
109
109
  load_needs(cfg)
110
110
 
111
111
 
112
+ def test_children_load_as_bare_id_or_stepped_mapping(tmp_path: Path) -> None:
113
+ # #90: a flow's children take either a bare id (unstepped) or a {id, step} mapping, mixed.
114
+ cfg = _config(
115
+ tmp_path,
116
+ (
117
+ "a.yaml",
118
+ "needs:\n"
119
+ " - id: F-1\n"
120
+ " text: a flow\n"
121
+ " children:\n"
122
+ " - C-1\n"
123
+ " - {id: C-2, step: execute}\n"
124
+ " - id: C-1\n text: one\n"
125
+ " - id: C-2\n text: two\n",
126
+ ),
127
+ )
128
+ flow = next(n for n in load_needs(cfg) if n.id == "F-1")
129
+ assert [(c.id, c.step) for c in flow.children] == [("C-1", None), ("C-2", "execute")]
130
+
131
+
112
132
  def test_variant_scope_shared_baseline_across_clients(tmp_path: Path) -> None:
113
133
  cfg = _config(
114
134
  tmp_path,
@@ -14,7 +14,7 @@ from dataclasses import dataclass, replace
14
14
  from musil import Action, Model, check, explore, reaches
15
15
 
16
16
  from prodc.adapters import Adapter, ProofResult
17
- from prodc.model import Decision, Need, NeedState, ProofState, Withdrawal, board_needs
17
+ from prodc.model import Child, Decision, Need, NeedState, ProofState, Withdrawal, board_needs
18
18
  from prodc.status import Resolver, active_decisions
19
19
 
20
20
 
@@ -141,7 +141,7 @@ def flow_state(f: Flow) -> NeedState:
141
141
  """The NeedState prodc's real aggregate gives this set of children + seam."""
142
142
  children = [_child_need(f"c{i}", s) for i, s in enumerate(f.children)]
143
143
  seam = tuple(f"stub:{s}" for s in f.seam if s != "none")
144
- flow = Need(id="flow", text="f", children=tuple(c.id for c in children), seam=seam)
144
+ flow = Need(id="flow", text="f", children=tuple(Child(c.id) for c in children), seam=seam)
145
145
  return Resolver([flow, *children], _ADAPTERS).resolve("flow").state
146
146
 
147
147
 
@@ -6,7 +6,7 @@ from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
 
8
8
  from prodc.adapters import FrontmatterAdapter, ProofResult, SourceAdapter
9
- from prodc.model import Need, NeedState, ProofState, Source, SourceKind
9
+ from prodc.model import Child, Need, NeedState, ProofState, Source, SourceKind
10
10
  from prodc.status import resolve
11
11
 
12
12
 
@@ -52,7 +52,7 @@ def test_flow_aggregates_two_of_three() -> None:
52
52
  Need(id="C-2", text="two", proofs=("web:2",)),
53
53
  Need(id="C-3", text="three", proofs=()), # a HOLE
54
54
  )
55
- flow = Need(id="F-1", text="a flow", children=("C-1", "C-2", "C-3"))
55
+ flow = Need(id="F-1", text="a flow", children=(Child("C-1"), Child("C-2"), Child("C-3")))
56
56
  results = {r.need.id: r for r in resolve([flow, *children], {"web": _delivered()})}
57
57
  flow_result = results["F-1"]
58
58
  assert flow_result.state is NeedState.PENDING # not all delivered → pending
@@ -234,7 +234,7 @@ def test_coverage_counts_leaves_not_flow_parents() -> None:
234
234
  from prodc.status import summarize
235
235
 
236
236
  needs = [
237
- Need(id="F-1", text="roast day flow", children=("L-1", "L-2")),
237
+ Need(id="F-1", text="roast day flow", children=(Child("L-1"), Child("L-2"))),
238
238
  Need(id="L-1", text="step one", proofs=("x:1",)),
239
239
  Need(id="L-2", text="step two", proofs=("x:1",)),
240
240
  ]
@@ -296,7 +296,7 @@ def test_flow_orphan_warning() -> None:
296
296
  from prodc.status import flow_orphan_warnings
297
297
 
298
298
  needs = [
299
- Need(id="F-1", text="a flow", children=("L-1",)),
299
+ Need(id="F-1", text="a flow", children=(Child("L-1"),)),
300
300
  Need(id="L-1", text="in the flow"),
301
301
  Need(id="L-2", text="in no flow"),
302
302
  ]
@@ -354,7 +354,7 @@ def test_flow_seam_proof_gates_delivered() -> None:
354
354
  # all children delivered but the seam proof pending → flow is PENDING, not DELIVERED (#44)
355
355
  from prodc.adapters import Adapter
356
356
 
357
- flow = Need(id="F-1", text="roast day", children=("L-1", "L-2"), seam=("web:seam#E2E",))
357
+ flow = Need(id="F-1", text="roast day", children=(Child("L-1"), Child("L-2")), seam=("web:seam#E2E",))
358
358
  children = [
359
359
  Need(id="L-1", text="a", proofs=("x:1",)),
360
360
  Need(id="L-2", text="b", proofs=("x:1",)),
@@ -368,14 +368,14 @@ def test_flow_seam_proof_gates_delivered() -> None:
368
368
 
369
369
 
370
370
  def test_flow_seam_green_delivers() -> None:
371
- flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("web:seam#E2E",))
371
+ flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("web:seam#E2E",))
372
372
  child = Need(id="L-1", text="a", proofs=("x:1",))
373
373
  by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered(), "web": _delivered()})}
374
374
  assert by_id["F-1"].state is NeedState.DELIVERED
375
375
 
376
376
 
377
377
  def test_flow_seam_broken_breaks_flow() -> None:
378
- flow = Need(id="F-1", text="roast day", children=("L-1",), seam=("nope:x",))
378
+ flow = Need(id="F-1", text="roast day", children=(Child("L-1"),), seam=("nope:x",))
379
379
  child = Need(id="L-1", text="a", proofs=("x:1",))
380
380
  by_id = {r.need.id: r for r in resolve([flow, child], {"x": _delivered()})}
381
381
  assert by_id["F-1"].state is NeedState.BROKEN # no 'nope' adapter → seam BROKEN
@@ -275,6 +275,33 @@ def test_delete_need_removes_the_block_keeping_others(tmp_path: Path) -> None:
275
275
  assert "# keep me" in text # header comment survives the splice
276
276
 
277
277
 
278
+ def test_children_append_stepped_edge(tmp_path: Path) -> None:
279
+ # #90: `children+=id@step` appends a {id, step} edge; a bare id stays a plain string.
280
+ body = (
281
+ "needs:\n"
282
+ " - id: F-1\n text: a flow\n children: [N-2]\n"
283
+ " - id: N-2\n text: step two\n proofs: ['x:1']\n"
284
+ " - id: N-3\n text: step three\n proofs: ['x:2']\n"
285
+ )
286
+ cfg = _proj(tmp_path, body)
287
+ set_field(cfg, "F-1", "children+=N-3@conclude", expect_sha=None, dry_run=False)
288
+ flow = next(n for n in load(cfg).needs if n.id == "F-1")
289
+ assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", "conclude")]
290
+
291
+
292
+ def test_children_append_bare_id(tmp_path: Path) -> None:
293
+ body = (
294
+ "needs:\n"
295
+ " - id: F-1\n text: a flow\n children: [N-2]\n"
296
+ " - id: N-2\n text: step two\n proofs: ['x:1']\n"
297
+ " - id: N-3\n text: step three\n proofs: ['x:2']\n"
298
+ )
299
+ cfg = _proj(tmp_path, body)
300
+ set_field(cfg, "F-1", "children+=N-3", expect_sha=None, dry_run=False)
301
+ flow = next(n for n in load(cfg).needs if n.id == "F-1")
302
+ assert [(c.id, c.step) for c in flow.children] == [("N-2", None), ("N-3", None)]
303
+
304
+
278
305
  def test_delete_need_unknown_id_rejected(tmp_path: Path) -> None:
279
306
  cfg = _proj(tmp_path, "needs:\n - id: N-1\n text: t\n")
280
307
  with pytest.raises(WriteError, match="no need 'N-9'"):
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