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/extensions/api.py
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""The contract a ww extension is written against.
|
|
3
|
+
|
|
4
|
+
An extension adds handlers, modes, commands, core-variable overrides, a
|
|
5
|
+
template namespace, and rule groups to ww. It is identified by a
|
|
6
|
+
``vendor/name`` pair and is addressed from ``ww.yaml`` by
|
|
7
|
+
its fully qualified reference, never by a bare name:
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
hooks:
|
|
11
|
+
after_complete:
|
|
12
|
+
- name: ext/ww/git/handlers:git-commit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Nothing an extension provides takes effect until it is referenced that way, so
|
|
16
|
+
installing one can never change the meaning of a name the project already uses.
|
|
17
|
+
|
|
18
|
+
Variable overrides apply only when the extension is otherwise referenced by a
|
|
19
|
+
saved workflow plan. Extensions do not provide hooks. A hook decides *when*
|
|
20
|
+
work runs against a particular workflow and step, which is knowledge that
|
|
21
|
+
belongs to the project's configuration; an extension only supplies the work
|
|
22
|
+
itself.
|
|
23
|
+
|
|
24
|
+
Packaged extensions use a ``vendor.name`` entry-point name. Discovery reads
|
|
25
|
+
that metadata without importing the package; loading occurs only when the
|
|
26
|
+
extension is referenced. Extensions are trusted in-process Python once loaded.
|
|
27
|
+
``api_version`` declares compatibility with this module's
|
|
28
|
+
``EXTENSION_API_VERSION``; ``version`` identifies extension behavior in saved
|
|
29
|
+
plans.
|
|
30
|
+
|
|
31
|
+
Writing one
|
|
32
|
+
-----------
|
|
33
|
+
|
|
34
|
+
Create ``<project>/ext/<vendor>/<name>/extension.py`` exposing ``EXTENSION``, or
|
|
35
|
+
publish a package advertising an ``ww.extensions`` entry point that resolves to
|
|
36
|
+
an :class:`Extension`:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from ww.extensions.api import Extension, ExtensionHandler, ExtensionResult
|
|
40
|
+
from ww.validation import is_strict_int
|
|
41
|
+
|
|
42
|
+
def _greet(context):
|
|
43
|
+
return ExtensionResult(
|
|
44
|
+
True,
|
|
45
|
+
f"hello from {context.root}",
|
|
46
|
+
values={"greeting": "hello"},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
EXTENSION = Extension(
|
|
50
|
+
vendor="acme",
|
|
51
|
+
name="hello",
|
|
52
|
+
version="1.0.0",
|
|
53
|
+
description="A minimal example.",
|
|
54
|
+
handlers=(
|
|
55
|
+
ExtensionHandler(
|
|
56
|
+
"greet", _greet, "Say hello.", outputs=("greeting",)
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Reserved paths
|
|
63
|
+
--------------
|
|
64
|
+
|
|
65
|
+
An extension that creates something outside ww's own state for a task, such
|
|
66
|
+
as a Git worktree, may declare ``reserved_paths``. ww asks configured
|
|
67
|
+
extensions for those paths before handing out a generated task ID, so an ID
|
|
68
|
+
whose worktree still exists on disk is never reused for an unrelated task.
|
|
69
|
+
|
|
70
|
+
Task records
|
|
71
|
+
------------
|
|
72
|
+
|
|
73
|
+
An extension that keeps its own records per task, as ww/git keeps branch and
|
|
74
|
+
commit records, may declare two hooks. ``claims_task`` returns whether the
|
|
75
|
+
extension still holds anything for ``task_id`` (a record, or a branch named
|
|
76
|
+
after the task); ww skips such an ID when it generates one, as it does for a
|
|
77
|
+
reserved path. ``forget_task`` drops the extension's records for ``task_id``
|
|
78
|
+
and its children; ``ww reset`` calls it for every configured extension, so a
|
|
79
|
+
reset task leaves nothing behind that could shape a later task under the same
|
|
80
|
+
ID.
|
|
81
|
+
|
|
82
|
+
Rule groups
|
|
83
|
+
-----------
|
|
84
|
+
|
|
85
|
+
An extension may ship rule groups in ``rules``: each a
|
|
86
|
+
:class:`RuleGroupContribution` naming the group, its rule files or
|
|
87
|
+
directories as absolute paths (``Path(__file__).parent / "rules"``) or other
|
|
88
|
+
group names, and optional ``workflows``/``steps`` filters. A project gets them
|
|
89
|
+
by listing the extension in its root settings ``extensions``, even with an
|
|
90
|
+
empty section; they come before the project's own groups, and a name the
|
|
91
|
+
project also declares is an error.
|
|
92
|
+
|
|
93
|
+
Template namespace
|
|
94
|
+
------------------
|
|
95
|
+
|
|
96
|
+
An extension may declare one ``namespace``: an :class:`ExtensionNamespace`
|
|
97
|
+
whose variables templates read as ``{{ww.<namespace>.<variable>}}``, for
|
|
98
|
+
example ww/git's ``{{ww.git.branch}}``. They are available to every workflow
|
|
99
|
+
of a project that configures the extension, are resolved for the task only
|
|
100
|
+
when a rendered template references them, and a resolver returning ``None``
|
|
101
|
+
stops an agent step reading the value before it starts (``value_unavailable``)
|
|
102
|
+
and fails an automatic handler reading it.
|
|
103
|
+
|
|
104
|
+
Branch strategies
|
|
105
|
+
-----------------
|
|
106
|
+
|
|
107
|
+
An extension that names branches from ``start --branch-strategy`` may declare
|
|
108
|
+
``branch_strategies``. It receives the extension's settings and returns the
|
|
109
|
+
strategy names it accepts, which ``discover`` lists for agents.
|
|
110
|
+
|
|
111
|
+
Retries
|
|
112
|
+
-------
|
|
113
|
+
|
|
114
|
+
An extension handler is one unit of work. When a run is retried, the whole
|
|
115
|
+
handler runs again — there is no per-step resumption inside it, unlike the
|
|
116
|
+
command-by-command ledger a shell handler gets. A handler may provide ``check``
|
|
117
|
+
to let ww ask whether an interrupted operation already took effect. It receives
|
|
118
|
+
the same stable operation ID as ``run`` and returns an
|
|
119
|
+
:class:`ExtensionCheckResult` with an explicit succeeded, not-succeeded, or
|
|
120
|
+
unknown outcome. Checker errors remain unknown and never become handler
|
|
121
|
+
failures. Without a checker, ww leaves the operation interrupted until an
|
|
122
|
+
explicit recovery action is chosen.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
from __future__ import annotations
|
|
126
|
+
|
|
127
|
+
import re
|
|
128
|
+
from collections.abc import Callable, Mapping
|
|
129
|
+
from dataclasses import dataclass, field
|
|
130
|
+
from pathlib import Path
|
|
131
|
+
from types import MappingProxyType
|
|
132
|
+
from typing import Literal
|
|
133
|
+
|
|
134
|
+
from ww.extensions.store import ExtensionStore
|
|
135
|
+
from ww.validation import NAME_PATTERN as _GROUP_NAME
|
|
136
|
+
from ww.validation import is_strict_int
|
|
137
|
+
from ww.variables import RESERVED_NAMESPACES, WW_NAMESPACE, is_reserved_name
|
|
138
|
+
from ww.workflow_config import ModeDefinition, ProvidedVariable, RuleHints
|
|
139
|
+
|
|
140
|
+
__all__ = [
|
|
141
|
+
"EXTENSION_API_VERSION",
|
|
142
|
+
"Extension",
|
|
143
|
+
"ExtensionCommand",
|
|
144
|
+
"ExtensionContext",
|
|
145
|
+
"ExtensionHandler",
|
|
146
|
+
"ExtensionNamespace",
|
|
147
|
+
"ExtensionResult",
|
|
148
|
+
"ExtensionCheckResult",
|
|
149
|
+
"ExtensionVariable",
|
|
150
|
+
"ModeDefinition",
|
|
151
|
+
"ProvidedVariable",
|
|
152
|
+
"RuleGroupContribution",
|
|
153
|
+
"RuleHints",
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
EXTENSION_API_VERSION = 1
|
|
157
|
+
# A lower-case name segment, e.g. "github" or "pull_request"; "GitHub" does not
|
|
158
|
+
# match.
|
|
159
|
+
_SEGMENT = re.compile(r"[a-z0-9][a-z0-9_-]*$")
|
|
160
|
+
# A value name, dots and hyphens allowed, e.g. "pr.url" or "base-branch".
|
|
161
|
+
_VALUE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_.-]*$")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class ExtensionResult:
|
|
166
|
+
"""What an extension handler reports back to the executor.
|
|
167
|
+
|
|
168
|
+
``output`` is human-readable diagnostic output. ``values`` is the
|
|
169
|
+
structured, machine-readable part of a successful result; every key must
|
|
170
|
+
have been declared by :attr:`ExtensionHandler.outputs` and ww adds those
|
|
171
|
+
values to the workflow value environment.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
ok: bool
|
|
175
|
+
output: str = ""
|
|
176
|
+
error: str = ""
|
|
177
|
+
working_directory: Path | None = None
|
|
178
|
+
values: Mapping[str, str] = field(default_factory=dict)
|
|
179
|
+
|
|
180
|
+
def __post_init__(self) -> None:
|
|
181
|
+
if type(self.ok) is not bool:
|
|
182
|
+
raise TypeError("extension result ok must be a bool")
|
|
183
|
+
if not isinstance(self.output, str) or not isinstance(self.error, str):
|
|
184
|
+
raise TypeError("extension result output and error must be strings")
|
|
185
|
+
if self.working_directory is not None and not isinstance(
|
|
186
|
+
self.working_directory, Path
|
|
187
|
+
):
|
|
188
|
+
raise TypeError("extension result working_directory must be a Path")
|
|
189
|
+
if not isinstance(self.values, Mapping):
|
|
190
|
+
raise TypeError("extension result values must be a mapping")
|
|
191
|
+
copied = dict(self.values)
|
|
192
|
+
for name, value in copied.items():
|
|
193
|
+
if not isinstance(name, str) or not _VALUE_NAME.fullmatch(name):
|
|
194
|
+
raise TypeError(
|
|
195
|
+
"extension result value names must be normalized strings"
|
|
196
|
+
)
|
|
197
|
+
if is_reserved_name(name):
|
|
198
|
+
raise ValueError(f"extension result value name {name!r} is reserved")
|
|
199
|
+
if not isinstance(value, str):
|
|
200
|
+
raise TypeError("extension result values must be strings")
|
|
201
|
+
if not self.ok and copied:
|
|
202
|
+
raise ValueError("a failed extension result cannot provide values")
|
|
203
|
+
object.__setattr__(self, "values", MappingProxyType(copied))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class ExtensionCheckResult:
|
|
208
|
+
"""Tri-state attestation of an interrupted extension operation."""
|
|
209
|
+
|
|
210
|
+
status: Literal["succeeded", "not_succeeded", "unknown"]
|
|
211
|
+
result: ExtensionResult | None = None
|
|
212
|
+
error: str = ""
|
|
213
|
+
|
|
214
|
+
def __post_init__(self) -> None:
|
|
215
|
+
if self.status not in {"succeeded", "not_succeeded", "unknown"}:
|
|
216
|
+
raise ValueError("extension check result has an invalid status")
|
|
217
|
+
if not isinstance(self.error, str):
|
|
218
|
+
raise TypeError("extension check result error must be a string")
|
|
219
|
+
if self.status == "succeeded":
|
|
220
|
+
if not isinstance(self.result, ExtensionResult) or not self.result.ok:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
"a succeeded extension check requires a successful result"
|
|
223
|
+
)
|
|
224
|
+
elif self.result is not None:
|
|
225
|
+
raise ValueError("only a succeeded extension check may include a result")
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def succeeded(cls, result: ExtensionResult) -> ExtensionCheckResult:
|
|
229
|
+
return cls("succeeded", result=result)
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def not_succeeded(cls) -> ExtensionCheckResult:
|
|
233
|
+
return cls("not_succeeded")
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def unknown(cls, error: str = "") -> ExtensionCheckResult:
|
|
237
|
+
return cls("unknown", error=error)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class ExtensionContext:
|
|
242
|
+
"""Everything a handler or command is allowed to know about the caller.
|
|
243
|
+
|
|
244
|
+
``task_id``, ``run_id`` and ``workflow`` are absent for commands invoked
|
|
245
|
+
outside a task. ``lane`` is the workflow whose project handling the task
|
|
246
|
+
takes: the workflow's ``hooks_from``, else ``workflow`` itself. An
|
|
247
|
+
extension keys its per-workflow settings (a branch format, a base branch)
|
|
248
|
+
by ``lane`` and records and shows ``workflow``. ``values`` holds the
|
|
249
|
+
workflow values collected so far, so a handler reads its declared inputs
|
|
250
|
+
from it by name.
|
|
251
|
+
|
|
252
|
+
``config`` is this extension's own section of ``ww.json``,
|
|
253
|
+
and nothing else from that file. ww passes it through unvalidated: it cannot
|
|
254
|
+
know a third party's schema, so an extension validates its own settings and
|
|
255
|
+
reports its own errors.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
root: Path
|
|
259
|
+
store: ExtensionStore
|
|
260
|
+
config: Mapping[str, object] = field(default_factory=dict)
|
|
261
|
+
task_id: str | None = None
|
|
262
|
+
run_id: str | None = None
|
|
263
|
+
workflow: str | None = None
|
|
264
|
+
lane: str | None = None
|
|
265
|
+
values: Mapping[str, str] = field(default_factory=dict)
|
|
266
|
+
arguments: tuple[str, ...] = ()
|
|
267
|
+
workspace: Path | None = None
|
|
268
|
+
# Execution identity is absent for standalone extension commands.
|
|
269
|
+
item_id: str | None = None
|
|
270
|
+
work_item_id: str | None = None
|
|
271
|
+
attempt: int = 0
|
|
272
|
+
operation_id: str | None = None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True)
|
|
276
|
+
class ExtensionHandler:
|
|
277
|
+
"""Work an extension contributes, addressable as ``.../handlers:<name>``.
|
|
278
|
+
|
|
279
|
+
``provide`` declares inputs ww collects from the agent before running the
|
|
280
|
+
handler, exactly as a configured CLI handler does. ``validate`` judges
|
|
281
|
+
those inputs when the agent supplies them: it receives the handler's own
|
|
282
|
+
declared values and returns an error message to refuse them, or ``None``.
|
|
283
|
+
ww then rejects the completion carrying a refused value before saving
|
|
284
|
+
anything, so the agent corrects it instead of the handler failing later.
|
|
285
|
+
|
|
286
|
+
``arguments`` names the positional arguments a reference must pass with
|
|
287
|
+
``args``, in order; ww renders their templates when the handler runs and
|
|
288
|
+
hands them over as ``ExtensionContext.arguments``. A reference passing
|
|
289
|
+
a different number is a configuration error.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
name: str
|
|
293
|
+
run: Callable[[ExtensionContext], ExtensionResult]
|
|
294
|
+
description: str = ""
|
|
295
|
+
provide: tuple[ProvidedVariable, ...] = ()
|
|
296
|
+
check: Callable[[ExtensionContext], ExtensionCheckResult] | None = None
|
|
297
|
+
outputs: tuple[str, ...] = ()
|
|
298
|
+
validate: Callable[[Mapping[str, str]], str | None] | None = None
|
|
299
|
+
arguments: tuple[str, ...] = ()
|
|
300
|
+
|
|
301
|
+
def __post_init__(self) -> None:
|
|
302
|
+
if not isinstance(self.name, str) or not _VALUE_NAME.fullmatch(self.name):
|
|
303
|
+
raise ValueError("extension handler name must be normalized")
|
|
304
|
+
if not isinstance(self.description, str):
|
|
305
|
+
raise TypeError("extension handler description must be a string")
|
|
306
|
+
if not callable(self.run):
|
|
307
|
+
raise TypeError("extension handler run must be callable")
|
|
308
|
+
if self.check is not None and not callable(self.check):
|
|
309
|
+
raise TypeError("extension handler check must be callable")
|
|
310
|
+
if self.validate is not None and not callable(self.validate):
|
|
311
|
+
raise TypeError("extension handler validate must be callable")
|
|
312
|
+
if not isinstance(self.provide, tuple) or not all(
|
|
313
|
+
isinstance(value, ProvidedVariable) for value in self.provide
|
|
314
|
+
):
|
|
315
|
+
raise TypeError("extension handler provide must be a tuple of variables")
|
|
316
|
+
provided_names = [value.name for value in self.provide]
|
|
317
|
+
if len(provided_names) != len(set(provided_names)):
|
|
318
|
+
raise ValueError("extension handler input names must be unique")
|
|
319
|
+
for value in self.provide:
|
|
320
|
+
if (
|
|
321
|
+
not isinstance(value.name, str)
|
|
322
|
+
or not _VALUE_NAME.fullmatch(value.name)
|
|
323
|
+
or is_reserved_name(value.name)
|
|
324
|
+
):
|
|
325
|
+
raise ValueError("extension handler input names must be normalized")
|
|
326
|
+
if not isinstance(value.description, str):
|
|
327
|
+
raise TypeError("extension handler input descriptions must be strings")
|
|
328
|
+
if not isinstance(self.outputs, tuple):
|
|
329
|
+
raise TypeError("extension handler outputs must be a tuple")
|
|
330
|
+
if len(self.outputs) != len(set(self.outputs)):
|
|
331
|
+
raise ValueError("extension handler output names must be unique")
|
|
332
|
+
for name in self.outputs:
|
|
333
|
+
if not isinstance(name, str) or not _VALUE_NAME.fullmatch(name):
|
|
334
|
+
raise ValueError(
|
|
335
|
+
"extension handler output names must be normalized strings"
|
|
336
|
+
)
|
|
337
|
+
if is_reserved_name(name):
|
|
338
|
+
raise ValueError(f"extension handler output name {name!r} is reserved")
|
|
339
|
+
if not isinstance(self.arguments, tuple) or not all(
|
|
340
|
+
isinstance(name, str) and _VALUE_NAME.fullmatch(name)
|
|
341
|
+
for name in self.arguments
|
|
342
|
+
):
|
|
343
|
+
raise ValueError(
|
|
344
|
+
"extension handler arguments must be a tuple of normalized names"
|
|
345
|
+
)
|
|
346
|
+
if len(self.arguments) != len(set(self.arguments)):
|
|
347
|
+
raise ValueError("extension handler argument names must be unique")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True)
|
|
351
|
+
class ExtensionCommand:
|
|
352
|
+
"""An operator command over the extension's own state.
|
|
353
|
+
|
|
354
|
+
Commands are reachable only as ``./ww extension <vendor>/<name> <command>``.
|
|
355
|
+
They are deliberately not addressable from ``ww.yaml``:
|
|
356
|
+
they inspect and maintain what the extension has recorded, and are
|
|
357
|
+
not workflow steps.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
name: str
|
|
361
|
+
run: Callable[[ExtensionContext], str]
|
|
362
|
+
description: str = ""
|
|
363
|
+
usage: str = ""
|
|
364
|
+
|
|
365
|
+
def __post_init__(self) -> None:
|
|
366
|
+
if not isinstance(self.name, str) or not _VALUE_NAME.fullmatch(self.name):
|
|
367
|
+
raise ValueError("extension command name must be normalized")
|
|
368
|
+
if not callable(self.run):
|
|
369
|
+
raise TypeError("extension command run must be callable")
|
|
370
|
+
if not isinstance(self.description, str) or not isinstance(self.usage, str):
|
|
371
|
+
raise TypeError("extension command description and usage must be strings")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@dataclass(frozen=True)
|
|
375
|
+
class ExtensionVariable:
|
|
376
|
+
"""A variable an extension resolves for a task.
|
|
377
|
+
|
|
378
|
+
In :attr:`Extension.variables` it overrides a variable defined by ww core:
|
|
379
|
+
returning ``None`` keeps the core value, and the core name is validated
|
|
380
|
+
when the override is resolved. In an :class:`ExtensionNamespace` it is a
|
|
381
|
+
new name under the extension's namespace, and ``None`` means the value is
|
|
382
|
+
not available yet.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
name: str
|
|
386
|
+
resolve: Callable[[ExtensionContext], str | None]
|
|
387
|
+
|
|
388
|
+
def __post_init__(self) -> None:
|
|
389
|
+
if not isinstance(self.name, str) or not _VALUE_NAME.fullmatch(self.name):
|
|
390
|
+
raise ValueError("extension variable name must be normalized")
|
|
391
|
+
if not callable(self.resolve):
|
|
392
|
+
raise TypeError("extension variable resolver must be callable")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@dataclass(frozen=True)
|
|
396
|
+
class ExtensionNamespace:
|
|
397
|
+
"""Template values an extension provides as ``{{ww.<name>.<variable>}}``.
|
|
398
|
+
|
|
399
|
+
Each variable resolves lazily, for the task on the context, and only when
|
|
400
|
+
a template being rendered references it; returning ``None`` means the
|
|
401
|
+
value is not available for that task yet, which stops the task for the
|
|
402
|
+
operator (``value_unavailable``) before a step reading it starts, or
|
|
403
|
+
fails a handler reading it. Unlike an :class:`ExtensionVariable`, these
|
|
404
|
+
are new names, available whenever the project configures the extension
|
|
405
|
+
(a section in the settings, even an empty one), not only when a handler
|
|
406
|
+
of the extension is referenced.
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
name: str
|
|
410
|
+
variables: tuple[ExtensionVariable, ...]
|
|
411
|
+
|
|
412
|
+
def __post_init__(self) -> None:
|
|
413
|
+
if not isinstance(self.name, str) or not _SEGMENT.fullmatch(self.name):
|
|
414
|
+
raise ValueError(
|
|
415
|
+
"extension namespace must be lowercase letters, digits, '_' or "
|
|
416
|
+
"'-', starting with a letter or digit"
|
|
417
|
+
)
|
|
418
|
+
if self.name == WW_NAMESPACE or self.name in RESERVED_NAMESPACES:
|
|
419
|
+
raise ValueError(f"extension namespace {self.name!r} is reserved")
|
|
420
|
+
if not isinstance(self.variables, tuple) or not self.variables:
|
|
421
|
+
raise ValueError("extension namespace variables must be a non-empty tuple")
|
|
422
|
+
if not all(isinstance(value, ExtensionVariable) for value in self.variables):
|
|
423
|
+
raise TypeError("extension namespace variables contain an invalid item")
|
|
424
|
+
names = [value.name for value in self.variables]
|
|
425
|
+
if len(names) != len(set(names)):
|
|
426
|
+
raise ValueError("extension namespace variable names must be unique")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass(frozen=True)
|
|
430
|
+
class RuleGroupContribution:
|
|
431
|
+
"""A rule group an extension ships, before it is resolved.
|
|
432
|
+
|
|
433
|
+
``items`` are absolute paths to rule files or directories, or the names
|
|
434
|
+
of other groups; an extension builds its paths from its own module file,
|
|
435
|
+
for example ``Path(__file__).parent / "rules"``.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
name: str
|
|
439
|
+
items: tuple[Path | str, ...]
|
|
440
|
+
workflows: tuple[str, ...] | None = None
|
|
441
|
+
steps: tuple[str, ...] | None = None
|
|
442
|
+
hints: RuleHints = RuleHints()
|
|
443
|
+
|
|
444
|
+
def __post_init__(self) -> None:
|
|
445
|
+
if not isinstance(self.name, str) or not _GROUP_NAME.fullmatch(self.name):
|
|
446
|
+
raise ValueError("rule group name must be a normalized name")
|
|
447
|
+
if not isinstance(self.items, tuple) or not self.items:
|
|
448
|
+
raise ValueError("rule group items must be a non-empty tuple")
|
|
449
|
+
for item in self.items:
|
|
450
|
+
if isinstance(item, Path):
|
|
451
|
+
if not item.is_absolute():
|
|
452
|
+
raise ValueError("rule group paths must be absolute")
|
|
453
|
+
elif not isinstance(item, str) or not _GROUP_NAME.fullmatch(item):
|
|
454
|
+
raise ValueError(
|
|
455
|
+
"rule group items must be absolute paths or group names"
|
|
456
|
+
)
|
|
457
|
+
for label, names in (("workflows", self.workflows), ("steps", self.steps)):
|
|
458
|
+
if names is not None and (
|
|
459
|
+
not isinstance(names, tuple)
|
|
460
|
+
or not all(isinstance(name, str) and name for name in names)
|
|
461
|
+
):
|
|
462
|
+
raise ValueError(f"rule group {label} must be a tuple of names")
|
|
463
|
+
if not isinstance(self.hints, RuleHints):
|
|
464
|
+
raise TypeError("rule group hints must be RuleHints")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@dataclass(frozen=True)
|
|
468
|
+
class Extension:
|
|
469
|
+
"""A vendor's contribution of handlers, modes, commands, and overrides."""
|
|
470
|
+
|
|
471
|
+
vendor: str
|
|
472
|
+
name: str
|
|
473
|
+
description: str = ""
|
|
474
|
+
handlers: tuple[ExtensionHandler, ...] = ()
|
|
475
|
+
modes: tuple[ModeDefinition, ...] = ()
|
|
476
|
+
commands: tuple[ExtensionCommand, ...] = ()
|
|
477
|
+
variables: tuple[ExtensionVariable, ...] = ()
|
|
478
|
+
version: str = "0"
|
|
479
|
+
api_version: int = EXTENSION_API_VERSION
|
|
480
|
+
# Paths a task claims outside ww state; receives config, root, task_id,
|
|
481
|
+
# and workflow on the context. ``None`` when the extension owns none.
|
|
482
|
+
reserved_paths: Callable[[ExtensionContext], tuple[Path, ...]] | None = None
|
|
483
|
+
# Whether the extension still holds records for ``task_id`` (same context
|
|
484
|
+
# as ``reserved_paths``), so a generated ID skips it. ``None`` for never.
|
|
485
|
+
claims_task: Callable[[ExtensionContext], bool] | None = None
|
|
486
|
+
# Forget ``task_id`` and its children when the task is reset; receives
|
|
487
|
+
# root, store, config, and task_id. ``None`` when there is nothing to drop.
|
|
488
|
+
forget_task: Callable[[ExtensionContext], None] | None = None
|
|
489
|
+
# Names accepted by ``start --branch-strategy``, given the extension's
|
|
490
|
+
# settings. ``None`` when the extension does not name branches.
|
|
491
|
+
branch_strategies: Callable[[Mapping[str, object]], tuple[str, ...]] | None = None
|
|
492
|
+
# Rule groups the extension ships; a project that configures the
|
|
493
|
+
# extension gets them under the root ``rules`` by these names.
|
|
494
|
+
rules: tuple[RuleGroupContribution, ...] = ()
|
|
495
|
+
# Template values under ``{{ww.<namespace>.*}}``; ``None`` for none.
|
|
496
|
+
namespace: ExtensionNamespace | None = None
|
|
497
|
+
|
|
498
|
+
def __post_init__(self) -> None:
|
|
499
|
+
if self.namespace is not None and not isinstance(
|
|
500
|
+
self.namespace, ExtensionNamespace
|
|
501
|
+
):
|
|
502
|
+
raise TypeError("extension namespace must be an ExtensionNamespace")
|
|
503
|
+
if self.reserved_paths is not None and not callable(self.reserved_paths):
|
|
504
|
+
raise TypeError("extension reserved_paths must be callable")
|
|
505
|
+
if self.claims_task is not None and not callable(self.claims_task):
|
|
506
|
+
raise TypeError("extension claims_task must be callable")
|
|
507
|
+
if self.forget_task is not None and not callable(self.forget_task):
|
|
508
|
+
raise TypeError("extension forget_task must be callable")
|
|
509
|
+
if self.branch_strategies is not None and not callable(self.branch_strategies):
|
|
510
|
+
raise TypeError("extension branch_strategies must be callable")
|
|
511
|
+
for label, value in (("vendor", self.vendor), ("name", self.name)):
|
|
512
|
+
if not _SEGMENT.fullmatch(value):
|
|
513
|
+
raise ValueError(
|
|
514
|
+
f"extension {label} {value!r} must be lowercase letters, "
|
|
515
|
+
"digits, '_' or '-', starting with a letter or digit"
|
|
516
|
+
)
|
|
517
|
+
if not isinstance(self.description, str):
|
|
518
|
+
raise TypeError("extension description must be a string")
|
|
519
|
+
if not isinstance(self.version, str) or not self.version.strip():
|
|
520
|
+
raise ValueError("extension version must be a non-empty string")
|
|
521
|
+
if not is_strict_int(self.api_version):
|
|
522
|
+
raise TypeError("extension api_version must be an integer")
|
|
523
|
+
for label, values, expected in (
|
|
524
|
+
("handlers", self.handlers, ExtensionHandler),
|
|
525
|
+
("modes", self.modes, ModeDefinition),
|
|
526
|
+
("commands", self.commands, ExtensionCommand),
|
|
527
|
+
("variables", self.variables, ExtensionVariable),
|
|
528
|
+
("rules", self.rules, RuleGroupContribution),
|
|
529
|
+
):
|
|
530
|
+
if not isinstance(values, tuple):
|
|
531
|
+
raise TypeError(f"extension {label} must be a tuple")
|
|
532
|
+
if not all(isinstance(value, expected) for value in values):
|
|
533
|
+
raise TypeError(f"extension {label} contain an invalid item")
|
|
534
|
+
names = [value.name for value in values]
|
|
535
|
+
if len(names) != len(set(names)):
|
|
536
|
+
raise ValueError(f"extension {label} names must be unique")
|
|
537
|
+
for mode in self.modes:
|
|
538
|
+
if not isinstance(mode.name, str) or not _VALUE_NAME.fullmatch(mode.name):
|
|
539
|
+
raise ValueError("extension mode name must be normalized")
|
|
540
|
+
if not isinstance(mode.description, tuple) or not all(
|
|
541
|
+
isinstance(line, str) for line in mode.description
|
|
542
|
+
):
|
|
543
|
+
raise TypeError("extension mode description must be a tuple of strings")
|
|
544
|
+
|
|
545
|
+
@property
|
|
546
|
+
def identifier(self) -> str:
|
|
547
|
+
return f"{self.vendor}/{self.name}"
|
|
548
|
+
|
|
549
|
+
@property
|
|
550
|
+
def handlers_by_name(self) -> dict[str, ExtensionHandler]:
|
|
551
|
+
return {handler.name: handler for handler in self.handlers}
|
|
552
|
+
|
|
553
|
+
@property
|
|
554
|
+
def modes_by_name(self) -> dict[str, ModeDefinition]:
|
|
555
|
+
return {mode.name: mode for mode in self.modes}
|
|
556
|
+
|
|
557
|
+
@property
|
|
558
|
+
def commands_by_name(self) -> dict[str, ExtensionCommand]:
|
|
559
|
+
return {command.name: command for command in self.commands}
|