ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/project_config.py
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""The project configuration file, ``ww.json``.
|
|
3
|
+
|
|
4
|
+
ww's settings file, separate from ``ww.yaml``. The YAML
|
|
5
|
+
describes what a workflow *does*, while this describes how the tools around
|
|
6
|
+
it behave. It
|
|
7
|
+
contains ww-wide settings, built-in execution hints, and extension settings.
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"enabled": true,
|
|
12
|
+
"runtime": "single",
|
|
13
|
+
"update_check": true,
|
|
14
|
+
"executable": "ww-agentic-workflows-dev",
|
|
15
|
+
"limits": {"rounds": 3, "fixes": 3},
|
|
16
|
+
"agent_hooks": {"check_unfinished": true, "recent_days": 3},
|
|
17
|
+
"pages": {"worker_requirements": "pointer"},
|
|
18
|
+
"workflows": {"catchall": {"enabled": false}},
|
|
19
|
+
"projects": [
|
|
20
|
+
{"name": "backend", "path": "./backend", "description": "Python API service."}
|
|
21
|
+
],
|
|
22
|
+
"extensions": {
|
|
23
|
+
"ww/git": {"base_branches": {"default": "main"}, "separate_branch": true}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
``enabled`` is ``true`` (agents use ww for project work), ``false`` (they
|
|
29
|
+
never do), or ``"on_request"`` (ww is available, but agents use it only when
|
|
30
|
+
the user explicitly asks for it).
|
|
31
|
+
|
|
32
|
+
``limits`` holds positive integers ``rounds`` and ``fixes`` and the non-negative
|
|
33
|
+
``auto_retries``. ``rounds`` is the round limit of a
|
|
34
|
+
step ``loop`` that sets no ``max_rounds`` of its own. ``fixes`` is how many
|
|
35
|
+
times a step's completion may be rejected for a failed check before ww stops
|
|
36
|
+
for the operator, unless a rule sets its own ``max_fixes``. ``auto_retries``
|
|
37
|
+
(default 0) is how many times ww retries a failed automatic step itself,
|
|
38
|
+
recording each failure on the step, before the step's own failure handling (a
|
|
39
|
+
repair assignment, or the operator) applies.
|
|
40
|
+
|
|
41
|
+
``agent_hooks`` tunes what the ``session-start`` hook reports.
|
|
42
|
+
``check_unfinished`` (default ``true``) is whether it scans for unfinished
|
|
43
|
+
tasks at all; ``recent_days`` (default 3) is how many days back a task's last
|
|
44
|
+
update or an interruption counts as recent, for that hook, ``discover``,
|
|
45
|
+
``lookup``, and ``ww interrupted``.
|
|
46
|
+
|
|
47
|
+
``pages`` tunes what pages print. ``worker_requirements`` is ``full`` (the
|
|
48
|
+
default: the first page of every delegated worker assignment prints the task
|
|
49
|
+
requirements in full) or ``pointer`` (it carries the pointer to ``ww
|
|
50
|
+
requirements`` instead); the manager's pages are unaffected. ``init`` writes the
|
|
51
|
+
key only once it is set.
|
|
52
|
+
|
|
53
|
+
``workflows`` switches off the workflows ww provides to every project, such
|
|
54
|
+
as ``catchall``; each is on unless its entry says ``"enabled": false``.
|
|
55
|
+
|
|
56
|
+
``projects`` are the directories, usually repositories, a task may work in.
|
|
57
|
+
They are optional and machine-specific, which is why they live here rather than
|
|
58
|
+
in ``ww.yaml``: the same workflows can run in checkouts laid out
|
|
59
|
+
differently on each machine.
|
|
60
|
+
|
|
61
|
+
An extension's settings are handed to it untouched. ww validates the shape of
|
|
62
|
+
the file — that ``extensions`` is a mapping of mappings — and nothing about what
|
|
63
|
+
is inside a section, because it cannot know a third party's schema. Each
|
|
64
|
+
extension validates its own settings and reports its own errors.
|
|
65
|
+
|
|
66
|
+
``task_format`` is the generated task ID format: a template over the
|
|
67
|
+
``{{timestamp}}``, ``{{digit}}``, and ``{{uuid}}`` placeholders, or ``explicit`` to
|
|
68
|
+
require an ID for every task. It is a setting of the checkout and of the
|
|
69
|
+
tracker a repository uses, not of what a workflow does, so it lives here.
|
|
70
|
+
|
|
71
|
+
A configured project may carry its own ``ww.json`` and
|
|
72
|
+
``ww.local.json``. Of those files ww reads only the keys in
|
|
73
|
+
``PROJECT_FILE_KEYS``, ``extensions`` applied over the root's and
|
|
74
|
+
``task_format`` replacing it, for work done in that project; every other key
|
|
75
|
+
describes the project as a ww root of its own, and the workspace root owns
|
|
76
|
+
those.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
from __future__ import annotations
|
|
80
|
+
|
|
81
|
+
import json
|
|
82
|
+
import re
|
|
83
|
+
from copy import deepcopy
|
|
84
|
+
from dataclasses import dataclass, field
|
|
85
|
+
from pathlib import Path
|
|
86
|
+
from typing import Any, Literal
|
|
87
|
+
|
|
88
|
+
from ww.builtin_workflows import builtin_workflow_names
|
|
89
|
+
from ww.config_files import (
|
|
90
|
+
SETTINGS_FILE,
|
|
91
|
+
ConfigurationLevel,
|
|
92
|
+
configuration_file_exists,
|
|
93
|
+
display_path,
|
|
94
|
+
project_settings_levels,
|
|
95
|
+
read_configuration_file,
|
|
96
|
+
settings_levels,
|
|
97
|
+
)
|
|
98
|
+
from ww.errors import ConfigurationError
|
|
99
|
+
from ww.runtimes import DEFAULT_RUNTIME, RUNTIME_INSTRUCTIONS
|
|
100
|
+
from ww.validation import expect_normalized_name, is_positive_int, is_strict_int
|
|
101
|
+
|
|
102
|
+
FILE_NAME = SETTINGS_FILE
|
|
103
|
+
BUILTIN_NAMES = frozenset({"init", "workflow_summary"})
|
|
104
|
+
# ``init`` only restates requirements; the workflow summary is what people
|
|
105
|
+
# read, so it follows the run's ordinary worker selection.
|
|
106
|
+
BUILTIN_DEFAULTS: dict[str, dict[str, str]] = {
|
|
107
|
+
"init": {"model": "cheapest", "reasoning": "low"},
|
|
108
|
+
"workflow_summary": {"model": "auto", "reasoning": "auto"},
|
|
109
|
+
}
|
|
110
|
+
DEFAULT_ROUNDS = 3
|
|
111
|
+
DEFAULT_FIXES = 3
|
|
112
|
+
DEFAULT_RECENT_DAYS = 3
|
|
113
|
+
# A ``task_format`` that forbids generated IDs: every task is started with an
|
|
114
|
+
# explicit ID, or binds one in its workflow's first step.
|
|
115
|
+
EXPLICIT_TASK_FORMAT = "explicit"
|
|
116
|
+
TASK_FORMAT_PLACEHOLDERS = frozenset({"{{digit}}", "{{timestamp}}", "{{uuid}}"})
|
|
117
|
+
# A placeholder in double braces, e.g. "{{digit}}" in "TASK-{{digit}}".
|
|
118
|
+
_TASK_FORMAT_TOKEN = re.compile(r"\{\{[^{}]*\}\}")
|
|
119
|
+
# The keys ww takes from a configured project's own settings files. Anything
|
|
120
|
+
# else in such a file describes the project as a ww root of its own.
|
|
121
|
+
PROJECT_FILE_KEYS = ("extensions", "task_format")
|
|
122
|
+
# ``enabled``: ww is used by default (``true``), never (``false``), or only
|
|
123
|
+
# when the user explicitly asks for it (``"on_request"``).
|
|
124
|
+
ON_REQUEST: Literal["on_request"] = "on_request"
|
|
125
|
+
Enabled = bool | Literal["on_request"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class ProjectDefinition:
|
|
130
|
+
"""One directory, usually a repository, a task may work in.
|
|
131
|
+
|
|
132
|
+
Without projects every task works in the project root, which is also
|
|
133
|
+
where ww keeps its configuration and state.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
name: str
|
|
137
|
+
path: str
|
|
138
|
+
description: str = ""
|
|
139
|
+
|
|
140
|
+
def directory(self, root: Path) -> Path:
|
|
141
|
+
"""The project's directory, resolved against ``root`` when relative."""
|
|
142
|
+
return (root / self.path).resolve()
|
|
143
|
+
|
|
144
|
+
def to_dict(self) -> dict[str, str]:
|
|
145
|
+
return {"name": self.name, "path": self.path, "description": self.description}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class ExtensionSections:
|
|
150
|
+
"""The ``extensions`` section of one settings source, keyed by extension.
|
|
151
|
+
|
|
152
|
+
``source`` names the file or files in messages, so a section that applies
|
|
153
|
+
to nothing is reported where it was written: at the root or in a project.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
sections: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
157
|
+
source: str = SETTINGS_FILE
|
|
158
|
+
|
|
159
|
+
def settings_for(self, identifier: str) -> dict[str, Any]:
|
|
160
|
+
"""Return one extension's settings, addressed by id or by bare name.
|
|
161
|
+
|
|
162
|
+
``"ww/git"`` is the canonical key, because two vendors may each ship an
|
|
163
|
+
extension called ``git``. A bare ``"git"`` also resolves, as long as it
|
|
164
|
+
is unambiguous for the extension being asked about.
|
|
165
|
+
"""
|
|
166
|
+
if identifier in self.sections:
|
|
167
|
+
return deepcopy(self.sections[identifier])
|
|
168
|
+
_, _, name = identifier.partition("/")
|
|
169
|
+
bare = [key for key in self.sections if "/" not in key and key == name]
|
|
170
|
+
if bare:
|
|
171
|
+
return deepcopy(self.sections[bare[0]])
|
|
172
|
+
return {}
|
|
173
|
+
|
|
174
|
+
def lists(self, identifier: str) -> bool:
|
|
175
|
+
"""Whether a section names the extension, even an empty one."""
|
|
176
|
+
_, _, name = identifier.partition("/")
|
|
177
|
+
return identifier in self.sections or name in self.sections
|
|
178
|
+
|
|
179
|
+
def validate_against(self, identifiers: tuple[str, ...]) -> None:
|
|
180
|
+
"""Reject sections that name no installed extension, or name two.
|
|
181
|
+
|
|
182
|
+
A settings block that silently applies to nothing is worse than an
|
|
183
|
+
error: the file looks configured and the behaviour never changes.
|
|
184
|
+
"""
|
|
185
|
+
for key in sorted(self.sections):
|
|
186
|
+
if "/" in key:
|
|
187
|
+
if key not in identifiers:
|
|
188
|
+
raise ConfigurationError(
|
|
189
|
+
f"{self.source} configures unknown extension {key!r}; "
|
|
190
|
+
+ _available(identifiers)
|
|
191
|
+
)
|
|
192
|
+
continue
|
|
193
|
+
matches = [
|
|
194
|
+
identifier
|
|
195
|
+
for identifier in identifiers
|
|
196
|
+
if identifier.partition("/")[2] == key
|
|
197
|
+
]
|
|
198
|
+
if not matches:
|
|
199
|
+
raise ConfigurationError(
|
|
200
|
+
f"{self.source} configures unknown extension {key!r}; "
|
|
201
|
+
+ _available(identifiers)
|
|
202
|
+
)
|
|
203
|
+
if len(matches) > 1:
|
|
204
|
+
raise ConfigurationError(
|
|
205
|
+
f"{self.source} key {key!r} is ambiguous; use the full "
|
|
206
|
+
"identifier: " + ", ".join(sorted(matches))
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class ProjectSettings:
|
|
212
|
+
"""What a configured project's own settings files contribute.
|
|
213
|
+
|
|
214
|
+
Exactly the keys in ``PROJECT_FILE_KEYS``: the project's extension
|
|
215
|
+
sections, and its task ID format when it sets one (``None`` means the
|
|
216
|
+
root's applies).
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
project: str
|
|
220
|
+
sections: ExtensionSections
|
|
221
|
+
task_format: str | None = None
|
|
222
|
+
# The files read, repo level then local; empty when the project has none.
|
|
223
|
+
sources: tuple[Path, ...] = ()
|
|
224
|
+
# Each file's own section, in the same order, so a section that applies
|
|
225
|
+
# to nothing is reported in the file that holds it.
|
|
226
|
+
levels: tuple[ExtensionSections, ...] = ()
|
|
227
|
+
|
|
228
|
+
def validate_against(self, identifiers: tuple[str, ...]) -> None:
|
|
229
|
+
"""Reject sections that name no installed extension, file by file."""
|
|
230
|
+
for level in self.levels:
|
|
231
|
+
level.validate_against(identifiers)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class Limits:
|
|
236
|
+
"""The ``limits`` setting: how far ww goes before the operator decides."""
|
|
237
|
+
|
|
238
|
+
# A step loop's rounds when it sets no ``max_rounds`` of its own.
|
|
239
|
+
rounds: int = DEFAULT_ROUNDS
|
|
240
|
+
# Rejected completions a check allows when its rule sets no ``max_fixes``.
|
|
241
|
+
fixes: int = DEFAULT_FIXES
|
|
242
|
+
# How often ww itself retries a failed automatic step before the step's
|
|
243
|
+
# failure handling (a repair assignment, or the operator) applies.
|
|
244
|
+
auto_retries: int = 0
|
|
245
|
+
|
|
246
|
+
def to_dict(self) -> dict[str, int]:
|
|
247
|
+
# The retries appear once set, so a default file stays as ``init`` wrote it.
|
|
248
|
+
return {
|
|
249
|
+
"rounds": self.rounds,
|
|
250
|
+
"fixes": self.fixes,
|
|
251
|
+
**({"auto_retries": self.auto_retries} if self.auto_retries else {}),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class AgentHooks:
|
|
257
|
+
"""The ``agent_hooks`` setting: what the session-start hook reports."""
|
|
258
|
+
|
|
259
|
+
# Whether session-start scans for unfinished tasks at all.
|
|
260
|
+
check_unfinished: bool = True
|
|
261
|
+
# How many days back a task's last update, or an interruption, is recent.
|
|
262
|
+
recent_days: int = DEFAULT_RECENT_DAYS
|
|
263
|
+
|
|
264
|
+
def to_dict(self) -> dict[str, object]:
|
|
265
|
+
return {
|
|
266
|
+
"check_unfinished": self.check_unfinished,
|
|
267
|
+
"recent_days": self.recent_days,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
WORKER_REQUIREMENTS = ("full", "pointer")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class Pages:
|
|
276
|
+
"""The ``pages`` setting: what the pages ww prints carry."""
|
|
277
|
+
|
|
278
|
+
# ``full`` prints the task requirements on a delegated worker assignment's
|
|
279
|
+
# first page; ``pointer`` names the command that prints them instead.
|
|
280
|
+
worker_requirements: Literal["full", "pointer"] = "full"
|
|
281
|
+
|
|
282
|
+
def to_dict(self) -> dict[str, str]:
|
|
283
|
+
# Written once set, so a default file stays as ``init`` wrote it.
|
|
284
|
+
return (
|
|
285
|
+
{"worker_requirements": self.worker_requirements}
|
|
286
|
+
if self.worker_requirements != "full"
|
|
287
|
+
else {}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class ProjectConfig:
|
|
293
|
+
"""Settings that apply to a project rather than to one workflow."""
|
|
294
|
+
|
|
295
|
+
extensions: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
296
|
+
builtins: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
297
|
+
limits: Limits = Limits()
|
|
298
|
+
agent_hooks: AgentHooks = AgentHooks()
|
|
299
|
+
pages: Pages = Pages()
|
|
300
|
+
# ``false`` tells agents not to use ww in this project; ``start`` refuses.
|
|
301
|
+
# ``"on_request"`` keeps ww available, but agents use it only when the
|
|
302
|
+
# user explicitly asks for it.
|
|
303
|
+
enabled: Enabled = True
|
|
304
|
+
# ``rules.check_guidance``: the operator's own words for the commands
|
|
305
|
+
# ``ww-scriptize-rules`` builds, which it reads from ``ww rules --json``.
|
|
306
|
+
rule_check_guidance: str | None = None
|
|
307
|
+
projects: tuple[ProjectDefinition, ...] = ()
|
|
308
|
+
# The runtime ``start`` uses when ``--runtime`` is omitted.
|
|
309
|
+
runtime: str = DEFAULT_RUNTIME
|
|
310
|
+
# ``false`` silences the notice that the ww checkout is behind its remote.
|
|
311
|
+
update_check: bool = True
|
|
312
|
+
# Analyse operator feedback into candidates, never automatic rules.
|
|
313
|
+
feedback_learning: bool = True
|
|
314
|
+
# Built-in workflows switched off for this project.
|
|
315
|
+
disabled_workflows: frozenset[str] = frozenset()
|
|
316
|
+
# The ww binary this project runs: a command on PATH or a path. ``None``
|
|
317
|
+
# means the project launcher, ``./ww``, which falls back to the standard
|
|
318
|
+
# name.
|
|
319
|
+
executable: str | None = None
|
|
320
|
+
# The generated task ID format; ``None`` keeps ww's ``TASK-{{timestamp}}``.
|
|
321
|
+
task_format: str | None = None
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def projects_by_name(self) -> dict[str, ProjectDefinition]:
|
|
325
|
+
return {project.name: project for project in self.projects}
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def disabled(self) -> bool:
|
|
329
|
+
"""Whether agents must not use ww here at all."""
|
|
330
|
+
return self.enabled is False
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def on_request(self) -> bool:
|
|
334
|
+
"""Whether agents use ww only when the user explicitly asks for it."""
|
|
335
|
+
return self.enabled == ON_REQUEST
|
|
336
|
+
|
|
337
|
+
def workflow_enabled(self, name: str) -> bool:
|
|
338
|
+
"""Whether a built-in workflow is offered in this project."""
|
|
339
|
+
return name not in self.disabled_workflows
|
|
340
|
+
|
|
341
|
+
def builtin_settings(self, name: str) -> dict[str, str]:
|
|
342
|
+
"""Return a built-in's complete model/reasoning request."""
|
|
343
|
+
return {**BUILTIN_DEFAULTS[name], **self.builtins.get(name, {})}
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def sections(self) -> ExtensionSections:
|
|
347
|
+
return ExtensionSections(self.extensions, FILE_NAME)
|
|
348
|
+
|
|
349
|
+
def settings_for(self, identifier: str) -> dict[str, Any]:
|
|
350
|
+
"""Return one extension's settings, addressed by id or by bare name."""
|
|
351
|
+
return self.sections.settings_for(identifier)
|
|
352
|
+
|
|
353
|
+
def validate_against(self, identifiers: tuple[str, ...]) -> None:
|
|
354
|
+
"""Reject sections that name no installed extension, or name two."""
|
|
355
|
+
self.sections.validate_against(identifiers)
|
|
356
|
+
|
|
357
|
+
def unknown_project(self, project: str) -> str:
|
|
358
|
+
"""The message for a ``--project`` value no entry of ``projects`` has."""
|
|
359
|
+
configured = ", ".join(entry.name for entry in self.projects)
|
|
360
|
+
return f"unknown project {project!r}; " + (
|
|
361
|
+
f"configured projects: {configured}"
|
|
362
|
+
if configured
|
|
363
|
+
else f"no projects are configured in {FILE_NAME}"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def project_settings(self, root: Path, project: str) -> ProjectSettings:
|
|
367
|
+
"""Load what the configured ``project``'s own settings files contribute."""
|
|
368
|
+
definition = self.projects_by_name.get(project)
|
|
369
|
+
if definition is None:
|
|
370
|
+
raise ConfigurationError(self.unknown_project(project))
|
|
371
|
+
return load_project_settings(root, definition)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def load_project_config(path: Path) -> ProjectConfig:
|
|
375
|
+
"""Load the settings levels around the repo file ``path``, deep-merged.
|
|
376
|
+
|
|
377
|
+
The user, repo, and local files apply in that order, each optional;
|
|
378
|
+
without any of them the defaults apply.
|
|
379
|
+
"""
|
|
380
|
+
raw, sources = compose_settings(path)
|
|
381
|
+
if not sources:
|
|
382
|
+
return ProjectConfig()
|
|
383
|
+
return _parse_settings(raw, " + ".join(str(source) for source in sources))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def compose_settings(path: Path) -> tuple[dict[str, Any], tuple[Path, ...]]:
|
|
387
|
+
"""Deep-merge the settings levels around ``path`` and name the files read.
|
|
388
|
+
|
|
389
|
+
Nested objects merge key by key; any other value, lists included, replaces
|
|
390
|
+
the one above it.
|
|
391
|
+
"""
|
|
392
|
+
return _compose_levels(settings_levels(path))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def load_project_settings(root: Path, project: ProjectDefinition) -> ProjectSettings:
|
|
396
|
+
"""Read the keys in ``PROJECT_FILE_KEYS`` from a project's own settings files.
|
|
397
|
+
|
|
398
|
+
The project's repo and local files apply in that order, each optional:
|
|
399
|
+
extension sections merge key by key and a later ``task_format`` replaces
|
|
400
|
+
an earlier one. Every other key describes the project as a ww root of its
|
|
401
|
+
own and is left alone. Errors name the project so a mistake is found in
|
|
402
|
+
the right directory.
|
|
403
|
+
"""
|
|
404
|
+
sources: list[Path] = []
|
|
405
|
+
levels: list[ExtensionSections] = []
|
|
406
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
407
|
+
task_format: str | None = None
|
|
408
|
+
for level in project_settings_levels(project.directory(root)):
|
|
409
|
+
raw = _read_level(level)
|
|
410
|
+
if raw is None:
|
|
411
|
+
continue
|
|
412
|
+
label = f"{display_path(level.path, root)} (project {project.name!r})"
|
|
413
|
+
sections = ExtensionSections(_parse_extensions(raw, label), label)
|
|
414
|
+
_deep_merge(merged, sections.sections)
|
|
415
|
+
if "task_format" in raw:
|
|
416
|
+
task_format = _parse_task_format(raw["task_format"], label)
|
|
417
|
+
sources.append(level.path)
|
|
418
|
+
levels.append(sections)
|
|
419
|
+
source = " + ".join(display_path(path, root) for path in sources)
|
|
420
|
+
return ProjectSettings(
|
|
421
|
+
project.name,
|
|
422
|
+
ExtensionSections(merged, f"{source} (project {project.name!r})"),
|
|
423
|
+
task_format,
|
|
424
|
+
tuple(sources),
|
|
425
|
+
tuple(levels),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def overlay_settings(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
|
430
|
+
"""Apply one extension's project settings over its root settings.
|
|
431
|
+
|
|
432
|
+
The same rule as between configuration levels: nested objects merge key
|
|
433
|
+
by key, any other value replaces the one above it, so a project states
|
|
434
|
+
only what differs.
|
|
435
|
+
"""
|
|
436
|
+
merged = deepcopy(base)
|
|
437
|
+
_deep_merge(merged, overlay)
|
|
438
|
+
return merged
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _compose_levels(
|
|
442
|
+
levels: tuple[ConfigurationLevel, ...],
|
|
443
|
+
) -> tuple[dict[str, Any], tuple[Path, ...]]:
|
|
444
|
+
merged: dict[str, Any] = {}
|
|
445
|
+
sources: list[Path] = []
|
|
446
|
+
for level in levels:
|
|
447
|
+
raw = _read_level(level)
|
|
448
|
+
if raw is None:
|
|
449
|
+
continue
|
|
450
|
+
_deep_merge(merged, raw)
|
|
451
|
+
sources.append(level.path)
|
|
452
|
+
return merged, tuple(sources)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _read_level(level: ConfigurationLevel) -> dict[str, Any] | None:
|
|
456
|
+
"""The JSON object one settings file holds, or ``None`` when it is absent."""
|
|
457
|
+
if not configuration_file_exists(level.path):
|
|
458
|
+
return None
|
|
459
|
+
try:
|
|
460
|
+
raw = json.loads(read_configuration_file(level.path))
|
|
461
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
462
|
+
raise ConfigurationError(f"invalid {level.path}: {error}") from error
|
|
463
|
+
if not isinstance(raw, dict):
|
|
464
|
+
raise ConfigurationError(f"{level.path} must contain a JSON object")
|
|
465
|
+
return raw
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _deep_merge(target: dict[str, Any], overlay: dict[str, Any]) -> None:
|
|
469
|
+
for key, value in overlay.items():
|
|
470
|
+
current = target.get(key)
|
|
471
|
+
if isinstance(current, dict) and isinstance(value, dict):
|
|
472
|
+
_deep_merge(current, value)
|
|
473
|
+
else:
|
|
474
|
+
target[key] = deepcopy(value)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _parse_extensions(raw: dict[str, Any], path: str) -> dict[str, dict[str, Any]]:
|
|
478
|
+
extensions = raw.get("extensions", {})
|
|
479
|
+
if not isinstance(extensions, dict):
|
|
480
|
+
raise ConfigurationError(f"{path}.extensions must be an object")
|
|
481
|
+
for key, value in extensions.items():
|
|
482
|
+
if not isinstance(value, dict):
|
|
483
|
+
raise ConfigurationError(
|
|
484
|
+
f"{path}.extensions[{key!r}] must be an object of settings"
|
|
485
|
+
)
|
|
486
|
+
return dict(extensions)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _parse_settings(raw: dict[str, Any], path: str) -> ProjectConfig:
|
|
490
|
+
unknown = set(raw) - {
|
|
491
|
+
"enabled",
|
|
492
|
+
"runtime",
|
|
493
|
+
"extensions",
|
|
494
|
+
"builtins",
|
|
495
|
+
"limits",
|
|
496
|
+
"agent_hooks",
|
|
497
|
+
"pages",
|
|
498
|
+
"projects",
|
|
499
|
+
"update_check",
|
|
500
|
+
"feedback_learning",
|
|
501
|
+
"workflows",
|
|
502
|
+
"executable",
|
|
503
|
+
"task_format",
|
|
504
|
+
"rules",
|
|
505
|
+
}
|
|
506
|
+
if unknown:
|
|
507
|
+
raise ConfigurationError(
|
|
508
|
+
f"{path} has unknown key(s): {', '.join(sorted(unknown))}"
|
|
509
|
+
)
|
|
510
|
+
extensions = _parse_extensions(raw, path)
|
|
511
|
+
enabled = _parse_enabled(raw.get("enabled", True), path)
|
|
512
|
+
update_check = raw.get("update_check", True)
|
|
513
|
+
if not isinstance(update_check, bool):
|
|
514
|
+
raise ConfigurationError(f"{path}.update_check must be true or false")
|
|
515
|
+
feedback_learning = raw.get("feedback_learning", True)
|
|
516
|
+
if not isinstance(feedback_learning, bool):
|
|
517
|
+
raise ConfigurationError(f"{path}.feedback_learning must be true or false")
|
|
518
|
+
runtime = raw.get("runtime", DEFAULT_RUNTIME)
|
|
519
|
+
if runtime not in RUNTIME_INSTRUCTIONS:
|
|
520
|
+
raise ConfigurationError(
|
|
521
|
+
f"{path}.runtime must be one of: " + ", ".join(RUNTIME_INSTRUCTIONS)
|
|
522
|
+
)
|
|
523
|
+
disabled_workflows = _parse_workflows(raw.get("workflows"), path)
|
|
524
|
+
builtins = raw.get("builtins", {})
|
|
525
|
+
if not isinstance(builtins, dict):
|
|
526
|
+
raise ConfigurationError(f"{path}.builtins must be an object")
|
|
527
|
+
unknown_builtins = set(builtins) - BUILTIN_NAMES
|
|
528
|
+
if unknown_builtins:
|
|
529
|
+
raise ConfigurationError(
|
|
530
|
+
f"{path}.builtins has unknown name(s): "
|
|
531
|
+
+ ", ".join(sorted(unknown_builtins))
|
|
532
|
+
)
|
|
533
|
+
normalized: dict[str, dict[str, str]] = {}
|
|
534
|
+
for name, value in builtins.items():
|
|
535
|
+
if not isinstance(value, dict):
|
|
536
|
+
raise ConfigurationError(f"{path}.builtins.{name} must be an object")
|
|
537
|
+
unknown_fields = set(value) - {"model", "reasoning"}
|
|
538
|
+
if unknown_fields:
|
|
539
|
+
raise ConfigurationError(
|
|
540
|
+
f"{path}.builtins.{name} has unknown key(s): "
|
|
541
|
+
+ ", ".join(sorted(unknown_fields))
|
|
542
|
+
)
|
|
543
|
+
for hint_name, hint in value.items():
|
|
544
|
+
if not isinstance(hint, str) or not hint.strip():
|
|
545
|
+
raise ConfigurationError(
|
|
546
|
+
f"{path}.builtins.{name}.{hint_name} must be a non-empty string"
|
|
547
|
+
)
|
|
548
|
+
normalized[name] = dict(value)
|
|
549
|
+
return ProjectConfig(
|
|
550
|
+
extensions=extensions,
|
|
551
|
+
builtins=normalized,
|
|
552
|
+
limits=_parse_limits(raw.get("limits"), path),
|
|
553
|
+
agent_hooks=_parse_agent_hooks(raw.get("agent_hooks"), path),
|
|
554
|
+
pages=_parse_pages(raw.get("pages"), path),
|
|
555
|
+
enabled=enabled,
|
|
556
|
+
projects=_parse_projects(raw.get("projects"), path),
|
|
557
|
+
runtime=runtime,
|
|
558
|
+
update_check=update_check,
|
|
559
|
+
feedback_learning=feedback_learning,
|
|
560
|
+
disabled_workflows=disabled_workflows,
|
|
561
|
+
executable=_parse_executable(raw.get("executable"), path),
|
|
562
|
+
task_format=_parse_task_format(raw.get("task_format"), path),
|
|
563
|
+
**_parse_rules(raw.get("rules"), path),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _parse_limits(data: Any, path: str) -> Limits:
|
|
568
|
+
"""``limits``: optional positive ``rounds`` and ``fixes``, and ``auto_retries``."""
|
|
569
|
+
if data is None:
|
|
570
|
+
return Limits()
|
|
571
|
+
if not isinstance(data, dict):
|
|
572
|
+
raise ConfigurationError(f"{path}.limits must be an object")
|
|
573
|
+
unknown = set(data) - {"rounds", "fixes", "auto_retries"}
|
|
574
|
+
if unknown:
|
|
575
|
+
raise ConfigurationError(
|
|
576
|
+
f"{path}.limits has unknown key(s): {', '.join(sorted(unknown))}"
|
|
577
|
+
)
|
|
578
|
+
for key, value in data.items():
|
|
579
|
+
if key == "auto_retries":
|
|
580
|
+
if not is_strict_int(value) or value < 0:
|
|
581
|
+
raise ConfigurationError(
|
|
582
|
+
f"{path}.limits.auto_retries must be a non-negative integer"
|
|
583
|
+
)
|
|
584
|
+
elif not is_positive_int(value):
|
|
585
|
+
raise ConfigurationError(f"{path}.limits.{key} must be a positive integer")
|
|
586
|
+
return Limits(**data)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _parse_pages(data: Any, path: str) -> Pages:
|
|
590
|
+
"""``pages``: an optional ``worker_requirements``, ``full`` or ``pointer``."""
|
|
591
|
+
if data is None:
|
|
592
|
+
return Pages()
|
|
593
|
+
if not isinstance(data, dict):
|
|
594
|
+
raise ConfigurationError(f"{path}.pages must be an object")
|
|
595
|
+
unknown = set(data) - {"worker_requirements"}
|
|
596
|
+
if unknown:
|
|
597
|
+
raise ConfigurationError(
|
|
598
|
+
f"{path}.pages has unknown key(s): {', '.join(sorted(unknown))}"
|
|
599
|
+
)
|
|
600
|
+
value = data.get("worker_requirements", "full")
|
|
601
|
+
if value not in WORKER_REQUIREMENTS:
|
|
602
|
+
raise ConfigurationError(
|
|
603
|
+
f"{path}.pages.worker_requirements must be one of: "
|
|
604
|
+
+ ", ".join(WORKER_REQUIREMENTS)
|
|
605
|
+
)
|
|
606
|
+
return Pages(**data)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _parse_agent_hooks(data: Any, path: str) -> AgentHooks:
|
|
610
|
+
"""``agent_hooks``: an optional ``check_unfinished`` and ``recent_days``."""
|
|
611
|
+
if data is None:
|
|
612
|
+
return AgentHooks()
|
|
613
|
+
if not isinstance(data, dict):
|
|
614
|
+
raise ConfigurationError(f"{path}.agent_hooks must be an object")
|
|
615
|
+
unknown = set(data) - {"check_unfinished", "recent_days"}
|
|
616
|
+
if unknown:
|
|
617
|
+
raise ConfigurationError(
|
|
618
|
+
f"{path}.agent_hooks has unknown key(s): {', '.join(sorted(unknown))}"
|
|
619
|
+
)
|
|
620
|
+
if not isinstance(data.get("check_unfinished", True), bool):
|
|
621
|
+
raise ConfigurationError(
|
|
622
|
+
f"{path}.agent_hooks.check_unfinished must be true or false"
|
|
623
|
+
)
|
|
624
|
+
if not is_positive_int(data.get("recent_days", DEFAULT_RECENT_DAYS)):
|
|
625
|
+
raise ConfigurationError(
|
|
626
|
+
f"{path}.agent_hooks.recent_days must be a positive integer"
|
|
627
|
+
)
|
|
628
|
+
return AgentHooks(**data)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _parse_rules(data: Any, path: str) -> dict[str, Any]:
|
|
632
|
+
"""``rules``: an optional ``check_guidance`` text."""
|
|
633
|
+
if data is None:
|
|
634
|
+
return {}
|
|
635
|
+
if not isinstance(data, dict):
|
|
636
|
+
raise ConfigurationError(f"{path}.rules must be an object")
|
|
637
|
+
unknown = set(data) - {"check_guidance"}
|
|
638
|
+
if unknown:
|
|
639
|
+
raise ConfigurationError(
|
|
640
|
+
f"{path}.rules has unknown key(s): {', '.join(sorted(unknown))}"
|
|
641
|
+
)
|
|
642
|
+
guidance = data.get("check_guidance")
|
|
643
|
+
if guidance is not None and not isinstance(guidance, str):
|
|
644
|
+
raise ConfigurationError(f"{path}.rules.check_guidance must be a string")
|
|
645
|
+
return {
|
|
646
|
+
# Blank text means unset.
|
|
647
|
+
"rule_check_guidance": (guidance or "").strip() or None,
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _parse_enabled(data: Any, path: str) -> Enabled:
|
|
652
|
+
if isinstance(data, bool):
|
|
653
|
+
return data
|
|
654
|
+
if data == ON_REQUEST:
|
|
655
|
+
return ON_REQUEST
|
|
656
|
+
raise ConfigurationError(f'{path}.enabled must be true, false, or "{ON_REQUEST}"')
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _parse_task_format(data: Any, path: str) -> str | None:
|
|
660
|
+
if data is None:
|
|
661
|
+
return None
|
|
662
|
+
if not isinstance(data, str) or not data:
|
|
663
|
+
raise ConfigurationError(f"{path}.task_format must be a non-empty string")
|
|
664
|
+
if data == EXPLICIT_TASK_FORMAT:
|
|
665
|
+
return data
|
|
666
|
+
tokens = _TASK_FORMAT_TOKEN.findall(data)
|
|
667
|
+
rest = _TASK_FORMAT_TOKEN.sub("", data)
|
|
668
|
+
if "{" in rest or "}" in rest:
|
|
669
|
+
raise ConfigurationError(f"{path}.task_format has invalid placeholders")
|
|
670
|
+
unknown = set(tokens) - TASK_FORMAT_PLACEHOLDERS
|
|
671
|
+
if unknown:
|
|
672
|
+
raise ConfigurationError(
|
|
673
|
+
f"{path}.task_format has unknown placeholder(s): "
|
|
674
|
+
+ ", ".join(sorted(unknown))
|
|
675
|
+
)
|
|
676
|
+
return data
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _parse_executable(data: Any, path: str) -> str | None:
|
|
680
|
+
if data is None:
|
|
681
|
+
return None
|
|
682
|
+
if not isinstance(data, str) or not data.strip():
|
|
683
|
+
raise ConfigurationError(
|
|
684
|
+
f"{path}.executable must be a command name or a path to the ww binary"
|
|
685
|
+
)
|
|
686
|
+
return data.strip()
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _parse_workflows(data: Any, path: str) -> frozenset[str]:
|
|
690
|
+
"""The built-in workflows switched off by ``enabled: false``."""
|
|
691
|
+
if data is None:
|
|
692
|
+
return frozenset()
|
|
693
|
+
if not isinstance(data, dict):
|
|
694
|
+
raise ConfigurationError(f"{path}.workflows must be an object")
|
|
695
|
+
known = builtin_workflow_names()
|
|
696
|
+
unknown = set(data) - known
|
|
697
|
+
if unknown:
|
|
698
|
+
raise ConfigurationError(
|
|
699
|
+
f"{path}.workflows has unknown name(s): {', '.join(sorted(unknown))}; "
|
|
700
|
+
"built-in workflows: " + ", ".join(sorted(known))
|
|
701
|
+
)
|
|
702
|
+
disabled: set[str] = set()
|
|
703
|
+
for name, value in data.items():
|
|
704
|
+
context = f"{path}.workflows.{name}"
|
|
705
|
+
if not isinstance(value, dict):
|
|
706
|
+
raise ConfigurationError(f"{context} must be an object")
|
|
707
|
+
unknown_keys = set(value) - {"enabled"}
|
|
708
|
+
if unknown_keys:
|
|
709
|
+
raise ConfigurationError(
|
|
710
|
+
f"{context} has unknown key(s): {', '.join(sorted(unknown_keys))}"
|
|
711
|
+
)
|
|
712
|
+
enabled = value.get("enabled", True)
|
|
713
|
+
if not isinstance(enabled, bool):
|
|
714
|
+
raise ConfigurationError(f"{context}.enabled must be true or false")
|
|
715
|
+
if not enabled:
|
|
716
|
+
disabled.add(name)
|
|
717
|
+
return frozenset(disabled)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _parse_projects(data: Any, path: str) -> tuple[ProjectDefinition, ...]:
|
|
721
|
+
if data is None:
|
|
722
|
+
return ()
|
|
723
|
+
if not isinstance(data, list):
|
|
724
|
+
raise ConfigurationError(f"{path}.projects must be a list")
|
|
725
|
+
result: list[ProjectDefinition] = []
|
|
726
|
+
for index, item in enumerate(data):
|
|
727
|
+
context = f"{path}.projects[{index}]"
|
|
728
|
+
if not isinstance(item, dict):
|
|
729
|
+
raise ConfigurationError(f"{context} must be an object")
|
|
730
|
+
unknown_keys = set(item) - {"name", "path", "description"}
|
|
731
|
+
if unknown_keys:
|
|
732
|
+
raise ConfigurationError(
|
|
733
|
+
f"{context} has unknown key(s): {', '.join(sorted(unknown_keys))}"
|
|
734
|
+
)
|
|
735
|
+
name = expect_normalized_name(
|
|
736
|
+
item.get("name"), f"{context}.name", error=ConfigurationError
|
|
737
|
+
)
|
|
738
|
+
if any(project.name == name for project in result):
|
|
739
|
+
raise ConfigurationError(f"{path}.projects has duplicate name {name!r}")
|
|
740
|
+
location = item.get("path")
|
|
741
|
+
if not isinstance(location, str) or not location.strip():
|
|
742
|
+
raise ConfigurationError(f"{context}.path must be a non-empty string")
|
|
743
|
+
description = item.get("description", "")
|
|
744
|
+
if not isinstance(description, str):
|
|
745
|
+
raise ConfigurationError(f"{context}.description must be a string")
|
|
746
|
+
result.append(ProjectDefinition(name, location.strip(), description))
|
|
747
|
+
return tuple(result)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _available(identifiers: tuple[str, ...]) -> str:
|
|
751
|
+
names = ", ".join(sorted(identifiers))
|
|
752
|
+
return f"installed: {names}" if names else "no extensions are installed"
|