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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/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}