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
@@ -0,0 +1,864 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Lazy discovery and validated lookup of installed ww extensions.
3
+
4
+ Discovery records where an extension comes from without importing its Python
5
+ module. Executable code is loaded only when a caller asks for that extension's
6
+ handlers, modes, or commands. Entry-point names are metadata and must use the
7
+ ``vendor.name`` form for an extension identified as ``vendor/name``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import importlib.util
14
+ import json
15
+ import re
16
+ import sys
17
+ from collections.abc import Callable, Mapping
18
+ from copy import deepcopy
19
+ from dataclasses import dataclass
20
+ from importlib.metadata import EntryPoint, entry_points
21
+ from importlib.resources import as_file, files
22
+ from pathlib import Path
23
+
24
+ from ww.errors import ConfigurationError
25
+ from ww.extensions.api import (
26
+ EXTENSION_API_VERSION,
27
+ Extension,
28
+ ExtensionCommand,
29
+ ExtensionContext,
30
+ ExtensionHandler,
31
+ ExtensionNamespace,
32
+ RuleGroupContribution,
33
+ )
34
+ from ww.extensions.store import ExtensionStore
35
+ from ww.project_config import (
36
+ FILE_NAME,
37
+ ProjectConfig,
38
+ ProjectSettings,
39
+ load_project_config,
40
+ overlay_settings,
41
+ )
42
+ from ww.variables import OVERRIDABLE_CORE_VARIABLE_NAMES, namespaced
43
+ from ww.workflow_config import ModeDefinition
44
+
45
+ ENTRY_POINT_GROUP = "ww.extensions"
46
+ EXTENSION_DIRECTORY = "ext"
47
+ # The vendor of the extensions ww bundles; a project may not provide them.
48
+ BUNDLED_VENDOR = "ww"
49
+ SECTIONS = ("handlers", "modes")
50
+
51
+ # One lower-case vendor or extension name, e.g. "ww" or "pull_request".
52
+ _SEGMENT = r"[a-z0-9][a-z0-9_-]*"
53
+ # An extension identifier, e.g. "ww/git".
54
+ _IDENTIFIER = re.compile(rf"^(?P<vendor>{_SEGMENT})/(?P<name>{_SEGMENT})$")
55
+ # An entry point name, e.g. "ww.git".
56
+ _ENTRY_POINT_NAME = re.compile(rf"^(?P<vendor>{_SEGMENT})\.(?P<name>{_SEGMENT})$")
57
+ # An extension item reference, e.g. "ext/ww/git/handlers:commit".
58
+ _REFERENCE = re.compile(
59
+ rf"^ext/(?P<vendor>{_SEGMENT})/(?P<name>{_SEGMENT})"
60
+ r"/(?P<section>[a-z]+):(?P<item>[A-Za-z0-9][A-Za-z0-9_.-]*)$"
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ExtensionReference:
66
+ """A parsed ``ext/<vendor>/<name>/<section>:<item>`` reference."""
67
+
68
+ vendor: str
69
+ name: str
70
+ section: str
71
+ item: str
72
+
73
+ @property
74
+ def identifier(self) -> str:
75
+ return f"{self.vendor}/{self.name}"
76
+
77
+ def __str__(self) -> str:
78
+ return f"ext/{self.identifier}/{self.section}:{self.item}"
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class ExtensionIdentity:
83
+ """The executable identity frozen into a compiled workflow plan."""
84
+
85
+ identifier: str
86
+ version: str
87
+ api_version: int
88
+ source: str
89
+ fingerprint: str
90
+
91
+
92
+ @dataclass
93
+ class _ExtensionProvider:
94
+ identifier: str
95
+ source: str
96
+ fingerprint: str
97
+ loader: Callable[[], Extension]
98
+ loaded: Extension | None = None
99
+
100
+
101
+ def is_extension_reference(value: str) -> bool:
102
+ """Return whether ``value`` is addressed to an extension."""
103
+ return value.startswith("ext/")
104
+
105
+
106
+ def parse_reference(value: str) -> ExtensionReference:
107
+ match = _REFERENCE.match(value)
108
+ if match is None:
109
+ raise ConfigurationError(
110
+ f"invalid extension reference {value!r}; expected "
111
+ "ext/<vendor>/<name>/<section>:<item>"
112
+ )
113
+ reference = ExtensionReference(**match.groupdict())
114
+ if reference.section not in SECTIONS:
115
+ raise ConfigurationError(
116
+ f"unknown extension section {reference.section!r} in {value!r}; "
117
+ f"choose one of: {', '.join(SECTIONS)}"
118
+ )
119
+ return reference
120
+
121
+
122
+ class ExtensionRegistry:
123
+ """The lazily loaded extensions available to one project root."""
124
+
125
+ def __init__(
126
+ self,
127
+ root: Path,
128
+ extensions: tuple[Extension, ...] = (),
129
+ config: ProjectConfig | None = None,
130
+ ) -> None:
131
+ self.root = Path(root)
132
+ self._config = config
133
+ self._project_settings: dict[str, ProjectSettings] = {}
134
+ self._providers: dict[str, _ExtensionProvider] = {}
135
+ for extension in extensions:
136
+ self._add_provider(
137
+ _ExtensionProvider(
138
+ extension.identifier,
139
+ "in-process",
140
+ _fingerprint(
141
+ f"{extension.identifier}:{extension.version}:"
142
+ f"{extension.api_version}"
143
+ ),
144
+ _extension_loader(extension),
145
+ )
146
+ )
147
+
148
+ @classmethod
149
+ def discover(cls, root: Path) -> ExtensionRegistry:
150
+ root = Path(root)
151
+ registry = cls(root)
152
+ for provider in _bundled_providers():
153
+ registry._add_provider(provider)
154
+ for provider in _project_providers(root):
155
+ registry._add_provider(provider)
156
+ for provider in _installed_providers():
157
+ registry._add_provider(provider)
158
+ return registry
159
+
160
+ @property
161
+ def identifiers(self) -> tuple[str, ...]:
162
+ """Return discovered identifiers without importing extension code."""
163
+ return tuple(sorted(self._providers))
164
+
165
+ def validate_configuration(self, project: str | None = None) -> None:
166
+ """Check the root's sections, and a project's own when one is named."""
167
+ self.config.validate_against(self.identifiers)
168
+ if project is not None:
169
+ self.project_settings(project).validate_against(self.identifiers)
170
+
171
+ @property
172
+ def config(self) -> ProjectConfig:
173
+ if self._config is None:
174
+ self._config = load_project_config(self.root / FILE_NAME)
175
+ return self._config
176
+
177
+ def project_settings(self, project: str) -> ProjectSettings:
178
+ """What a configured project's own settings files contribute, read once."""
179
+ loaded = self._project_settings.get(project)
180
+ if loaded is None:
181
+ loaded = self.config.project_settings(self.root, project)
182
+ self._project_settings[project] = loaded
183
+ return loaded
184
+
185
+ def task_format(self, project: str | None = None) -> str | None:
186
+ """The generated task ID format: the project's own, else the root's."""
187
+ if project is not None:
188
+ own = self.project_settings(project).task_format
189
+ if own is not None:
190
+ return own
191
+ return self.config.task_format
192
+
193
+ def settings(
194
+ self, identifier: str, project: str | None = None
195
+ ) -> dict[str, object]:
196
+ """Return a detached copy of one extension's validated settings.
197
+
198
+ With ``project``, that project's own section for the extension is
199
+ applied over the root's, so work done in the project follows the
200
+ project's conventions while the root stays the only place that
201
+ decides which extensions are configured at all.
202
+ """
203
+ self.get(identifier)
204
+ self.validate_configuration(project)
205
+ settings = self.config.settings_for(identifier)
206
+ if project is not None:
207
+ settings = overlay_settings(
208
+ settings,
209
+ self.project_settings(project).sections.settings_for(identifier),
210
+ )
211
+ try:
212
+ json.dumps(settings, sort_keys=True)
213
+ except (TypeError, ValueError) as error:
214
+ raise ConfigurationError(
215
+ f"settings for extension {identifier!r} must be JSON serializable"
216
+ ) from error
217
+ return settings
218
+
219
+ @property
220
+ def extensions(self) -> tuple[Extension, ...]:
221
+ """Load all extensions for an explicit full-catalog request."""
222
+ return tuple(self.get(identifier) for identifier in self.identifiers)
223
+
224
+ def get(self, identifier: str) -> Extension:
225
+ provider = self._providers.get(identifier)
226
+ if provider is None:
227
+ raise ConfigurationError(
228
+ f"unknown extension {identifier!r}; {self._available()}"
229
+ )
230
+ if provider.loaded is None:
231
+ provider.loaded = self._load(provider)
232
+ return provider.loaded
233
+
234
+ def identity(self, identifier: str) -> ExtensionIdentity:
235
+ extension = self.get(identifier)
236
+ provider = self._providers[identifier]
237
+ return ExtensionIdentity(
238
+ identifier,
239
+ extension.version,
240
+ extension.api_version,
241
+ provider.source,
242
+ provider.fingerprint,
243
+ )
244
+
245
+ def validate_identity(
246
+ self,
247
+ identifier: str,
248
+ *,
249
+ version: str | None,
250
+ api_version: int | None,
251
+ source: str | None,
252
+ fingerprint: str | None,
253
+ ) -> None:
254
+ """Reject dispatch through an extension other than the one a plan used.
255
+
256
+ Identity is the extension's version, API version, and provider source.
257
+ The source fingerprint is recorded for audit but not enforced: like
258
+ ww's own code, an extension may be fixed in place while a task is in
259
+ flight, and a change in behaviour is signalled by a version bump.
260
+ """
261
+ del fingerprint
262
+ if all(value is None for value in (version, api_version, source)):
263
+ return
264
+ current = self.identity(identifier)
265
+ expected = (version, api_version, source)
266
+ actual = (current.version, current.api_version, current.source)
267
+ if expected != actual:
268
+ raise ConfigurationError(
269
+ f"extension {identifier!r} no longer matches the saved plan; "
270
+ f"expected version {version!r} (API {api_version}) from "
271
+ f"{source!r}, found version {current.version!r} "
272
+ f"(API {current.api_version}) from {current.source!r}"
273
+ )
274
+
275
+ def handler(self, value: str) -> ExtensionHandler:
276
+ reference = self._resolve(value, "handlers")
277
+ self._require_reference_provider(value, reference.identifier)
278
+ extension = self.get(reference.identifier)
279
+ handler = extension.handlers_by_name.get(reference.item)
280
+ if handler is None:
281
+ raise ConfigurationError(
282
+ f"extension {reference.identifier!r} has no handler "
283
+ f"{reference.item!r}; available: "
284
+ + (", ".join(sorted(extension.handlers_by_name)) or "none")
285
+ )
286
+ return handler
287
+
288
+ def mode(self, value: str) -> ModeDefinition:
289
+ reference = self._resolve(value, "modes")
290
+ self._require_reference_provider(value, reference.identifier)
291
+ extension = self.get(reference.identifier)
292
+ mode = extension.modes_by_name.get(reference.item)
293
+ if mode is None:
294
+ raise ConfigurationError(
295
+ f"extension {reference.identifier!r} has no mode "
296
+ f"{reference.item!r}; available: "
297
+ + (", ".join(sorted(extension.modes_by_name)) or "none")
298
+ )
299
+ return ModeDefinition(str(reference), mode.description)
300
+
301
+ def command(self, identifier: str, name: str) -> ExtensionCommand:
302
+ extension = self.get(identifier)
303
+ command = extension.commands_by_name.get(name)
304
+ if command is None:
305
+ raise ConfigurationError(
306
+ f"extension {identifier!r} has no command {name!r}; available: "
307
+ + (", ".join(sorted(extension.commands_by_name)) or "none")
308
+ )
309
+ return command
310
+
311
+ def qualified_modes(self) -> tuple[ModeDefinition, ...]:
312
+ """Load and return every extension mode under its qualified name."""
313
+ return tuple(
314
+ ModeDefinition(
315
+ f"ext/{extension.identifier}/modes:{mode.name}", mode.description
316
+ )
317
+ for extension in self.extensions
318
+ for mode in extension.modes
319
+ )
320
+
321
+ def apply_variable_overrides(
322
+ self,
323
+ values: dict[str, str],
324
+ bindings: tuple[tuple[str, Mapping[str, object] | None], ...],
325
+ *,
326
+ task_id: str,
327
+ run_id: str | None,
328
+ workflow: str,
329
+ workflow_values: dict[str, str],
330
+ workspace: Path | None,
331
+ lane: str | None = None,
332
+ ) -> dict[str, str]:
333
+ """Apply referenced extensions' overrides to existing core variables."""
334
+ result = dict(values)
335
+ claimed: dict[str, str] = {}
336
+ for identifier, frozen_settings in dict(bindings).items():
337
+ extension = self.get(identifier)
338
+ extension_context = ExtensionContext(
339
+ root=self.root,
340
+ store=self.store(identifier),
341
+ config=(
342
+ deepcopy(dict(frozen_settings))
343
+ if frozen_settings is not None
344
+ else self.settings(identifier)
345
+ ),
346
+ task_id=task_id,
347
+ run_id=run_id,
348
+ workflow=workflow,
349
+ lane=lane or workflow,
350
+ values={**workflow_values, **values},
351
+ workspace=workspace,
352
+ )
353
+ for variable in extension.variables:
354
+ if variable.name not in OVERRIDABLE_CORE_VARIABLE_NAMES:
355
+ raise ConfigurationError(
356
+ f"extension {identifier!r} cannot override core variable "
357
+ f"{variable.name!r}"
358
+ )
359
+ previous = claimed.get(variable.name)
360
+ if previous is not None:
361
+ raise ConfigurationError(
362
+ f"extensions {previous!r} and {identifier!r} both override "
363
+ f"core variable {variable.name!r}"
364
+ )
365
+ try:
366
+ value = variable.resolve(extension_context)
367
+ except ConfigurationError:
368
+ raise
369
+ except Exception as error: # noqa: BLE001 - add extension context
370
+ raise ConfigurationError(
371
+ f"extension {identifier!r} failed to resolve variable "
372
+ f"{variable.name!r}: {error}"
373
+ ) from error
374
+ if value is not None and not isinstance(value, str):
375
+ raise ConfigurationError(
376
+ f"extension {identifier!r} variable {variable.name!r} "
377
+ "must resolve to a string or null"
378
+ )
379
+ claimed[variable.name] = identifier
380
+ if value is not None:
381
+ result[variable.name] = value
382
+ return result
383
+
384
+ def rule_groups(self) -> tuple[tuple[str, RuleGroupContribution], ...]:
385
+ """The rule groups of the extensions the root lists, with their IDs.
386
+
387
+ Only an extension with a section in the root settings, even an empty
388
+ one, contributes rules, so installing one never changes what a
389
+ project's steps are told; loading those is the cost of asking.
390
+ """
391
+ return tuple(
392
+ (identifier, group)
393
+ for identifier in self.identifiers
394
+ if self.config.sections.lists(identifier)
395
+ for group in self.get(identifier).rules
396
+ )
397
+
398
+ def namespaces(self) -> dict[str, tuple[str, ExtensionNamespace]]:
399
+ """The template namespaces of the extensions the root lists, by name.
400
+
401
+ Like rule groups, a namespace comes with listing the extension in the
402
+ root settings, even with an empty section; two listed extensions
403
+ claiming one namespace are an error.
404
+ """
405
+ claimed: dict[str, tuple[str, ExtensionNamespace]] = {}
406
+ for identifier in self.identifiers:
407
+ if not self.config.sections.lists(identifier):
408
+ continue
409
+ namespace = self.get(identifier).namespace
410
+ if namespace is None:
411
+ continue
412
+ previous = claimed.get(namespace.name)
413
+ if previous is not None:
414
+ raise ConfigurationError(
415
+ f"extensions {previous[0]!r} and {identifier!r} both provide "
416
+ f"the template namespace {namespace.name!r}"
417
+ )
418
+ claimed[namespace.name] = (identifier, namespace)
419
+ return claimed
420
+
421
+ def namespace_variables(self) -> tuple[str, ...]:
422
+ """Every ``ww.<namespace>.<variable>`` name a template may reference."""
423
+ return tuple(
424
+ namespaced(name, variable.name)
425
+ for name, (_, namespace) in self.namespaces().items()
426
+ for variable in namespace.variables
427
+ )
428
+
429
+ def namespace_values(
430
+ self,
431
+ names: tuple[str, ...],
432
+ *,
433
+ task_id: str,
434
+ run_id: str | None,
435
+ workflow: str,
436
+ workflow_values: Mapping[str, str],
437
+ workspace: Path | None,
438
+ project: str | None,
439
+ lane: str | None = None,
440
+ ) -> dict[str, str]:
441
+ """Resolve the namespaced ``names`` for ``task_id``.
442
+
443
+ A name no listed namespace provides, or one whose resolver returns
444
+ ``None`` (not available for the task yet), is left out: an agent
445
+ step reading it stops for the operator before it starts, and an
446
+ automatic handler reading it fails.
447
+ """
448
+ wanted = set(names)
449
+ values: dict[str, str] = {}
450
+ for name, (identifier, namespace) in self.namespaces().items():
451
+ variables = [
452
+ variable
453
+ for variable in namespace.variables
454
+ if namespaced(name, variable.name) in wanted
455
+ ]
456
+ if not variables:
457
+ continue
458
+ context = ExtensionContext(
459
+ root=self.root,
460
+ store=self.store(identifier),
461
+ config=self.settings(identifier, project),
462
+ task_id=task_id,
463
+ run_id=run_id,
464
+ workflow=workflow,
465
+ lane=lane or workflow,
466
+ values=dict(workflow_values),
467
+ workspace=workspace,
468
+ )
469
+ for variable in variables:
470
+ full_name = namespaced(name, variable.name)
471
+ try:
472
+ value = variable.resolve(context)
473
+ except ConfigurationError:
474
+ raise
475
+ except Exception as error: # noqa: BLE001 - add extension context
476
+ raise ConfigurationError(
477
+ f"extension {identifier!r} failed to resolve "
478
+ f"{full_name!r}: {error}"
479
+ ) from error
480
+ if value is not None and not isinstance(value, str):
481
+ raise ConfigurationError(
482
+ f"extension {identifier!r} variable {full_name!r} "
483
+ "must resolve to a string or null"
484
+ )
485
+ if value is not None:
486
+ values[full_name] = value
487
+ return values
488
+
489
+ def configured(self, project: str | None = None) -> tuple[str, ...]:
490
+ """The extensions with a settings section at the root or in ``project``.
491
+
492
+ Those are the ones the project has chosen, and loading them is the
493
+ cost of asking.
494
+ """
495
+ return tuple(
496
+ identifier
497
+ for identifier in self.identifiers
498
+ if self.config.settings_for(identifier)
499
+ or (
500
+ project is not None
501
+ and self.project_settings(project).sections.settings_for(identifier)
502
+ )
503
+ )
504
+
505
+ def _task_context(
506
+ self,
507
+ identifier: str,
508
+ task_id: str,
509
+ workflow: str | None,
510
+ project: str | None,
511
+ lane: str | None = None,
512
+ ) -> ExtensionContext:
513
+ """The context a task-identity hook receives for ``task_id``."""
514
+ return ExtensionContext(
515
+ root=self.root,
516
+ store=self.store(identifier),
517
+ config=self.settings(identifier, project),
518
+ task_id=task_id,
519
+ workflow=workflow,
520
+ lane=lane or workflow,
521
+ workspace=(
522
+ self.config.projects_by_name[project].directory(self.root)
523
+ if project is not None
524
+ else None
525
+ ),
526
+ )
527
+
528
+ def reserved_paths(
529
+ self,
530
+ task_id: str,
531
+ workflow: str | None,
532
+ project: str | None = None,
533
+ lane: str | None = None,
534
+ ) -> tuple[Path, ...]:
535
+ """Paths configured extensions claim for ``task_id`` outside ww state."""
536
+ paths: list[Path] = []
537
+ for identifier in self.configured(project):
538
+ extension = self.get(identifier)
539
+ if extension.reserved_paths is None:
540
+ continue
541
+ context = self._task_context(identifier, task_id, workflow, project, lane)
542
+ try:
543
+ claimed = extension.reserved_paths(context)
544
+ except ConfigurationError:
545
+ raise
546
+ except Exception as error: # noqa: BLE001 - add extension context
547
+ raise ConfigurationError(
548
+ f"extension {identifier!r} failed to report reserved paths: {error}"
549
+ ) from error
550
+ paths.extend(Path(path) for path in claimed)
551
+ return tuple(paths)
552
+
553
+ def _record_keepers(self, project: str | None) -> tuple[str, ...]:
554
+ """Extensions that may hold task records: listed, or with a store.
555
+
556
+ A handler referenced from a workflow writes records even when the
557
+ extension has no settings section, so an existing store counts; a
558
+ listed extension counts even with an empty section and no store yet.
559
+ """
560
+ return tuple(
561
+ identifier
562
+ for identifier in self.identifiers
563
+ if self.config.sections.lists(identifier)
564
+ or (
565
+ project is not None
566
+ and self.project_settings(project).sections.lists(identifier)
567
+ )
568
+ or ExtensionStore(self.root, identifier).directory.is_dir()
569
+ )
570
+
571
+ def claims_task(
572
+ self,
573
+ task_id: str,
574
+ workflow: str | None,
575
+ project: str | None = None,
576
+ lane: str | None = None,
577
+ ) -> bool:
578
+ """Whether an extension in use still holds records for ``task_id``."""
579
+ for identifier in self._record_keepers(project):
580
+ extension = self.get(identifier)
581
+ if extension.claims_task is None:
582
+ continue
583
+ context = self._task_context(identifier, task_id, workflow, project, lane)
584
+ try:
585
+ claimed = extension.claims_task(context)
586
+ except ConfigurationError:
587
+ raise
588
+ except Exception as error: # noqa: BLE001 - add extension context
589
+ raise ConfigurationError(
590
+ f"extension {identifier!r} failed to report a task claim: {error}"
591
+ ) from error
592
+ if claimed:
593
+ return True
594
+ return False
595
+
596
+ def forget_task(self, task_id: str) -> None:
597
+ """Let every extension in use anywhere drop its records of a task.
598
+
599
+ Extension stores are per ww root, not per project, so each extension
600
+ listed at the root or in any project, or with a store, forgets the
601
+ task once, with the first settings that list it.
602
+ """
603
+ scopes: dict[str, str | None] = {}
604
+ for project in (None, *self.config.projects_by_name):
605
+ for identifier in self._record_keepers(project):
606
+ scopes.setdefault(identifier, project)
607
+ for identifier, project in scopes.items():
608
+ extension = self.get(identifier)
609
+ if extension.forget_task is None:
610
+ continue
611
+ context = self._task_context(identifier, task_id, None, project)
612
+ try:
613
+ extension.forget_task(context)
614
+ except ConfigurationError:
615
+ raise
616
+ except Exception as error: # noqa: BLE001 - add extension context
617
+ raise ConfigurationError(
618
+ f"extension {identifier!r} failed to forget task "
619
+ f"{task_id!r}: {error}"
620
+ ) from error
621
+
622
+ def branch_strategies(self, project: str | None = None) -> tuple[str, ...]:
623
+ """Names configured extensions accept for ``start --branch-strategy``."""
624
+ names: list[str] = []
625
+ for identifier in self.configured(project):
626
+ extension = self.get(identifier)
627
+ if extension.branch_strategies is None:
628
+ continue
629
+ try:
630
+ declared = extension.branch_strategies(
631
+ self.settings(identifier, project)
632
+ )
633
+ except ConfigurationError:
634
+ raise
635
+ except Exception as error: # noqa: BLE001 - add extension context
636
+ raise ConfigurationError(
637
+ f"extension {identifier!r} failed to report branch strategies: "
638
+ f"{error}"
639
+ ) from error
640
+ names.extend(declared)
641
+ return tuple(dict.fromkeys(names))
642
+
643
+ def store(self, identifier: str) -> ExtensionStore:
644
+ self.get(identifier)
645
+ return ExtensionStore(self.root, identifier)
646
+
647
+ def _resolve(self, value: str, section: str) -> ExtensionReference:
648
+ reference = parse_reference(value)
649
+ if reference.section != section:
650
+ raise ConfigurationError(
651
+ f"{value!r} addresses {reference.section!r}, but a "
652
+ f"{section[:-1]} is required here"
653
+ )
654
+ return reference
655
+
656
+ def _add_provider(self, provider: _ExtensionProvider) -> None:
657
+ if not _IDENTIFIER.fullmatch(provider.identifier):
658
+ raise ConfigurationError(
659
+ "invalid extension identifier in discovery metadata: "
660
+ f"{provider.identifier!r}"
661
+ )
662
+ if provider.identifier in self._providers:
663
+ raise ConfigurationError(
664
+ f"extension {provider.identifier!r} is provided more than once"
665
+ )
666
+ self._providers[provider.identifier] = provider
667
+
668
+ def _require_reference_provider(self, value: str, identifier: str) -> None:
669
+ if identifier not in self._providers:
670
+ raise ConfigurationError(
671
+ f"{value!r} references unknown extension {identifier!r}; "
672
+ f"{self._available()}"
673
+ )
674
+
675
+ def _load(self, provider: _ExtensionProvider) -> Extension:
676
+ try:
677
+ extension = provider.loader()
678
+ except ConfigurationError:
679
+ raise
680
+ except Exception as error: # noqa: BLE001 - add provider context
681
+ raise ConfigurationError(
682
+ f"extension {provider.source} failed to load: {error}"
683
+ ) from error
684
+ if not isinstance(extension, Extension):
685
+ raise ConfigurationError(
686
+ f"extension {provider.source} must resolve to an Extension"
687
+ )
688
+ if extension.identifier != provider.identifier:
689
+ raise ConfigurationError(
690
+ f"extension at {provider.source} declares {extension.identifier!r} "
691
+ f"but discovery metadata says {provider.identifier!r}"
692
+ )
693
+ if extension.api_version != EXTENSION_API_VERSION:
694
+ raise ConfigurationError(
695
+ f"extension {extension.identifier!r} requires API version "
696
+ f"{extension.api_version}; ww supports {EXTENSION_API_VERSION}"
697
+ )
698
+ return extension
699
+
700
+ def _available(self) -> str:
701
+ names = ", ".join(self.identifiers)
702
+ return f"available: {names}" if names else "no extensions are installed"
703
+
704
+
705
+ def _project_providers(root: Path) -> tuple[_ExtensionProvider, ...]:
706
+ directory = root / EXTENSION_DIRECTORY
707
+ if not directory.is_dir():
708
+ return ()
709
+ found = []
710
+ ww_checkout = is_ww_checkout(root)
711
+ for module_path in sorted(directory.glob("*/*/extension.py")):
712
+ if module_path.resolve() == _bundled_source_path().resolve():
713
+ continue
714
+ name, vendor = module_path.parent.name, module_path.parent.parent.name
715
+ if ww_checkout and vendor == BUNDLED_VENDOR:
716
+ # Another checkout of ww itself: its ext/ww is a copy of ww's own
717
+ # bundled extensions, possibly another version, not something the
718
+ # project adds. The running install's bundled copy is used.
719
+ continue
720
+ identifier = f"{vendor}/{name}"
721
+ relative = module_path.relative_to(root).as_posix()
722
+ found.append(
723
+ _ExtensionProvider(
724
+ identifier,
725
+ f"project:{relative}",
726
+ _file_fingerprint(module_path),
727
+ _module_loader(module_path, identifier),
728
+ )
729
+ )
730
+ return tuple(found)
731
+
732
+
733
+ def _bundled_providers() -> tuple[_ExtensionProvider, ...]:
734
+ resource = (
735
+ files("ww")
736
+ .joinpath("_bundled_extensions")
737
+ .joinpath("ww")
738
+ .joinpath("git")
739
+ .joinpath("extension.py")
740
+ )
741
+ if resource.is_file():
742
+ contents = resource.read_bytes()
743
+
744
+ def load_bundled() -> Extension:
745
+ with as_file(resource) as path:
746
+ return _load_module(path, "ww/git")
747
+
748
+ return (
749
+ _ExtensionProvider(
750
+ "ww/git", "bundled:ww/git", _bytes_fingerprint(contents), load_bundled
751
+ ),
752
+ )
753
+ source = _bundled_source_path()
754
+ if source.is_file():
755
+ return (
756
+ _ExtensionProvider(
757
+ "ww/git",
758
+ "bundled:ww/git",
759
+ _file_fingerprint(source),
760
+ lambda: _load_module(source, "ww/git"),
761
+ ),
762
+ )
763
+ return ()
764
+
765
+
766
+ def is_ww_checkout(root: Path) -> bool:
767
+ """Whether ``root`` is a source checkout of ww itself.
768
+
769
+ Two checkouts of ww may sit side by side, one for developing ww and one
770
+ kept on ``dev``, each with its own install; either install then runs in
771
+ the other checkout. The test is one file only ww's source tree holds.
772
+ """
773
+ return (root / "src" / "ww" / "extensions" / "registry.py").is_file()
774
+
775
+
776
+ def _bundled_source_path() -> Path:
777
+ return Path(__file__).parents[3] / "ext" / "ww" / "git" / "extension.py"
778
+
779
+
780
+ def _load_module(path: Path, identifier: str) -> Extension:
781
+ module_name = f"ww_ext_{identifier.replace('/', '_').replace('-', '_')}"
782
+ spec = importlib.util.spec_from_file_location(module_name, path)
783
+ if spec is None or spec.loader is None:
784
+ raise ConfigurationError(f"cannot load extension module: {path}")
785
+ module = importlib.util.module_from_spec(spec)
786
+ written = sys.dont_write_bytecode
787
+ sys.dont_write_bytecode = True
788
+ sys.modules[module_name] = module
789
+ try:
790
+ spec.loader.exec_module(module)
791
+ except Exception as error: # noqa: BLE001 - reported with its own context
792
+ sys.modules.pop(module_name, None)
793
+ raise ConfigurationError(
794
+ f"extension {path} failed to import: {error}"
795
+ ) from error
796
+ finally:
797
+ sys.dont_write_bytecode = written
798
+ extension = getattr(module, "EXTENSION", None)
799
+ if not isinstance(extension, Extension):
800
+ raise ConfigurationError(f"extension {path} must define EXTENSION")
801
+ return extension
802
+
803
+
804
+ def _installed_providers() -> tuple[_ExtensionProvider, ...]:
805
+ found = []
806
+ for point in entry_points(group=ENTRY_POINT_GROUP):
807
+ match = _ENTRY_POINT_NAME.fullmatch(point.name)
808
+ if match is None:
809
+ raise ConfigurationError(
810
+ f"extension entry point {point.name!r} must use vendor.name"
811
+ )
812
+ identifier = f"{match['vendor']}/{match['name']}"
813
+ distribution = getattr(point, "dist", None)
814
+ distribution_name = getattr(distribution, "name", None) or "unknown-package"
815
+ distribution_version = getattr(distribution, "version", None) or "unknown"
816
+ source = f"package:{distribution_name}@{distribution_version}:{point.value}"
817
+ found.append(
818
+ _ExtensionProvider(
819
+ identifier,
820
+ source,
821
+ _fingerprint(source),
822
+ _entry_point_loader(point),
823
+ )
824
+ )
825
+ return tuple(found)
826
+
827
+
828
+ def _load_entry_point(point: EntryPoint) -> Extension:
829
+ loaded = point.load()
830
+ extension = loaded() if callable(loaded) else loaded
831
+ if not isinstance(extension, Extension):
832
+ raise ConfigurationError(
833
+ f"entry point {point.name!r} must resolve to an Extension"
834
+ )
835
+ return extension
836
+
837
+
838
+ def _extension_loader(extension: Extension) -> Callable[[], Extension]:
839
+ return lambda: extension
840
+
841
+
842
+ def _module_loader(path: Path, identifier: str) -> Callable[[], Extension]:
843
+ return lambda: _load_module(path, identifier)
844
+
845
+
846
+ def _entry_point_loader(point: EntryPoint) -> Callable[[], Extension]:
847
+ return lambda: _load_entry_point(point)
848
+
849
+
850
+ def _file_fingerprint(path: Path) -> str:
851
+ try:
852
+ return _bytes_fingerprint(path.read_bytes())
853
+ except OSError as error:
854
+ raise ConfigurationError(
855
+ f"cannot read extension source {path}: {error}"
856
+ ) from error
857
+
858
+
859
+ def _bytes_fingerprint(value: bytes) -> str:
860
+ return "sha256:" + hashlib.sha256(value).hexdigest()
861
+
862
+
863
+ def _fingerprint(value: str) -> str:
864
+ return _bytes_fingerprint(value.encode())