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.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- 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 {}
|