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/actions/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Internal registry and public imports for workflow action contracts."""
|
|
3
|
+
|
|
4
|
+
from .command import CommandAction
|
|
5
|
+
from .contracts import (
|
|
6
|
+
Action,
|
|
7
|
+
ActionPayload,
|
|
8
|
+
ActionRegistry,
|
|
9
|
+
ActionResult,
|
|
10
|
+
ActionTraits,
|
|
11
|
+
AssertionCondition,
|
|
12
|
+
AssertionDefinition,
|
|
13
|
+
AttestationField,
|
|
14
|
+
AutomaticAction,
|
|
15
|
+
CommandDefinition,
|
|
16
|
+
CommandOutcome,
|
|
17
|
+
CommandRequest,
|
|
18
|
+
Commands,
|
|
19
|
+
CommandService,
|
|
20
|
+
DefinedAction,
|
|
21
|
+
DefinitionOverrideContext,
|
|
22
|
+
ExecutionContext,
|
|
23
|
+
Extension,
|
|
24
|
+
ExtensionBinding,
|
|
25
|
+
ExtensionIdentityService,
|
|
26
|
+
ExtensionService,
|
|
27
|
+
InstructionContent,
|
|
28
|
+
InstructionContext,
|
|
29
|
+
Mcp,
|
|
30
|
+
PlannedAction,
|
|
31
|
+
PreflightContext,
|
|
32
|
+
Prompt,
|
|
33
|
+
RecoveryCheckResult,
|
|
34
|
+
RecoveryContext,
|
|
35
|
+
RecoveryExtensionService,
|
|
36
|
+
ResolutionContext,
|
|
37
|
+
Skill,
|
|
38
|
+
SlashCommand,
|
|
39
|
+
actions,
|
|
40
|
+
)
|
|
41
|
+
from .extension import ExtensionAction
|
|
42
|
+
from .mcp import McpAction
|
|
43
|
+
from .prompt import PromptAction
|
|
44
|
+
from .skill import SkillAction
|
|
45
|
+
from .slash_command import SlashCommandAction
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Action",
|
|
49
|
+
"ActionResult",
|
|
50
|
+
"ActionTraits",
|
|
51
|
+
"AttestationField",
|
|
52
|
+
"AutomaticAction",
|
|
53
|
+
"ActionPayload",
|
|
54
|
+
"ActionRegistry",
|
|
55
|
+
"CommandAction",
|
|
56
|
+
"CommandOutcome",
|
|
57
|
+
"CommandRequest",
|
|
58
|
+
"CommandService",
|
|
59
|
+
"AssertionCondition",
|
|
60
|
+
"AssertionDefinition",
|
|
61
|
+
"CommandDefinition",
|
|
62
|
+
"Commands",
|
|
63
|
+
"ExecutionContext",
|
|
64
|
+
"DefinedAction",
|
|
65
|
+
"DefinitionOverrideContext",
|
|
66
|
+
"Extension",
|
|
67
|
+
"ExtensionBinding",
|
|
68
|
+
"ExtensionService",
|
|
69
|
+
"ExtensionIdentityService",
|
|
70
|
+
"ExtensionAction",
|
|
71
|
+
"InstructionContent",
|
|
72
|
+
"InstructionContext",
|
|
73
|
+
"Mcp",
|
|
74
|
+
"PlannedAction",
|
|
75
|
+
"PreflightContext",
|
|
76
|
+
"Prompt",
|
|
77
|
+
"ResolutionContext",
|
|
78
|
+
"RecoveryCheckResult",
|
|
79
|
+
"RecoveryContext",
|
|
80
|
+
"RecoveryExtensionService",
|
|
81
|
+
"Skill",
|
|
82
|
+
"SlashCommand",
|
|
83
|
+
"actions",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
for _action in (
|
|
87
|
+
PromptAction(),
|
|
88
|
+
SkillAction(),
|
|
89
|
+
SlashCommandAction(),
|
|
90
|
+
McpAction(),
|
|
91
|
+
CommandAction(),
|
|
92
|
+
ExtensionAction(),
|
|
93
|
+
):
|
|
94
|
+
actions.register(_action)
|
ww/actions/command.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Command action parsing, planning, rendering, and execution effects."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ww.errors import ConfigurationError, StateError
|
|
12
|
+
from ww.interpolation import dependencies, interpolate
|
|
13
|
+
from ww.validation import (
|
|
14
|
+
expect_bool,
|
|
15
|
+
expect_mapping,
|
|
16
|
+
expect_nonempty_string,
|
|
17
|
+
expect_string,
|
|
18
|
+
reject_unknown_keys,
|
|
19
|
+
)
|
|
20
|
+
from ww.variables import item_context_error
|
|
21
|
+
|
|
22
|
+
from .contracts import (
|
|
23
|
+
ActionResult,
|
|
24
|
+
ActionTraits,
|
|
25
|
+
AssertionCondition,
|
|
26
|
+
AssertionDefinition,
|
|
27
|
+
AutomaticAction,
|
|
28
|
+
CommandDefinition,
|
|
29
|
+
CommandOutcome,
|
|
30
|
+
CommandRequest,
|
|
31
|
+
Commands,
|
|
32
|
+
ExecutionContext,
|
|
33
|
+
InstructionContent,
|
|
34
|
+
InstructionContext,
|
|
35
|
+
ResolutionContext,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# An environment variable name, e.g. "WW_TASK_ID"; "2FA" does not match.
|
|
39
|
+
_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _failure_message(
|
|
43
|
+
rendered: tuple[str, ...], index: int, outcome: CommandOutcome
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Say what failed and show what it printed, on whichever stream it used.
|
|
46
|
+
|
|
47
|
+
Test runners, linters, and type checkers overwhelmingly print their
|
|
48
|
+
diagnostics to stdout, so reporting stderr alone would leave the operator,
|
|
49
|
+
who has to choose between retrying and forcing past a failure, with no
|
|
50
|
+
basis for the decision.
|
|
51
|
+
"""
|
|
52
|
+
detail = outcome.stderr.strip() or outcome.stdout.strip()
|
|
53
|
+
command = shlex.join(rendered) if rendered else ""
|
|
54
|
+
heading = f"automatic handler failed ({outcome.exit_code})"
|
|
55
|
+
if command:
|
|
56
|
+
heading += f" running: {command}"
|
|
57
|
+
elif index:
|
|
58
|
+
heading += f" at command {index + 1}"
|
|
59
|
+
if not detail:
|
|
60
|
+
return f"{heading}; it printed nothing. See the command output artifact."
|
|
61
|
+
return f"{heading}\n\n{_tail(detail)}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tail(detail: str, limit: int = 40) -> str:
|
|
65
|
+
"""Keep the end of the output, where a failure normally explains itself."""
|
|
66
|
+
lines = detail.splitlines()
|
|
67
|
+
if len(lines) <= limit:
|
|
68
|
+
return detail
|
|
69
|
+
dropped = len(lines) - limit
|
|
70
|
+
return "\n".join(
|
|
71
|
+
[f"[{dropped} earlier line(s) omitted; the full output is an artifact]"]
|
|
72
|
+
+ lines[-limit:]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CommandAction(AutomaticAction[Commands, Commands]):
|
|
77
|
+
identifier = "cli"
|
|
78
|
+
planned_type = Commands
|
|
79
|
+
|
|
80
|
+
def execute(self, planned: Commands, context: ExecutionContext) -> ActionResult:
|
|
81
|
+
"""Run the command sequence while the command service records each effect."""
|
|
82
|
+
outputs: list[str] = []
|
|
83
|
+
for index, command in enumerate(planned.commands):
|
|
84
|
+
# Reading completed output first is intentional: inputs may have
|
|
85
|
+
# changed after interruption, but a completed external operation is
|
|
86
|
+
# never re-rendered or replayed merely to aggregate its output.
|
|
87
|
+
outcome = context.commands.completed(index)
|
|
88
|
+
rendered_for_report: tuple[str, ...] = ()
|
|
89
|
+
if outcome is None:
|
|
90
|
+
try:
|
|
91
|
+
rendered, environment = self.render_command(
|
|
92
|
+
command, context.runtime_values
|
|
93
|
+
)
|
|
94
|
+
except StateError as error:
|
|
95
|
+
return ActionResult.failed(str(error))
|
|
96
|
+
rendered_for_report = tuple(rendered)
|
|
97
|
+
outcome = context.commands.execute(
|
|
98
|
+
index, CommandRequest(tuple(rendered), environment)
|
|
99
|
+
)
|
|
100
|
+
if not outcome.ok:
|
|
101
|
+
if outcome.launch_error is not None:
|
|
102
|
+
return ActionResult.failed(
|
|
103
|
+
f"automatic handler could not launch command {index + 1}: "
|
|
104
|
+
f"{outcome.launch_error}"
|
|
105
|
+
)
|
|
106
|
+
return ActionResult.failed(
|
|
107
|
+
_failure_message(rendered_for_report, index, outcome)
|
|
108
|
+
)
|
|
109
|
+
outputs.append(outcome.stdout)
|
|
110
|
+
output = "\n".join(part for part in outputs if part).strip()
|
|
111
|
+
if planned.assertion and not planned.assertion.holds(output):
|
|
112
|
+
expected = " and ".join(
|
|
113
|
+
"no output" if condition.value is None else repr(condition.value)
|
|
114
|
+
for condition in planned.assertion.conditions
|
|
115
|
+
)
|
|
116
|
+
return ActionResult.failed(
|
|
117
|
+
f"automatic handler assertion failed: expected {expected}, "
|
|
118
|
+
f"got {output!r}",
|
|
119
|
+
output=output,
|
|
120
|
+
)
|
|
121
|
+
return ActionResult.succeeded(output)
|
|
122
|
+
|
|
123
|
+
def traits(self, planned: Commands) -> ActionTraits:
|
|
124
|
+
return ActionTraits(
|
|
125
|
+
attests_output=planned.assertion is not None,
|
|
126
|
+
command_segments=planned.commands,
|
|
127
|
+
idempotent=planned.idempotent,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def parse(
|
|
131
|
+
self, source: dict[str, Any], name: str, description: str, path: str
|
|
132
|
+
) -> Commands:
|
|
133
|
+
commands, assertion = _parse_command(source, path)
|
|
134
|
+
if not commands:
|
|
135
|
+
raise ConfigurationError(f"{path} requires argv or shell")
|
|
136
|
+
return Commands(commands, assertion, _parse_idempotent(source, path))
|
|
137
|
+
|
|
138
|
+
def validate(self, definition: Commands, path: str) -> None:
|
|
139
|
+
if not definition.commands:
|
|
140
|
+
raise ConfigurationError(f"{path} command requires at least one command")
|
|
141
|
+
|
|
142
|
+
def templates(self, definition: Commands) -> tuple[str, ...]:
|
|
143
|
+
return tuple(
|
|
144
|
+
template
|
|
145
|
+
for command in definition.commands
|
|
146
|
+
for template in command.templates
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def render_command(
|
|
150
|
+
self, command: CommandDefinition, values: Mapping[str, str]
|
|
151
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
152
|
+
missing = {
|
|
153
|
+
name
|
|
154
|
+
for template in command.templates
|
|
155
|
+
for name in dependencies(template)
|
|
156
|
+
if name not in values
|
|
157
|
+
}
|
|
158
|
+
context_error = item_context_error(missing, values)
|
|
159
|
+
if context_error is not None:
|
|
160
|
+
raise StateError(context_error)
|
|
161
|
+
if missing:
|
|
162
|
+
raise StateError(
|
|
163
|
+
"automatic handler is missing variable(s): "
|
|
164
|
+
+ ", ".join(sorted(missing))
|
|
165
|
+
)
|
|
166
|
+
environment = {
|
|
167
|
+
name: interpolate(template, values) for name, template in command.env
|
|
168
|
+
}
|
|
169
|
+
if command.shell is not None:
|
|
170
|
+
if dependencies(command.shell):
|
|
171
|
+
raise StateError(
|
|
172
|
+
"saved shell source contains interpolation; use shell args or env"
|
|
173
|
+
)
|
|
174
|
+
return [
|
|
175
|
+
"/bin/sh",
|
|
176
|
+
"-c",
|
|
177
|
+
command.shell,
|
|
178
|
+
"ww-command",
|
|
179
|
+
*(interpolate(argument, values) for argument in command.args),
|
|
180
|
+
], environment
|
|
181
|
+
return [interpolate(argument, values) for argument in command.argv], environment
|
|
182
|
+
|
|
183
|
+
def plan(self, definition: Commands, context: ResolutionContext) -> Commands:
|
|
184
|
+
commands = tuple(
|
|
185
|
+
CommandDefinition(
|
|
186
|
+
argv=tuple(context.interpolate(value) for value in command.argv),
|
|
187
|
+
shell=command.shell,
|
|
188
|
+
args=tuple(context.interpolate(value) for value in command.args),
|
|
189
|
+
env=tuple(
|
|
190
|
+
(key, context.interpolate(value)) for key, value in command.env
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
for command in definition.commands
|
|
194
|
+
)
|
|
195
|
+
return Commands(commands, definition.assertion, definition.idempotent)
|
|
196
|
+
|
|
197
|
+
def instruction(
|
|
198
|
+
self, planned: Commands, context: InstructionContext
|
|
199
|
+
) -> InstructionContent:
|
|
200
|
+
return InstructionContent(
|
|
201
|
+
context.description or context.name,
|
|
202
|
+
(
|
|
203
|
+
"> **Automated by ww:** shown for information only; do not run "
|
|
204
|
+
"this command yourself.",
|
|
205
|
+
"",
|
|
206
|
+
"**Run**",
|
|
207
|
+
"",
|
|
208
|
+
"```sh",
|
|
209
|
+
*(_display_command(command) for command in planned.commands),
|
|
210
|
+
"```",
|
|
211
|
+
"",
|
|
212
|
+
*(
|
|
213
|
+
(
|
|
214
|
+
"**Recovery**",
|
|
215
|
+
"",
|
|
216
|
+
"Idempotent: after an interruption ww replays it "
|
|
217
|
+
"without asking the operator.",
|
|
218
|
+
"",
|
|
219
|
+
)
|
|
220
|
+
if planned.idempotent
|
|
221
|
+
else ()
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
after_shared=(
|
|
225
|
+
(
|
|
226
|
+
"**Check**",
|
|
227
|
+
"",
|
|
228
|
+
planned.assertion.describe(),
|
|
229
|
+
"",
|
|
230
|
+
)
|
|
231
|
+
if planned.assertion
|
|
232
|
+
else ()
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def encode(self, planned: Commands) -> dict[str, object]:
|
|
237
|
+
data: dict[str, object] = {
|
|
238
|
+
"commands": [command.to_dict() for command in planned.commands],
|
|
239
|
+
"assert": planned.assertion.to_data() if planned.assertion else None,
|
|
240
|
+
}
|
|
241
|
+
# The default is left out so plans saved before the key existed still
|
|
242
|
+
# decode, and a plan that relies on it is refused by a ww without it.
|
|
243
|
+
if planned.idempotent:
|
|
244
|
+
data["idempotent"] = True
|
|
245
|
+
return data
|
|
246
|
+
|
|
247
|
+
def decode(self, data: dict[str, Any]) -> Commands:
|
|
248
|
+
if not {"commands", "assert"} <= set(data):
|
|
249
|
+
raise ValueError(f"action {self.identifier!r} payload has invalid fields")
|
|
250
|
+
idempotent = data.get("idempotent", False)
|
|
251
|
+
if not isinstance(idempotent, bool):
|
|
252
|
+
raise ValueError(f"action {self.identifier!r} idempotent must be a boolean")
|
|
253
|
+
return Commands(
|
|
254
|
+
_commands_from_list(data["commands"], "action"),
|
|
255
|
+
_assertion_from_dict(data["assert"], "action"),
|
|
256
|
+
idempotent,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _command_from_dict(data: Any) -> CommandDefinition:
|
|
261
|
+
if not isinstance(data, dict):
|
|
262
|
+
raise ValueError("plan command must be a mapping")
|
|
263
|
+
if "argv" in data:
|
|
264
|
+
if "shell" in data:
|
|
265
|
+
raise ValueError("plan command cannot be both argv and shell")
|
|
266
|
+
argv = data["argv"]
|
|
267
|
+
if (
|
|
268
|
+
not isinstance(argv, list)
|
|
269
|
+
or not argv
|
|
270
|
+
or not all(isinstance(item, str) for item in argv)
|
|
271
|
+
):
|
|
272
|
+
raise ValueError("plan command argv must be a list of strings")
|
|
273
|
+
return CommandDefinition(argv=tuple(argv))
|
|
274
|
+
script = data.get("shell")
|
|
275
|
+
args = data.get("args", [])
|
|
276
|
+
env = data.get("env", {})
|
|
277
|
+
if (
|
|
278
|
+
not isinstance(script, str)
|
|
279
|
+
or not isinstance(args, list)
|
|
280
|
+
or not all(isinstance(item, str) for item in args)
|
|
281
|
+
or not isinstance(env, dict)
|
|
282
|
+
or not all(
|
|
283
|
+
isinstance(key, str) and isinstance(value, str)
|
|
284
|
+
for key, value in env.items()
|
|
285
|
+
)
|
|
286
|
+
):
|
|
287
|
+
raise ValueError("invalid plan shell command")
|
|
288
|
+
return CommandDefinition(shell=script, args=tuple(args), env=tuple(env.items()))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _assertion_from_dict(value: Any, item_path: str) -> AssertionDefinition | None:
|
|
292
|
+
"""Decode a saved ``assert`` list: ``"empty"`` or ``{"equals": <value>}``."""
|
|
293
|
+
if value is None:
|
|
294
|
+
return None
|
|
295
|
+
if not isinstance(value, list) or not value:
|
|
296
|
+
raise ValueError(f"{item_path}.assert must be a non-empty list or null")
|
|
297
|
+
conditions = []
|
|
298
|
+
for entry in value:
|
|
299
|
+
if entry == "empty":
|
|
300
|
+
conditions.append(AssertionCondition("empty"))
|
|
301
|
+
elif isinstance(entry, dict) and set(entry) == {"equals"}:
|
|
302
|
+
conditions.append(
|
|
303
|
+
AssertionCondition(
|
|
304
|
+
"equals", expect_string(entry["equals"], "assertion value")
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
else:
|
|
308
|
+
raise ValueError(f"{item_path}.assert has an invalid condition")
|
|
309
|
+
return AssertionDefinition(tuple(conditions))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _commands_from_list(value: Any, item_path: str) -> tuple[CommandDefinition, ...]:
|
|
313
|
+
if not isinstance(value, list):
|
|
314
|
+
raise ValueError(f"{item_path}.commands must be a list")
|
|
315
|
+
result = []
|
|
316
|
+
for index, command in enumerate(value):
|
|
317
|
+
try:
|
|
318
|
+
result.append(_command_from_dict(command))
|
|
319
|
+
except ValueError as error:
|
|
320
|
+
raise ValueError(f"{item_path}.commands[{index}]: {error}") from error
|
|
321
|
+
return tuple(result)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parse_assertion(raw: object, path: str) -> AssertionDefinition:
|
|
325
|
+
"""Parse ``assert``: a list of conditions that must all hold.
|
|
326
|
+
|
|
327
|
+
A condition is ``empty`` or ``{equals: <value>}``.
|
|
328
|
+
"""
|
|
329
|
+
context = f"{path}.assert"
|
|
330
|
+
if not isinstance(raw, list) or not raw:
|
|
331
|
+
raise ConfigurationError(
|
|
332
|
+
f"{context} must be a non-empty list of conditions, such as "
|
|
333
|
+
"[empty] or [{equals: clean}]"
|
|
334
|
+
)
|
|
335
|
+
conditions = []
|
|
336
|
+
for index, entry in enumerate(raw):
|
|
337
|
+
entry_path = f"{context}[{index}]"
|
|
338
|
+
if entry == "empty":
|
|
339
|
+
conditions.append(AssertionCondition("empty"))
|
|
340
|
+
continue
|
|
341
|
+
condition = expect_mapping(entry, entry_path, error=ConfigurationError)
|
|
342
|
+
reject_unknown_keys(condition, {"equals"}, entry_path, error=ConfigurationError)
|
|
343
|
+
if "equals" not in condition:
|
|
344
|
+
raise ConfigurationError(f"{entry_path} must be empty or {{equals: ...}}")
|
|
345
|
+
conditions.append(
|
|
346
|
+
AssertionCondition(
|
|
347
|
+
"equals",
|
|
348
|
+
expect_nonempty_string(
|
|
349
|
+
condition["equals"],
|
|
350
|
+
f"{entry_path}.equals",
|
|
351
|
+
error=ConfigurationError,
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
return AssertionDefinition(tuple(conditions))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _parse_command(
|
|
359
|
+
mapping: dict[str, Any], path: str
|
|
360
|
+
) -> tuple[tuple[CommandDefinition, ...], AssertionDefinition | None]:
|
|
361
|
+
action_keys = {"argv", "shell"} & set(mapping)
|
|
362
|
+
if not action_keys:
|
|
363
|
+
if {"args", "env", "assert", "idempotent"} & set(mapping):
|
|
364
|
+
raise ConfigurationError(
|
|
365
|
+
f"{path} args, env, assert, and idempotent require argv or shell"
|
|
366
|
+
)
|
|
367
|
+
return (), None
|
|
368
|
+
if len(action_keys) != 1:
|
|
369
|
+
raise ConfigurationError(f"{path} cannot combine argv and shell")
|
|
370
|
+
action = {
|
|
371
|
+
key: mapping[key] for key in action_keys | ({"args", "env"} & set(mapping))
|
|
372
|
+
}
|
|
373
|
+
assertion = (
|
|
374
|
+
_parse_assertion(mapping["assert"], path)
|
|
375
|
+
if mapping.get("assert") is not None
|
|
376
|
+
else None
|
|
377
|
+
)
|
|
378
|
+
return (_parse_command_action(action, path),), assertion
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _parse_idempotent(mapping: Mapping[str, Any], path: str) -> bool:
|
|
382
|
+
"""Read the handler-level replay declaration; it defaults to ``False``."""
|
|
383
|
+
if "idempotent" not in mapping:
|
|
384
|
+
return False
|
|
385
|
+
return expect_bool(
|
|
386
|
+
mapping["idempotent"], f"{path}.idempotent", error=ConfigurationError
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _parse_command_action(value: dict[Any, Any], path: str) -> CommandDefinition:
|
|
391
|
+
if "argv" in value:
|
|
392
|
+
reject_unknown_keys(value, {"argv"}, path, error=ConfigurationError)
|
|
393
|
+
argv = value["argv"]
|
|
394
|
+
if (
|
|
395
|
+
not isinstance(argv, list)
|
|
396
|
+
or not argv
|
|
397
|
+
or not all(isinstance(item, str) and item for item in argv)
|
|
398
|
+
):
|
|
399
|
+
raise ConfigurationError(f"{path} argv must be a non-empty list of strings")
|
|
400
|
+
return CommandDefinition(argv=tuple(argv))
|
|
401
|
+
if "shell" in value:
|
|
402
|
+
reject_unknown_keys(
|
|
403
|
+
value, {"shell", "args", "env"}, path, error=ConfigurationError
|
|
404
|
+
)
|
|
405
|
+
script = value["shell"]
|
|
406
|
+
if not isinstance(script, str) or not script.strip():
|
|
407
|
+
raise ConfigurationError(f"{path} shell must be a non-empty string")
|
|
408
|
+
if dependencies(script):
|
|
409
|
+
raise ConfigurationError(
|
|
410
|
+
f"{path} shell source cannot interpolate values; use args or env"
|
|
411
|
+
)
|
|
412
|
+
args = value.get("args", [])
|
|
413
|
+
if not isinstance(args, list) or not all(
|
|
414
|
+
isinstance(item, str) for item in args
|
|
415
|
+
):
|
|
416
|
+
raise ConfigurationError(f"{path} shell args must be a list of strings")
|
|
417
|
+
raw_env = value.get("env", {})
|
|
418
|
+
if not isinstance(raw_env, dict) or not all(
|
|
419
|
+
isinstance(name, str)
|
|
420
|
+
and _ENV_NAME.fullmatch(name)
|
|
421
|
+
and isinstance(item, str)
|
|
422
|
+
for name, item in raw_env.items()
|
|
423
|
+
):
|
|
424
|
+
raise ConfigurationError(
|
|
425
|
+
f"{path} shell env must map variable names to strings"
|
|
426
|
+
)
|
|
427
|
+
return CommandDefinition(
|
|
428
|
+
shell=script,
|
|
429
|
+
args=tuple(args),
|
|
430
|
+
env=tuple(raw_env.items()),
|
|
431
|
+
)
|
|
432
|
+
raise ConfigurationError(f"{path} command action requires argv or shell")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _display_command(command: CommandDefinition) -> str:
|
|
436
|
+
if command.shell is not None:
|
|
437
|
+
environment = " ".join(
|
|
438
|
+
f"{name}={shlex.quote(value)}" for name, value in command.env
|
|
439
|
+
)
|
|
440
|
+
invocation = shlex.join(
|
|
441
|
+
["/bin/sh", "-c", command.shell, "ww-command", *command.args]
|
|
442
|
+
)
|
|
443
|
+
return " ".join(part for part in (environment, invocation) if part)
|
|
444
|
+
return shlex.join(command.argv)
|