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
|
@@ -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())
|