ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl

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 (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/setup_apply.py ADDED
@@ -0,0 +1,428 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """``ww setup apply``: place a proposed configuration fragment where it belongs.
3
+
4
+ A setup skill proposes a fragment: any of the root keys ``workflows``,
5
+ ``modes``, ``profiles``, ``documents``, ``handlers``, ``hooks`` and ``rules``,
6
+ plus an optional ``settings`` mapping of ``ww.json`` keys.
7
+ ww, not the agent, puts it in place, so no agent edits ww's own configuration
8
+ files (which some agents' safety layers refuse):
9
+
10
+ - ``--for team``: the YAML part goes into ``ww-setup.yaml`` next to the repo
11
+ file, which lists it under ``imports``; ``settings`` merge into
12
+ ``ww.json``;
13
+ - ``--for me``: the YAML part goes into ``ww-setup.local.yaml``, imported by
14
+ ``ww.local.yaml`` (created with just that import when it
15
+ is missing); ``settings`` merge into ``ww.local.json``. All
16
+ three stay out of version control.
17
+
18
+ The setup file is ww's own and rewritten whole: a definition of the same name
19
+ replaces the one already there (rule groups whole, hooks appended), and the
20
+ rest are kept. Adding the import is the only change to a root file, checked to
21
+ leave everything else as it was. Settings merge key by key, and a key that
22
+ already holds a different value is a conflict: the whole apply is refused,
23
+ listing each one.
24
+
25
+ The plan is validated in memory: the configuration is loaded as ww would,
26
+ reading the planned files' new contents in place of the files on disk, so
27
+ validating never touches the project. Only a plan that would load is shown,
28
+ and the files are written once, after the operator confirms; a failed write
29
+ puts back every file already written.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ from collections.abc import Callable
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ import yaml
41
+
42
+ from ww.config import load_configuration
43
+ from ww.config.composition import entry_name
44
+ from ww.config_files import (
45
+ LOCAL_SETTINGS_FILE,
46
+ LOCAL_SETUP_IMPORT_FILE,
47
+ LOCAL_WORKFLOWS_FILE,
48
+ SETTINGS_FILE,
49
+ SETUP_IMPORT_FILE,
50
+ display_path,
51
+ staged_files,
52
+ )
53
+ from ww.config_writes import FileWrite, Transaction, dump_yaml, import_write
54
+ from ww.errors import StateError, WwError
55
+ from ww.extensions import ExtensionRegistry
56
+
57
+ AUDIENCES = ("me", "team")
58
+ SETTINGS_KEY = "settings"
59
+ # The named catalogs: an entry replaces the one of the same name.
60
+ NAMED = {
61
+ "workflows": "workflow",
62
+ "modes": "mode",
63
+ "documents": "document",
64
+ "handlers": "handler",
65
+ }
66
+ # Mappings whose keys name what they hold: a key replaces the same key.
67
+ KEYED = {"profiles": "profile", "rules": "rule group"}
68
+ HOOKS = "hooks"
69
+ FRAGMENT_KEYS = (*NAMED, *KEYED, HOOKS)
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class FileChange:
74
+ """One file the apply writes, and what it changes there."""
75
+
76
+ path: Path
77
+ created: bool
78
+ details: tuple[str, ...]
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class SetupPlan:
83
+ """Everything one apply writes, and what the operator is shown first."""
84
+
85
+ audience: str
86
+ writes: tuple[FileWrite, ...]
87
+ changes: tuple[FileChange, ...]
88
+ warnings: tuple[str, ...] = field(default=())
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class _Target:
93
+ setup_file: Path
94
+ # The root file whose ``imports`` list the setup file.
95
+ root_file: Path
96
+ settings_file: Path
97
+
98
+
99
+ def _target(config_path: Path, audience: str) -> _Target:
100
+ base = config_path.parent
101
+ if audience == "team":
102
+ return _Target(base / SETUP_IMPORT_FILE, config_path, base / SETTINGS_FILE)
103
+ return _Target(
104
+ base / LOCAL_SETUP_IMPORT_FILE,
105
+ base / LOCAL_WORKFLOWS_FILE,
106
+ base / LOCAL_SETTINGS_FILE,
107
+ )
108
+
109
+
110
+ def plan_setup(
111
+ root: Path, config_path: Path, fragment: Path, audience: str
112
+ ) -> SetupPlan:
113
+ """Plan the writes placing ``fragment`` for ``audience``; nothing is written."""
114
+ if audience not in AUDIENCES:
115
+ raise StateError(f"--for takes {' or '.join(AUDIENCES)}")
116
+ definitions, settings = _read_fragment(fragment)
117
+ target = _target(config_path, audience)
118
+
119
+ def label(path: Path) -> str:
120
+ return display_path(path, root)
121
+
122
+ writes: list[FileWrite] = []
123
+ changes: list[FileChange] = []
124
+ root_raw, root_text = _read_yaml(target.root_file, label(target.root_file))
125
+ imported = any(
126
+ isinstance(entry, str)
127
+ and (target.root_file.parent / entry).resolve() == target.setup_file.resolve()
128
+ for entry in (root_raw.get("imports") or [])
129
+ )
130
+ if target.setup_file.exists() and not imported:
131
+ raise StateError(
132
+ f"{label(target.setup_file)} exists but {label(target.root_file)} does "
133
+ "not import it; add it to imports or move it away first"
134
+ )
135
+ existing, _ = _read_yaml(target.setup_file, label(target.setup_file))
136
+ merged, details = _merge(existing, definitions)
137
+ # A fragment whose every definition is already in the imported setup file,
138
+ # hooks included, leaves that file as it is.
139
+ if definitions and not (imported and merged == existing):
140
+ header = (
141
+ f"# Written by `ww setup apply --for {audience}`; ww rewrites this "
142
+ f"file whole,\n# and {target.root_file.name} imports it.\n"
143
+ )
144
+ writes.append(FileWrite(target.setup_file, header + dump_yaml(merged)))
145
+ changes.append(
146
+ FileChange(target.setup_file, not target.setup_file.exists(), details)
147
+ )
148
+ if not imported:
149
+ entry = target.setup_file.name
150
+ if target.root_file.exists():
151
+ writes.append(
152
+ import_write(
153
+ target.root_file,
154
+ root_text,
155
+ root_raw,
156
+ entry,
157
+ label(target.root_file),
158
+ )
159
+ )
160
+ else:
161
+ writes.append(FileWrite(target.root_file, f"imports:\n - {entry}\n"))
162
+ changes.append(
163
+ FileChange(
164
+ target.root_file,
165
+ not target.root_file.exists(),
166
+ (f"adds {entry} to imports",),
167
+ )
168
+ )
169
+ if settings:
170
+ write, change = _settings_write(target.settings_file, settings, label)
171
+ if write is not None and change is not None:
172
+ writes.append(write)
173
+ changes.append(change)
174
+ if not writes:
175
+ raise StateError(
176
+ f"{label(fragment)} changes nothing: everything it proposes is "
177
+ "already in place"
178
+ )
179
+ return SetupPlan(
180
+ audience,
181
+ tuple(writes),
182
+ tuple(changes),
183
+ _shadowed(definitions, root_raw, target, label),
184
+ )
185
+
186
+
187
+ def validate_setup(root: Path, config_path: Path, plan: SetupPlan) -> None:
188
+ """Load the configuration as the plan would leave it; nothing is written."""
189
+ with staged_files({write.path: write.content for write in plan.writes}):
190
+ try:
191
+ load_configuration(config_path, ExtensionRegistry.discover(root))
192
+ except WwError as error:
193
+ raise StateError(
194
+ f"refused: the configuration would not be valid ({error}); "
195
+ "nothing was written"
196
+ ) from error
197
+
198
+
199
+ def apply_setup(plan: SetupPlan) -> None:
200
+ """Write the validated plan, every file or none."""
201
+ with Transaction() as transaction:
202
+ transaction.apply(plan.writes)
203
+
204
+
205
+ def render_plan(plan: SetupPlan, root: Path) -> str:
206
+ audience = (
207
+ "the team: files shared through the repository"
208
+ if plan.audience == "team"
209
+ else "you only: local files kept out of version control"
210
+ )
211
+ lines = [f"`ww setup apply --for {plan.audience}` writes for {audience}:"]
212
+ for change in plan.changes:
213
+ state = " (new)" if change.created else ""
214
+ lines.append(
215
+ f"- {display_path(change.path, root)}{state}: " + "; ".join(change.details)
216
+ )
217
+ lines.extend(f"Warning: {warning}" for warning in plan.warnings)
218
+ return "\n".join(lines) + "\n"
219
+
220
+
221
+ def plan_to_dict(plan: SetupPlan, root: Path, *, applied: bool) -> dict[str, object]:
222
+ return {
223
+ "for": plan.audience,
224
+ "files": [
225
+ {
226
+ "path": display_path(change.path, root),
227
+ "created": change.created,
228
+ "changes": list(change.details),
229
+ }
230
+ for change in plan.changes
231
+ ],
232
+ "warnings": list(plan.warnings),
233
+ "applied": applied,
234
+ }
235
+
236
+
237
+ def _read_fragment(path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
238
+ """The fragment's definitions and its settings, each checked for shape."""
239
+ try:
240
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
241
+ except OSError as error:
242
+ raise StateError(f"cannot read {path}: {error}") from error
243
+ except yaml.YAMLError as error:
244
+ raise StateError(f"invalid YAML in {path}: {error}") from error
245
+ if not isinstance(raw, dict) or not raw:
246
+ raise StateError(f"{path} must be a mapping of configuration keys")
247
+ unknown = set(raw) - {*FRAGMENT_KEYS, SETTINGS_KEY}
248
+ if unknown:
249
+ raise StateError(
250
+ f"{path} has unknown key(s): {', '.join(sorted(map(str, unknown)))}; "
251
+ f"a setup fragment holds {', '.join(FRAGMENT_KEYS)} and {SETTINGS_KEY}"
252
+ )
253
+ for key in NAMED:
254
+ if key in raw:
255
+ entries = raw[key]
256
+ if not isinstance(entries, list):
257
+ raise StateError(f"{path}: {key} must be a list")
258
+ for index, entry in enumerate(entries):
259
+ if entry_name(entry) is None:
260
+ raise StateError(f"{path}: {key}[{index}] needs a name")
261
+ for key in (*KEYED, HOOKS, SETTINGS_KEY):
262
+ if key in raw and not isinstance(raw[key], dict):
263
+ raise StateError(f"{path}: {key} must be a mapping")
264
+ for phase, entries in (raw.get(HOOKS) or {}).items():
265
+ if not isinstance(entries, list):
266
+ raise StateError(f"{path}: hooks.{phase} must be a list")
267
+ settings = raw.pop(SETTINGS_KEY, None) or {}
268
+ return raw, settings
269
+
270
+
271
+ def _read_yaml(path: Path, label: str) -> tuple[dict[str, Any], str]:
272
+ if not path.exists():
273
+ return {}, ""
274
+ text = path.read_text(encoding="utf-8")
275
+ try:
276
+ raw = yaml.safe_load(text)
277
+ except yaml.YAMLError as error:
278
+ raise StateError(f"invalid YAML in {label}: {error}") from error
279
+ if raw is None:
280
+ return {}, text
281
+ if not isinstance(raw, dict):
282
+ raise StateError(f"{label} must contain a mapping")
283
+ return raw, text
284
+
285
+
286
+ def _merge(
287
+ existing: dict[str, Any], definitions: dict[str, Any]
288
+ ) -> tuple[dict[str, Any], tuple[str, ...]]:
289
+ """The setup file with the fragment folded in, and what that changes."""
290
+ merged = {key: value for key, value in existing.items()}
291
+ added: list[str] = []
292
+ replaced: list[str] = []
293
+ appended: list[str] = []
294
+ kept: list[str] = []
295
+ for key, value in definitions.items():
296
+ if key in NAMED:
297
+ current = list(merged.get(key) or [])
298
+ positions = {
299
+ entry_name(entry): index for index, entry in enumerate(current)
300
+ }
301
+ for entry in value:
302
+ name = entry_name(entry)
303
+ if name in positions:
304
+ current[positions[name]] = entry
305
+ replaced.append(f"{NAMED[key]} `{name}`")
306
+ else:
307
+ positions[name] = len(current)
308
+ current.append(entry)
309
+ added.append(f"{NAMED[key]} `{name}`")
310
+ merged[key] = current
311
+ elif key in KEYED:
312
+ mapping = dict(merged.get(key) or {})
313
+ for name, item in value.items():
314
+ (replaced if name in mapping else added).append(
315
+ f"{KEYED[key]} `{name}`"
316
+ )
317
+ mapping[name] = item
318
+ merged[key] = mapping
319
+ else:
320
+ hooks = dict(merged.get(HOOKS) or {})
321
+ for phase, entries in value.items():
322
+ # Hooks carry no name: an entry identical to one already in
323
+ # the phase is the same hook, so applying twice adds it once.
324
+ current = list(hooks.get(phase) or [])
325
+ new = []
326
+ for entry in entries:
327
+ if entry in current or entry in new:
328
+ continue
329
+ new.append(entry)
330
+ hooks[phase] = [*current, *new]
331
+ if new:
332
+ appended.append(_hooks_phrase(len(new), phase))
333
+ if len(new) < len(entries):
334
+ skipped = len(entries) - len(new)
335
+ kept.append(
336
+ f"{skipped} hook{'s' if skipped != 1 else ''} already "
337
+ f"in {phase}"
338
+ )
339
+ merged[HOOKS] = hooks
340
+ details = [
341
+ *(["adds " + ", ".join(added)] if added else []),
342
+ *(["replaces " + ", ".join(replaced)] if replaced else []),
343
+ *(["appends " + ", ".join(appended)] if appended else []),
344
+ *(["skips " + ", ".join(kept)] if kept else []),
345
+ ]
346
+ return merged, tuple(details)
347
+
348
+
349
+ def _hooks_phrase(count: int, phase: str) -> str:
350
+ return f"{count} hook{'s' if count != 1 else ''} to {phase}"
351
+
352
+
353
+ def _settings_write(
354
+ path: Path, settings: dict[str, Any], label: Callable[[Path], str]
355
+ ) -> tuple[FileWrite | None, FileChange | None]:
356
+ """Merge ``settings`` into the JSON file key by key, refusing conflicts."""
357
+ current: Any = {}
358
+ if path.exists():
359
+ try:
360
+ current = json.loads(path.read_text(encoding="utf-8"))
361
+ except (OSError, json.JSONDecodeError) as error:
362
+ raise StateError(f"invalid {label(path)}: {error}") from error
363
+ if not isinstance(current, dict):
364
+ raise StateError(f"{label(path)} must contain a JSON object")
365
+ added: list[str] = []
366
+ conflicts: list[str] = []
367
+
368
+ def merge(target: dict[str, Any], source: dict[str, Any], prefix: str) -> None:
369
+ for key, value in source.items():
370
+ dotted = f"{prefix}{key}"
371
+ if key not in target:
372
+ target[key] = value
373
+ added.append(dotted)
374
+ elif isinstance(target[key], dict) and isinstance(value, dict):
375
+ merge(target[key], value, f"{dotted}.")
376
+ elif target[key] != value:
377
+ conflicts.append(
378
+ f"{dotted} is {json.dumps(target[key])}, the fragment "
379
+ f"proposes {json.dumps(value)}"
380
+ )
381
+
382
+ merge(current, settings, "")
383
+ if conflicts:
384
+ raise StateError(
385
+ f"refused: {label(path)} already sets different values, and ww "
386
+ "does not overwrite them; nothing was written:\n"
387
+ + "\n".join(f"- {conflict}" for conflict in conflicts)
388
+ )
389
+ if not added:
390
+ return None, None
391
+ return (
392
+ FileWrite(path, json.dumps(current, indent=2) + "\n"),
393
+ FileChange(path, not path.exists(), ("sets " + ", ".join(added),)),
394
+ )
395
+
396
+
397
+ def _shadowed(
398
+ definitions: dict[str, Any],
399
+ root_raw: dict[str, Any],
400
+ target: _Target,
401
+ label: Callable[[Path], str],
402
+ ) -> tuple[str, ...]:
403
+ """Fragment definitions the importing root file overrides.
404
+
405
+ A level's imports fold before its root file, so a definition the root file
406
+ also holds keeps the root's version.
407
+ """
408
+ warnings: list[str] = []
409
+ for key, value in definitions.items():
410
+ declared = root_raw.get(key)
411
+ if key in NAMED and isinstance(declared, list):
412
+ names = {entry_name(entry) for entry in declared}
413
+ shadowed = [
414
+ entry_name(entry) for entry in value if entry_name(entry) in names
415
+ ]
416
+ kind = NAMED[key]
417
+ elif key in KEYED and isinstance(declared, dict):
418
+ shadowed = [name for name in value if name in declared]
419
+ kind = KEYED[key]
420
+ else:
421
+ continue
422
+ warnings.extend(
423
+ f"{kind} `{name}` is also defined in {label(target.root_file)}, which "
424
+ f"takes precedence over {target.setup_file.name}; this one has no "
425
+ "effect until it is removed there"
426
+ for name in shadowed
427
+ )
428
+ return tuple(warnings)
ww/step_values.py ADDED
@@ -0,0 +1,20 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The shape of a provider of per-step template values."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable
7
+
8
+ from ww.execution_models import ExecutionState
9
+ from ww.plan import PlanItem, WorkflowPlan
10
+
11
+ # Template values one step reads about what it works on, such as
12
+ # ``{{ww.child.*}}`` for a per-child stage or ``{{ww.item.*}}`` for a per-item
13
+ # stage; empty for a step that works on neither.
14
+ StepValues = Callable[[ExecutionState, WorkflowPlan, PlanItem], dict[str, str]]
15
+
16
+
17
+ def no_step_values(
18
+ state: ExecutionState, plan: WorkflowPlan, item: PlanItem
19
+ ) -> dict[str, str]:
20
+ return {}