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/config/actions.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Action, command, metadata, and hook parsing for ww.yaml."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from ww.actions import DefinedAction, actions
|
|
10
|
+
from ww.contracts import HookFailure, HookPhase, HookScope, RequestedActionKind
|
|
11
|
+
from ww.errors import ConfigurationError
|
|
12
|
+
from ww.extensions import is_extension_reference
|
|
13
|
+
from ww.operations import WorkflowHandoff
|
|
14
|
+
from ww.variables import is_reserved_name
|
|
15
|
+
from ww.workflow_config import (
|
|
16
|
+
ALL,
|
|
17
|
+
ALL_NAMES,
|
|
18
|
+
DocumentUpdate,
|
|
19
|
+
HandlerDefinition,
|
|
20
|
+
HookDefinition,
|
|
21
|
+
ItemFieldUpdate,
|
|
22
|
+
ProvidedVariable,
|
|
23
|
+
SavedMetadata,
|
|
24
|
+
)
|
|
25
|
+
from ww.workspace import WORKDIRS, Workdir
|
|
26
|
+
|
|
27
|
+
from .values import (
|
|
28
|
+
_description,
|
|
29
|
+
_mapping,
|
|
30
|
+
_name,
|
|
31
|
+
_name_filter,
|
|
32
|
+
_named_entry,
|
|
33
|
+
_nonempty_string,
|
|
34
|
+
_only,
|
|
35
|
+
_optional_agent,
|
|
36
|
+
_optional_bool,
|
|
37
|
+
_optional_string,
|
|
38
|
+
_unique,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
HOOK_PHASES: tuple[HookPhase, ...] = (
|
|
42
|
+
"before_start_workflow",
|
|
43
|
+
"before_start",
|
|
44
|
+
"before_complete",
|
|
45
|
+
"after_complete",
|
|
46
|
+
"before_complete_workflow",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ``action.type`` names that spell a core control rather than a registered action.
|
|
51
|
+
CORE_ACTION_TYPES = frozenset({"loop", "workflow_transition", "child_workflow"})
|
|
52
|
+
# The agent action kinds ``kind`` chooses between.
|
|
53
|
+
ACTION_KINDS: tuple[RequestedActionKind, ...] = ("skill", "slash_command", "prompt")
|
|
54
|
+
# The prefixes of ``saves`` entries; the prefix is the kind and scope of the
|
|
55
|
+
# saved value, the rest its storage path.
|
|
56
|
+
_SAVE_PREFIXES = ("metadata.", "project_metadata.", "documents.", "item.field.")
|
|
57
|
+
# A dotted metadata path, e.g. "github.owner"; "github..owner" does not match.
|
|
58
|
+
_METADATA_PATH = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*")
|
|
59
|
+
# A variable name, dots and hyphens allowed, e.g. "ww.task-id".
|
|
60
|
+
_VARIABLE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_.-]*")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_handler(
|
|
64
|
+
mapping: dict[str, Any],
|
|
65
|
+
path: str,
|
|
66
|
+
*,
|
|
67
|
+
inline: bool = False,
|
|
68
|
+
transition: bool = False,
|
|
69
|
+
allowed_extra: set[str] | None = None,
|
|
70
|
+
) -> HandlerDefinition:
|
|
71
|
+
"""Parse one handler; ``transition`` admits the ``handoff_to`` key on a step."""
|
|
72
|
+
_only(
|
|
73
|
+
mapping,
|
|
74
|
+
_handler_keys()
|
|
75
|
+
| ({"handoff_to"} if inline or transition else set())
|
|
76
|
+
| (allowed_extra or set()),
|
|
77
|
+
path,
|
|
78
|
+
)
|
|
79
|
+
if "handlers" in mapping:
|
|
80
|
+
return _parse_automatic_group(mapping, path, inline=inline)
|
|
81
|
+
if (inline or transition) and "handoff_to" in mapping:
|
|
82
|
+
extra = set(mapping) - {
|
|
83
|
+
"name",
|
|
84
|
+
"handoff_to",
|
|
85
|
+
"start_child",
|
|
86
|
+
"description",
|
|
87
|
+
"agent",
|
|
88
|
+
"model",
|
|
89
|
+
"reasoning",
|
|
90
|
+
}
|
|
91
|
+
if "children" in extra:
|
|
92
|
+
raise ConfigurationError(
|
|
93
|
+
f"{path} cannot combine children with handoff_to; "
|
|
94
|
+
"name the child workflow under children.workflow"
|
|
95
|
+
)
|
|
96
|
+
if extra:
|
|
97
|
+
raise ConfigurationError(
|
|
98
|
+
f"{path} handoff_to cannot contain other handler fields"
|
|
99
|
+
)
|
|
100
|
+
return HandlerDefinition(
|
|
101
|
+
_name(mapping, path) if "name" in mapping else "start-workflow",
|
|
102
|
+
_description(mapping.get("description"), path),
|
|
103
|
+
operation=WorkflowHandoff(_nonempty_string(mapping, "handoff_to", path)),
|
|
104
|
+
agent=_optional_agent(mapping, "agent", path),
|
|
105
|
+
model=_optional_string(mapping, "model", path),
|
|
106
|
+
reasoning=_optional_string(mapping, "reasoning", path),
|
|
107
|
+
)
|
|
108
|
+
if inline and "name" not in mapping:
|
|
109
|
+
if "argv" in mapping:
|
|
110
|
+
name = "inline-argv"
|
|
111
|
+
elif "shell" in mapping:
|
|
112
|
+
name = "inline-shell"
|
|
113
|
+
elif "mcp" in mapping and isinstance(mapping["mcp"], str):
|
|
114
|
+
name = f"mcp-{mapping['mcp']}"
|
|
115
|
+
else:
|
|
116
|
+
raise ConfigurationError(f"{path}.name is required for this handler")
|
|
117
|
+
else:
|
|
118
|
+
name = _name(mapping, path)
|
|
119
|
+
description = _description(mapping.get("description"), f"handler {name!r}")
|
|
120
|
+
if "action" in mapping:
|
|
121
|
+
shorthand_keys = {
|
|
122
|
+
"kind",
|
|
123
|
+
"mcp",
|
|
124
|
+
"argv",
|
|
125
|
+
"shell",
|
|
126
|
+
"args",
|
|
127
|
+
"env",
|
|
128
|
+
"assert",
|
|
129
|
+
"idempotent",
|
|
130
|
+
}
|
|
131
|
+
if shorthand_keys & set(mapping):
|
|
132
|
+
raise ConfigurationError(
|
|
133
|
+
f"{path} cannot combine action with shorthand fields"
|
|
134
|
+
)
|
|
135
|
+
raw_action = _mapping(mapping["action"], f"{path}.action")
|
|
136
|
+
identifier = _nonempty_string(raw_action, "type", f"{path}.action")
|
|
137
|
+
source = {key: value for key, value in raw_action.items() if key != "type"}
|
|
138
|
+
if identifier in CORE_ACTION_TYPES:
|
|
139
|
+
# Core controls are engine behaviour with their own keys, not
|
|
140
|
+
# registry actions selected by type.
|
|
141
|
+
raise ConfigurationError(
|
|
142
|
+
f"{path}.action.type {identifier!r} is a core control; use "
|
|
143
|
+
"`handoff_to`, `children`, or `loop` on the step instead"
|
|
144
|
+
)
|
|
145
|
+
implementation = actions.get(identifier)
|
|
146
|
+
payload = implementation.parse(source, name, description, f"{path}.action")
|
|
147
|
+
implementation.validate(payload, f"{path}.action")
|
|
148
|
+
action = DefinedAction(identifier, payload)
|
|
149
|
+
return HandlerDefinition(
|
|
150
|
+
name=name,
|
|
151
|
+
description=description,
|
|
152
|
+
action=action,
|
|
153
|
+
**_failure_policy(mapping, path),
|
|
154
|
+
**handler_values(mapping, f"handler {name!r}"),
|
|
155
|
+
agent=_optional_agent(mapping, "agent", f"handler {name!r}"),
|
|
156
|
+
model=_optional_string(mapping, "model", f"handler {name!r}"),
|
|
157
|
+
reasoning=_optional_string(mapping, "reasoning", f"handler {name!r}"),
|
|
158
|
+
workdir=_optional_workdir(mapping, f"handler {name!r}"),
|
|
159
|
+
)
|
|
160
|
+
explicit: list[RequestedActionKind] = []
|
|
161
|
+
if "kind" in mapping:
|
|
162
|
+
kind = mapping["kind"]
|
|
163
|
+
if kind not in ACTION_KINDS:
|
|
164
|
+
raise ConfigurationError(
|
|
165
|
+
f"handler {name!r} kind must be one of: " + ", ".join(ACTION_KINDS)
|
|
166
|
+
)
|
|
167
|
+
explicit.append(cast(RequestedActionKind, kind))
|
|
168
|
+
mcp_value = mapping.get("mcp")
|
|
169
|
+
has_command = bool({"argv", "shell"} & set(mapping))
|
|
170
|
+
if {"args", "env", "assert", "idempotent"} & set(mapping) and not has_command:
|
|
171
|
+
actions.get("cli").parse(mapping, name, description, f"handler {name!r}")
|
|
172
|
+
if has_command and explicit:
|
|
173
|
+
raise ConfigurationError(f"handler {name!r} cannot combine a command and kind")
|
|
174
|
+
if mcp_value is not None:
|
|
175
|
+
if not isinstance(mcp_value, str) or not mcp_value.strip():
|
|
176
|
+
raise ConfigurationError(f"handler {name!r} mcp must be non-empty")
|
|
177
|
+
if has_command or explicit:
|
|
178
|
+
raise ConfigurationError(
|
|
179
|
+
f"handler {name!r} cannot combine mcp with another action"
|
|
180
|
+
)
|
|
181
|
+
requested_kind: RequestedActionKind | None = "mcp"
|
|
182
|
+
else:
|
|
183
|
+
requested_kind = explicit[0] if explicit else None
|
|
184
|
+
action_kind = (
|
|
185
|
+
"cli" if has_command else "mcp" if mcp_value is not None else requested_kind
|
|
186
|
+
)
|
|
187
|
+
typed_action = None
|
|
188
|
+
if action_kind is not None:
|
|
189
|
+
implementation = actions.get(action_kind)
|
|
190
|
+
payload = implementation.parse(mapping, name, description, path)
|
|
191
|
+
implementation.validate(payload, path)
|
|
192
|
+
typed_action = DefinedAction(action_kind, payload)
|
|
193
|
+
return HandlerDefinition(
|
|
194
|
+
name=name,
|
|
195
|
+
description=description,
|
|
196
|
+
action=typed_action,
|
|
197
|
+
**_failure_policy(mapping, path),
|
|
198
|
+
**handler_values(mapping, f"handler {name!r}"),
|
|
199
|
+
agent=_optional_agent(mapping, "agent", f"handler {name!r}"),
|
|
200
|
+
model=_optional_string(mapping, "model", f"handler {name!r}"),
|
|
201
|
+
reasoning=_optional_string(mapping, "reasoning", f"handler {name!r}"),
|
|
202
|
+
workdir=_optional_workdir(mapping, f"handler {name!r}"),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_automatic_group(
|
|
207
|
+
mapping: dict[str, Any], path: str, *, inline: bool
|
|
208
|
+
) -> HandlerDefinition:
|
|
209
|
+
allowed = {
|
|
210
|
+
"name",
|
|
211
|
+
"description",
|
|
212
|
+
"handlers",
|
|
213
|
+
"workdir",
|
|
214
|
+
"agent",
|
|
215
|
+
"model",
|
|
216
|
+
"reasoning",
|
|
217
|
+
"on_failure",
|
|
218
|
+
"on_failure_instruction",
|
|
219
|
+
"handler",
|
|
220
|
+
"hooks",
|
|
221
|
+
"profile",
|
|
222
|
+
"subagents",
|
|
223
|
+
"artifact",
|
|
224
|
+
}
|
|
225
|
+
extra = set(mapping) - allowed
|
|
226
|
+
if extra:
|
|
227
|
+
raise ConfigurationError(
|
|
228
|
+
f"{path}.handlers cannot combine with: " + ", ".join(sorted(extra))
|
|
229
|
+
)
|
|
230
|
+
entries = mapping["handlers"]
|
|
231
|
+
if not isinstance(entries, list) or not entries:
|
|
232
|
+
raise ConfigurationError(f"{path}.handlers must be a non-empty list")
|
|
233
|
+
members = tuple(
|
|
234
|
+
_parse_hook_handler(entry, f"{path}.handlers[{index}]")
|
|
235
|
+
for index, entry in enumerate(entries)
|
|
236
|
+
)
|
|
237
|
+
return HandlerDefinition(
|
|
238
|
+
_name(mapping, path) if "name" in mapping else "inline-handlers",
|
|
239
|
+
description=_description(mapping.get("description"), path),
|
|
240
|
+
handlers=members,
|
|
241
|
+
workdir=_optional_workdir(mapping, path),
|
|
242
|
+
agent=_optional_agent(mapping, "agent", path),
|
|
243
|
+
model=_optional_string(mapping, "model", path),
|
|
244
|
+
reasoning=_optional_string(mapping, "reasoning", path),
|
|
245
|
+
**_failure_policy(mapping, path),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _failure_policy(mapping: dict[str, Any], path: str) -> dict[str, Any]:
|
|
250
|
+
return {
|
|
251
|
+
"on_failure": _on_failure(mapping, path, "operator")
|
|
252
|
+
if "on_failure" in mapping
|
|
253
|
+
else None,
|
|
254
|
+
"on_failure_instruction": _optional_string(
|
|
255
|
+
mapping, "on_failure_instruction", path
|
|
256
|
+
),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _optional_workdir(mapping: dict[str, Any], path: str) -> Workdir | None:
|
|
261
|
+
if "workdir" not in mapping:
|
|
262
|
+
return None
|
|
263
|
+
value = mapping["workdir"]
|
|
264
|
+
if value not in WORKDIRS:
|
|
265
|
+
raise ConfigurationError(
|
|
266
|
+
f"{path}.workdir must be one of: {', '.join(WORKDIRS)}"
|
|
267
|
+
)
|
|
268
|
+
return cast(Workdir, value)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _handler_keys() -> set[str]:
|
|
272
|
+
return {
|
|
273
|
+
"name",
|
|
274
|
+
"description",
|
|
275
|
+
"handlers",
|
|
276
|
+
"kind",
|
|
277
|
+
"mcp",
|
|
278
|
+
"argv",
|
|
279
|
+
"shell",
|
|
280
|
+
"args",
|
|
281
|
+
"env",
|
|
282
|
+
"assert",
|
|
283
|
+
"idempotent",
|
|
284
|
+
"on_failure",
|
|
285
|
+
"on_failure_instruction",
|
|
286
|
+
"agent",
|
|
287
|
+
"variables",
|
|
288
|
+
"saves",
|
|
289
|
+
"model",
|
|
290
|
+
"reasoning",
|
|
291
|
+
"action",
|
|
292
|
+
"workdir",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _parse_hooks(data: Any, scope: HookScope, path: str) -> tuple[HookDefinition, ...]:
|
|
297
|
+
if data is None:
|
|
298
|
+
return ()
|
|
299
|
+
mapping = _mapping(data, path)
|
|
300
|
+
_only(mapping, set(HOOK_PHASES), path)
|
|
301
|
+
result: list[HookDefinition] = []
|
|
302
|
+
for phase in HOOK_PHASES:
|
|
303
|
+
entries = mapping.get(phase, [])
|
|
304
|
+
if not isinstance(entries, list):
|
|
305
|
+
raise ConfigurationError(f"{path}.{phase} must be a list")
|
|
306
|
+
for index, entry in enumerate(entries):
|
|
307
|
+
result.extend(_parse_hook(entry, phase, scope, f"{path}.{phase}[{index}]"))
|
|
308
|
+
return tuple(result)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
HOOK_FAILURES: tuple[HookFailure, ...] = ("fix", "operator")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _parse_hook(
|
|
315
|
+
data: Any, phase: HookPhase, scope: HookScope, path: str
|
|
316
|
+
) -> tuple[HookDefinition, ...]:
|
|
317
|
+
mapping = _mapping(data, path)
|
|
318
|
+
allowed = _handler_keys() | {"handlers", "handoff_to", "on_failure"}
|
|
319
|
+
if scope in {"global", "workflow"} and phase not in {
|
|
320
|
+
"before_start_workflow",
|
|
321
|
+
"before_complete_workflow",
|
|
322
|
+
}:
|
|
323
|
+
allowed.add("steps")
|
|
324
|
+
if scope == "global":
|
|
325
|
+
allowed.add("workflows")
|
|
326
|
+
mapping = _named_entry(
|
|
327
|
+
mapping,
|
|
328
|
+
path,
|
|
329
|
+
allowed=allowed,
|
|
330
|
+
ignored={"workflows", "steps", "on_failure", "on_failure_instruction"},
|
|
331
|
+
)
|
|
332
|
+
_only(mapping, allowed, path)
|
|
333
|
+
on_failure = _on_failure(mapping, path, "operator")
|
|
334
|
+
_optional_string(mapping, "on_failure_instruction", path)
|
|
335
|
+
if "handlers" in mapping:
|
|
336
|
+
action_keys = set(mapping) & (
|
|
337
|
+
(_handler_keys() - {"handlers", "on_failure", "on_failure_instruction"})
|
|
338
|
+
| {"handoff_to"}
|
|
339
|
+
)
|
|
340
|
+
if action_keys:
|
|
341
|
+
raise ConfigurationError(
|
|
342
|
+
f"{path} cannot combine handlers with handler key(s): "
|
|
343
|
+
+ ", ".join(sorted(action_keys))
|
|
344
|
+
)
|
|
345
|
+
raw_handlers = mapping["handlers"]
|
|
346
|
+
if not isinstance(raw_handlers, list) or not raw_handlers:
|
|
347
|
+
raise ConfigurationError(f"{path}.handlers must be a non-empty list")
|
|
348
|
+
references = tuple(
|
|
349
|
+
_parse_hook_member(
|
|
350
|
+
item,
|
|
351
|
+
f"{path}.handlers[{index}]",
|
|
352
|
+
on_failure,
|
|
353
|
+
mapping.get("on_failure_instruction"),
|
|
354
|
+
)
|
|
355
|
+
for index, item in enumerate(raw_handlers)
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
action = {
|
|
359
|
+
key: value
|
|
360
|
+
for key, value in mapping.items()
|
|
361
|
+
if key not in {"workflows", "steps", "on_failure"}
|
|
362
|
+
}
|
|
363
|
+
if not action:
|
|
364
|
+
raise ConfigurationError(f"{path} requires a handler action")
|
|
365
|
+
references = ((_parse_hook_handler(action, path), on_failure),)
|
|
366
|
+
|
|
367
|
+
workflows = _name_filter(
|
|
368
|
+
mapping.get("workflows", ALL_NAMES), f"{path}.workflows", empty=ALL
|
|
369
|
+
)
|
|
370
|
+
steps = _name_filter(mapping.get("steps", ALL_NAMES), f"{path}.steps", empty=ALL)
|
|
371
|
+
return tuple(
|
|
372
|
+
HookDefinition(
|
|
373
|
+
phase=phase,
|
|
374
|
+
handler=reference,
|
|
375
|
+
workflows=workflows,
|
|
376
|
+
steps=steps,
|
|
377
|
+
scope=scope,
|
|
378
|
+
path=path,
|
|
379
|
+
on_failure=failure,
|
|
380
|
+
)
|
|
381
|
+
for reference, failure in references
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _on_failure(
|
|
386
|
+
mapping: dict[str, Any], path: str, default: HookFailure
|
|
387
|
+
) -> HookFailure:
|
|
388
|
+
value = mapping.get("on_failure", default)
|
|
389
|
+
if value not in HOOK_FAILURES:
|
|
390
|
+
raise ConfigurationError(
|
|
391
|
+
f"{path}.on_failure must be one of: " + ", ".join(HOOK_FAILURES)
|
|
392
|
+
)
|
|
393
|
+
return cast(HookFailure, value)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _parse_hook_member(
|
|
397
|
+
data: Any,
|
|
398
|
+
path: str,
|
|
399
|
+
group_failure: HookFailure,
|
|
400
|
+
group_instruction: str | None = None,
|
|
401
|
+
) -> tuple[HandlerDefinition, HookFailure]:
|
|
402
|
+
"""One member of a hook's ``handlers`` list and its own ``on_failure``."""
|
|
403
|
+
mapping = _named_entry(
|
|
404
|
+
_mapping(data, path),
|
|
405
|
+
path,
|
|
406
|
+
allowed=_handler_keys() | {"handoff_to", "on_failure"},
|
|
407
|
+
ignored={"on_failure"},
|
|
408
|
+
)
|
|
409
|
+
failure = _on_failure(mapping, path, group_failure)
|
|
410
|
+
handler = {key: value for key, value in mapping.items() if key != "on_failure"}
|
|
411
|
+
if "on_failure_instruction" not in handler and group_instruction is not None:
|
|
412
|
+
handler["on_failure_instruction"] = group_instruction
|
|
413
|
+
return _parse_hook_handler(handler, path), failure
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _parse_hook_handler(data: Any, path: str) -> HandlerDefinition:
|
|
417
|
+
mapping = _named_entry(
|
|
418
|
+
_mapping(data, path),
|
|
419
|
+
path,
|
|
420
|
+
allowed=_handler_keys() | {"handoff_to"},
|
|
421
|
+
)
|
|
422
|
+
if _bare_extension_reference(mapping):
|
|
423
|
+
return extension_reference(mapping, path)
|
|
424
|
+
return _parse_handler(mapping, path, inline=True)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _bare_extension_reference(mapping: dict[str, Any]) -> bool:
|
|
428
|
+
"""Whether ``mapping`` names an extension handler as is.
|
|
429
|
+
|
|
430
|
+
Such an entry carries at most the directory it works in and the
|
|
431
|
+
arguments it runs with; everything else about the handler is the
|
|
432
|
+
extension's to define.
|
|
433
|
+
"""
|
|
434
|
+
name = mapping.get("name")
|
|
435
|
+
return (
|
|
436
|
+
isinstance(name, str)
|
|
437
|
+
and is_extension_reference(name)
|
|
438
|
+
and set(mapping) <= {"name", "workdir", "args"}
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def extension_reference(mapping: dict[str, Any], path: str) -> HandlerDefinition:
|
|
443
|
+
"""A bare reference to an extension handler, with its ``args``."""
|
|
444
|
+
arguments = mapping.get("args", [])
|
|
445
|
+
if not isinstance(arguments, list) or not all(
|
|
446
|
+
isinstance(argument, str) for argument in arguments
|
|
447
|
+
):
|
|
448
|
+
raise ConfigurationError(f"{path}.args must be a list of strings")
|
|
449
|
+
return HandlerDefinition(
|
|
450
|
+
mapping["name"],
|
|
451
|
+
workdir=_optional_workdir(mapping, path),
|
|
452
|
+
extension_arguments=tuple(arguments),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def handler_values(mapping: dict[str, Any], path: str) -> dict[str, Any]:
|
|
457
|
+
"""The ``variables`` and ``saves`` of a handler, as its definition fields."""
|
|
458
|
+
provide, outputs = parse_variables(mapping.get("variables", []), path)
|
|
459
|
+
save_metadata, update_document, update_item = parse_saves(
|
|
460
|
+
mapping.get("saves", []), path
|
|
461
|
+
)
|
|
462
|
+
return {
|
|
463
|
+
"provide": provide,
|
|
464
|
+
"outputs": outputs,
|
|
465
|
+
"save_metadata": save_metadata,
|
|
466
|
+
"update_document": update_document,
|
|
467
|
+
"update_item": update_item,
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def parse_variables(
|
|
472
|
+
data: Any, path: str
|
|
473
|
+
) -> tuple[tuple[ProvidedVariable, ...], tuple[str, ...]]:
|
|
474
|
+
"""Parse ``variables``: what the step hands back, read as ``{{name}}``.
|
|
475
|
+
|
|
476
|
+
``- name: description`` is a value the performer supplies with
|
|
477
|
+
``--variable``; a bare ``- name`` is one an automatic action returns
|
|
478
|
+
itself. A name may not start with ``ww`` (or ``__``): those are ww's.
|
|
479
|
+
"""
|
|
480
|
+
if data is None:
|
|
481
|
+
return (), ()
|
|
482
|
+
if not isinstance(data, list):
|
|
483
|
+
raise ConfigurationError(f"{path}.variables must be a list")
|
|
484
|
+
provided: list[ProvidedVariable] = []
|
|
485
|
+
returned: list[str] = []
|
|
486
|
+
for index, item in enumerate(data):
|
|
487
|
+
item_path = f"{path}.variables[{index}]"
|
|
488
|
+
if isinstance(item, str):
|
|
489
|
+
name = item
|
|
490
|
+
if not _VARIABLE_NAME.fullmatch(name):
|
|
491
|
+
raise ConfigurationError(
|
|
492
|
+
f"{item_path} must be a normalized variable name"
|
|
493
|
+
)
|
|
494
|
+
else:
|
|
495
|
+
mapping = _named_entry(_mapping(item, item_path), item_path)
|
|
496
|
+
_only(mapping, {"name", "description"}, item_path)
|
|
497
|
+
name = _name(mapping, item_path)
|
|
498
|
+
if is_reserved_name(name):
|
|
499
|
+
raise ConfigurationError(
|
|
500
|
+
f"{path}.variables name {name!r} is reserved: names starting "
|
|
501
|
+
"with ww are ww's own values"
|
|
502
|
+
)
|
|
503
|
+
if isinstance(item, str):
|
|
504
|
+
returned.append(name)
|
|
505
|
+
else:
|
|
506
|
+
provided.append(
|
|
507
|
+
ProvidedVariable(
|
|
508
|
+
name, _description(mapping.get("description"), item_path)
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
_unique((*(item.name for item in provided), *returned), f"variable in {path}")
|
|
512
|
+
return tuple(provided), tuple(returned)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def parse_saves(
|
|
516
|
+
data: Any, path: str
|
|
517
|
+
) -> tuple[
|
|
518
|
+
tuple[SavedMetadata, ...], tuple[DocumentUpdate, ...], tuple[ItemFieldUpdate, ...]
|
|
519
|
+
]:
|
|
520
|
+
"""Parse ``saves``: what the step writes to metadata, documents, or its item.
|
|
521
|
+
|
|
522
|
+
Each entry names a prefixed path, ``metadata.<path>``,
|
|
523
|
+
``project_metadata.<path>``, ``documents.<name>`` or
|
|
524
|
+
``item.field.<name>``, with the text saying what to put there; a
|
|
525
|
+
metadata entry may add ``append: true``.
|
|
526
|
+
"""
|
|
527
|
+
if data is None:
|
|
528
|
+
return (), (), ()
|
|
529
|
+
if not isinstance(data, list):
|
|
530
|
+
raise ConfigurationError(f"{path}.saves must be a list")
|
|
531
|
+
metadata: list[SavedMetadata] = []
|
|
532
|
+
documents: list[DocumentUpdate] = []
|
|
533
|
+
fields: list[ItemFieldUpdate] = []
|
|
534
|
+
for index, item in enumerate(data):
|
|
535
|
+
item_path = f"{path}.saves[{index}]"
|
|
536
|
+
mapping = _named_entry(_mapping(item, item_path), item_path)
|
|
537
|
+
_only(mapping, {"name", "description", "append"}, item_path)
|
|
538
|
+
target = mapping.get("name")
|
|
539
|
+
if not isinstance(target, str) or not target.startswith(_SAVE_PREFIXES):
|
|
540
|
+
hint = (
|
|
541
|
+
"; saves take no ww. prefix"
|
|
542
|
+
if isinstance(target, str) and target.startswith("ww.")
|
|
543
|
+
else ""
|
|
544
|
+
)
|
|
545
|
+
raise ConfigurationError(
|
|
546
|
+
f"{item_path} must name metadata.<path>, project_metadata.<path>, "
|
|
547
|
+
f"documents.<name>, or item.field.<name>{hint}"
|
|
548
|
+
)
|
|
549
|
+
description = _description(mapping.get("description"), item_path)
|
|
550
|
+
append = _optional_bool(mapping, "append", item_path)
|
|
551
|
+
prefix = next(prefix for prefix in _SAVE_PREFIXES if target.startswith(prefix))
|
|
552
|
+
rest = target.removeprefix(prefix)
|
|
553
|
+
if append is not None and prefix not in {"metadata.", "project_metadata."}:
|
|
554
|
+
raise ConfigurationError(f"{item_path}.append applies to metadata only")
|
|
555
|
+
try:
|
|
556
|
+
if prefix in {"metadata.", "project_metadata."}:
|
|
557
|
+
if not _METADATA_PATH.fullmatch(rest):
|
|
558
|
+
raise ConfigurationError(
|
|
559
|
+
f"{item_path} must name a dotted metadata path"
|
|
560
|
+
)
|
|
561
|
+
project = prefix == "project_metadata."
|
|
562
|
+
metadata.append(
|
|
563
|
+
SavedMetadata(
|
|
564
|
+
target if project else rest,
|
|
565
|
+
rest,
|
|
566
|
+
description,
|
|
567
|
+
"project" if project else "task",
|
|
568
|
+
append=append or False,
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
elif prefix == "documents.":
|
|
572
|
+
documents.append(DocumentUpdate(rest, description))
|
|
573
|
+
else:
|
|
574
|
+
fields.append(ItemFieldUpdate(rest, description))
|
|
575
|
+
except ValueError as error:
|
|
576
|
+
raise ConfigurationError(f"{item_path}: {error}") from error
|
|
577
|
+
_unique((item.name for item in metadata), f"saved metadata path in {path}")
|
|
578
|
+
_unique((item.name for item in documents), f"document in {path}")
|
|
579
|
+
_unique((item.name for item in fields), f"item field in {path}")
|
|
580
|
+
for scope in ("task", "project"):
|
|
581
|
+
keys = [item.key for item in metadata if item.scope == scope]
|
|
582
|
+
for index, key in enumerate(keys):
|
|
583
|
+
if any(
|
|
584
|
+
key.startswith(f"{other}.") or other.startswith(f"{key}.")
|
|
585
|
+
for other in keys[index + 1 :]
|
|
586
|
+
):
|
|
587
|
+
raise ConfigurationError(
|
|
588
|
+
f"saved metadata paths in {path} cannot overlap within "
|
|
589
|
+
f"the {scope} scope"
|
|
590
|
+
)
|
|
591
|
+
return tuple(metadata), tuple(documents), tuple(fields)
|