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/config/values.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Shared YAML value validation for workflow configuration parsing."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, cast, get_args
|
|
7
|
+
|
|
8
|
+
from ww.contracts import StepRole
|
|
9
|
+
from ww.errors import ConfigurationError
|
|
10
|
+
from ww.extensions import is_extension_reference
|
|
11
|
+
from ww.validation import (
|
|
12
|
+
NAME_PATTERN as _NAME,
|
|
13
|
+
)
|
|
14
|
+
from ww.validation import (
|
|
15
|
+
expect_mapping,
|
|
16
|
+
expect_nonempty_string,
|
|
17
|
+
expect_normalized_name,
|
|
18
|
+
reject_unknown_keys,
|
|
19
|
+
)
|
|
20
|
+
from ww.workflow_config import ALL, ALL_NAMES, NameFilter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _mapping(value: Any, path: str) -> dict[str, Any]:
|
|
24
|
+
return expect_mapping(value, path, error=ConfigurationError)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _named_entry(
|
|
28
|
+
mapping: dict[str, Any],
|
|
29
|
+
path: str,
|
|
30
|
+
*,
|
|
31
|
+
allowed: set[str] | None = None,
|
|
32
|
+
ignored: set[str] | None = None,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""Expand ``name: description`` shorthand into the shared action shape."""
|
|
35
|
+
if "name" in mapping:
|
|
36
|
+
return mapping
|
|
37
|
+
candidates = [key for key in mapping if ignored is None or key not in ignored]
|
|
38
|
+
if not candidates:
|
|
39
|
+
return mapping
|
|
40
|
+
shorthand_name = candidates[0]
|
|
41
|
+
if allowed is not None and shorthand_name in allowed:
|
|
42
|
+
return mapping
|
|
43
|
+
shorthand_description = mapping[shorthand_name]
|
|
44
|
+
if not isinstance(shorthand_name, str) or not shorthand_name.strip():
|
|
45
|
+
raise ConfigurationError(f"{path} shorthand name must be a non-empty string")
|
|
46
|
+
if shorthand_description is not None and not isinstance(shorthand_description, str):
|
|
47
|
+
raise ConfigurationError(
|
|
48
|
+
f"{path} shorthand description must be a string or null"
|
|
49
|
+
)
|
|
50
|
+
if "description" in mapping:
|
|
51
|
+
raise ConfigurationError(
|
|
52
|
+
f"{path} cannot combine shorthand and explicit descriptions"
|
|
53
|
+
)
|
|
54
|
+
normalized = dict(mapping)
|
|
55
|
+
del normalized[shorthand_name]
|
|
56
|
+
normalized["name"] = shorthand_name
|
|
57
|
+
if shorthand_description is not None:
|
|
58
|
+
normalized["description"] = shorthand_description
|
|
59
|
+
return normalized
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _only(mapping: dict[str, Any], allowed: set[str], path: str) -> None:
|
|
63
|
+
reject_unknown_keys(mapping, allowed, path, error=ConfigurationError)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _name(mapping: dict[str, Any], path: str) -> str:
|
|
67
|
+
return _required_string(mapping, "name", path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _required_string(mapping: dict[str, Any], key: str, path: str) -> str:
|
|
71
|
+
return expect_normalized_name(
|
|
72
|
+
mapping.get(key), f"{path}.{key}", error=ConfigurationError
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _nonempty_string(mapping: dict[str, Any], key: str, path: str) -> str:
|
|
77
|
+
return expect_nonempty_string(
|
|
78
|
+
mapping.get(key), f"{path}.{key}", error=ConfigurationError
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _optional_string(mapping: dict[str, Any], key: str, path: str) -> str | None:
|
|
83
|
+
if key not in mapping:
|
|
84
|
+
return None
|
|
85
|
+
return _nonempty_string(mapping, key, path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _optional_bool(mapping: dict[str, Any], key: str, path: str) -> bool | None:
|
|
89
|
+
if key not in mapping:
|
|
90
|
+
return None
|
|
91
|
+
value = mapping[key]
|
|
92
|
+
if not isinstance(value, bool):
|
|
93
|
+
raise ConfigurationError(f"{path}.{key} must be true or false")
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _optional_agent(mapping: dict[str, Any], key: str, path: str) -> str | None:
|
|
98
|
+
value = _optional_string(mapping, key, path)
|
|
99
|
+
if value == "auto":
|
|
100
|
+
raise ConfigurationError(f"{path}.{key} must not be 'auto'")
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _role(mapping: dict[str, Any], path: str) -> StepRole | None:
|
|
105
|
+
"""The declared ``role``, or ``None`` when the step inherits one."""
|
|
106
|
+
if "role" not in mapping:
|
|
107
|
+
return None
|
|
108
|
+
value = mapping["role"]
|
|
109
|
+
if value not in get_args(StepRole):
|
|
110
|
+
raise ConfigurationError(f"{path}.role must be manager or worker")
|
|
111
|
+
return cast(StepRole, value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _subagents(mapping: dict[str, Any], path: str) -> bool | None:
|
|
115
|
+
"""The declared ``subagents``, or ``None`` when the step inherits it.
|
|
116
|
+
|
|
117
|
+
``false`` means whoever performs the step, manager or worker, spawns no
|
|
118
|
+
subagents for anything; who performs it is ``role``'s business.
|
|
119
|
+
"""
|
|
120
|
+
if "subagents" not in mapping:
|
|
121
|
+
return None
|
|
122
|
+
value = mapping["subagents"]
|
|
123
|
+
if not isinstance(value, bool):
|
|
124
|
+
raise ConfigurationError(f"{path}.subagents must be true or false")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _optional_name(mapping: dict[str, Any], key: str, path: str) -> str | None:
|
|
129
|
+
if key not in mapping:
|
|
130
|
+
return None
|
|
131
|
+
return _required_string(mapping, key, path)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _profile(mapping: dict[str, Any], path: str) -> dict[str, str | None]:
|
|
135
|
+
value = mapping.get("profile")
|
|
136
|
+
if value is None:
|
|
137
|
+
return {"profile": None, "profile_description": None}
|
|
138
|
+
if isinstance(value, str):
|
|
139
|
+
if not value.strip() or not _NAME.fullmatch(value):
|
|
140
|
+
raise ConfigurationError(f"{path}.profile must be a normalized name")
|
|
141
|
+
return {"profile": value, "profile_description": None}
|
|
142
|
+
profile = _mapping(value, f"{path}.profile")
|
|
143
|
+
_only(profile, {"name", "description"}, f"{path}.profile")
|
|
144
|
+
name = _optional_name(profile, "name", f"{path}.profile")
|
|
145
|
+
description = _optional_string(profile, "description", f"{path}.profile")
|
|
146
|
+
if name is None and description is None:
|
|
147
|
+
raise ConfigurationError(f"{path}.profile requires name or description")
|
|
148
|
+
return {"profile": name, "profile_description": description}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _description(value: Any, path: str) -> str:
|
|
152
|
+
if value is None:
|
|
153
|
+
return ""
|
|
154
|
+
if not isinstance(value, str):
|
|
155
|
+
raise ConfigurationError(f"{path}.description must be a string")
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _description_items(value: Any, path: str) -> tuple[str, ...]:
|
|
160
|
+
if value is None:
|
|
161
|
+
return ()
|
|
162
|
+
if isinstance(value, str):
|
|
163
|
+
return (value,)
|
|
164
|
+
if not isinstance(value, list) or not all(
|
|
165
|
+
isinstance(item, str) and item for item in value
|
|
166
|
+
):
|
|
167
|
+
raise ConfigurationError(
|
|
168
|
+
f"{path}.description must be a string or list of strings"
|
|
169
|
+
)
|
|
170
|
+
return tuple(value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _required_list(mapping: dict[str, Any], key: str, path: str) -> list[Any]:
|
|
174
|
+
value = mapping.get(key)
|
|
175
|
+
if not isinstance(value, list):
|
|
176
|
+
raise ConfigurationError(f"{path}.{key} must be a list")
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _reference_or_name(value: str) -> bool:
|
|
181
|
+
return bool(_NAME.fullmatch(value)) or is_extension_reference(value)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _string_list(value: Any, path: str) -> tuple[str, ...]:
|
|
185
|
+
if not isinstance(value, list) or not all(
|
|
186
|
+
isinstance(item, str) and _reference_or_name(item) for item in value
|
|
187
|
+
):
|
|
188
|
+
raise ConfigurationError(f"{path} must be a list of normalized names")
|
|
189
|
+
return tuple(value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _name_filter(value: Any, path: str, *, empty: NameFilter) -> NameFilter:
|
|
193
|
+
"""Parse a ``workflows`` or ``steps`` filter: ``"*"`` or a list of names.
|
|
194
|
+
|
|
195
|
+
A step name may be a ``/``-separated path. ``empty`` is what ``[]``
|
|
196
|
+
means where the filter is written: every name for a hook, none for a
|
|
197
|
+
rule group.
|
|
198
|
+
"""
|
|
199
|
+
if value == ALL_NAMES:
|
|
200
|
+
return ALL
|
|
201
|
+
if not isinstance(value, list):
|
|
202
|
+
raise ConfigurationError(
|
|
203
|
+
f'{path} must be "*" or a list of names'
|
|
204
|
+
+ (f" (write [{value}] for one name)" if isinstance(value, str) else "")
|
|
205
|
+
)
|
|
206
|
+
if ALL_NAMES in value:
|
|
207
|
+
raise ConfigurationError(
|
|
208
|
+
f'{path} cannot mix "*" with names; write "*" alone for all'
|
|
209
|
+
)
|
|
210
|
+
if not all(
|
|
211
|
+
isinstance(item, str)
|
|
212
|
+
and item
|
|
213
|
+
and all(_NAME.fullmatch(segment) for segment in item.split("/"))
|
|
214
|
+
for item in value
|
|
215
|
+
):
|
|
216
|
+
raise ConfigurationError(f'{path} must be "*" or a list of normalized names')
|
|
217
|
+
return NameFilter.of(value) if value else empty
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _unique(values: Any, label: str) -> None:
|
|
221
|
+
items = tuple(values)
|
|
222
|
+
if len(set(items)) != len(items):
|
|
223
|
+
raise ConfigurationError(f"duplicate {label} name")
|
ww/config_files.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""The names and locations of ww's configuration files.
|
|
3
|
+
|
|
4
|
+
``ww.yaml`` describes what workflows do and
|
|
5
|
+
``ww.json`` how the tools around them behave. Each comes in
|
|
6
|
+
three levels, applied top to bottom so a lower level wins:
|
|
7
|
+
|
|
8
|
+
1. user: ``ww.{yaml,json}`` in the user's configuration
|
|
9
|
+
directory, shared by every project of the user;
|
|
10
|
+
2. repo: ``ww.{yaml,json}`` in the project root;
|
|
11
|
+
3. local: ``ww.local.{yaml,json}`` next to the repo files,
|
|
12
|
+
kept out of version control.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import Iterator, Mapping
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from contextvars import ContextVar
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
FILE_STEM = "ww"
|
|
25
|
+
WORKFLOWS_FILE = f"{FILE_STEM}.yaml"
|
|
26
|
+
SETTINGS_FILE = f"{FILE_STEM}.json"
|
|
27
|
+
LOCAL_WORKFLOWS_FILE = f"{FILE_STEM}.local.yaml"
|
|
28
|
+
LOCAL_SETTINGS_FILE = f"{FILE_STEM}.local.json"
|
|
29
|
+
# The rule-automation store: ww-owned derived knowledge at the project root,
|
|
30
|
+
# committed so every checkout shares it, never part of a step's change set.
|
|
31
|
+
RULE_AUTOMATION_FILE = "ww-rule-automation.json"
|
|
32
|
+
# Rule groups ``ww rules add --group`` and ``ww rules filter`` write, imported
|
|
33
|
+
# by the repo file so that file is never rewritten; ww owns this one.
|
|
34
|
+
RULES_IMPORT_FILE = "ww-rules.yaml"
|
|
35
|
+
# What ``ww setup apply`` writes: an import file for the repo level, and one
|
|
36
|
+
# for the local level, which stays out of version control.
|
|
37
|
+
SETUP_IMPORT_FILE = "ww-setup.yaml"
|
|
38
|
+
LOCAL_SETUP_IMPORT_FILE = "ww-setup.local.yaml"
|
|
39
|
+
# The .gitignore patterns ``init`` adds; they also cover local files a local
|
|
40
|
+
# configuration imports, such as ``git.ww.local.yaml``.
|
|
41
|
+
LOCAL_IGNORE_PATTERNS = (
|
|
42
|
+
f"*{LOCAL_WORKFLOWS_FILE}",
|
|
43
|
+
f"*{LOCAL_SETTINGS_FILE}",
|
|
44
|
+
LOCAL_SETUP_IMPORT_FILE,
|
|
45
|
+
)
|
|
46
|
+
# The files under ``.ww`` a team commits: what ww learned about the project.
|
|
47
|
+
# Everything else there is one checkout's state. Existing ``.gitignore`` lines
|
|
48
|
+
# are never touched; the operator's own re-includes stay as they are.
|
|
49
|
+
SHARED_RUNTIME_FILES = ("project.md",)
|
|
50
|
+
# The .gitignore lines ``init`` writes for ``.ww``. Git cannot re-include a
|
|
51
|
+
# file inside an ignored directory, so the directory's contents are ignored
|
|
52
|
+
# rather than the directory itself, and each shared file is then re-included.
|
|
53
|
+
RUNTIME_IGNORE_LINES = (
|
|
54
|
+
".ww/*",
|
|
55
|
+
*(f"!.ww/{name}" for name in SHARED_RUNTIME_FILES),
|
|
56
|
+
)
|
|
57
|
+
# The lines that ignore ``.ww`` whole, as an operator may write them; ``init``
|
|
58
|
+
# replaces them with :data:`RUNTIME_IGNORE_LINES`, since a directory ignored
|
|
59
|
+
# whole cannot have files re-included.
|
|
60
|
+
WHOLE_RUNTIME_IGNORE_LINES = (".ww/", ".ww", "/.ww", "/.ww/")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def runtime_ignored(gitignore: str) -> bool:
|
|
64
|
+
"""Whether a .gitignore's text already keeps ``.ww`` out, in any form."""
|
|
65
|
+
return bool(
|
|
66
|
+
{*WHOLE_RUNTIME_IGNORE_LINES, RUNTIME_IGNORE_LINES[0]}.intersection(
|
|
67
|
+
line.strip() for line in gitignore.splitlines()
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def newline_of(text: str) -> str:
|
|
73
|
+
"""The line ending a text file uses: CRLF when it has one, else LF."""
|
|
74
|
+
return "\r\n" if "\r\n" in text else "\n"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Where the user level lives instead of the user's configuration directory.
|
|
78
|
+
USER_DIR_VARIABLE = "WW_USER_CONFIG_DIR"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ConfigurationLevel:
|
|
83
|
+
"""Where one level keeps one kind of configuration file."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
path: Path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def user_directory() -> Path:
|
|
90
|
+
"""The user level's directory, following XDG when it is configured."""
|
|
91
|
+
configured = os.environ.get(USER_DIR_VARIABLE)
|
|
92
|
+
if configured:
|
|
93
|
+
directory = Path(configured)
|
|
94
|
+
else:
|
|
95
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
96
|
+
directory = (Path(base) if base else Path.home() / ".config") / FILE_STEM
|
|
97
|
+
return directory
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Configuration files read as if written, by resolved path; ``None`` reads as
|
|
101
|
+
# absent. ``ww setup apply`` validates its plan this way, so checking a change
|
|
102
|
+
# never touches the project.
|
|
103
|
+
_STAGED: ContextVar[Mapping[Path, str | None] | None] = ContextVar(
|
|
104
|
+
"ww_staged_configuration", default=None
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def staged_files(contents: Mapping[Path, str | None]) -> Iterator[None]:
|
|
110
|
+
"""Read configuration files as if ``contents`` were written in place.
|
|
111
|
+
|
|
112
|
+
Only reads through :func:`read_configuration_file` and
|
|
113
|
+
:func:`configuration_file_exists` see the staged contents; nothing is
|
|
114
|
+
written.
|
|
115
|
+
"""
|
|
116
|
+
token = _STAGED.set({path.resolve(): text for path, text in contents.items()})
|
|
117
|
+
try:
|
|
118
|
+
yield
|
|
119
|
+
finally:
|
|
120
|
+
_STAGED.reset(token)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_configuration_file(path: Path) -> str:
|
|
124
|
+
"""A configuration file's text, staged or on disk."""
|
|
125
|
+
staged = _STAGED.get()
|
|
126
|
+
if staged is not None and path.resolve() in staged:
|
|
127
|
+
text = staged[path.resolve()]
|
|
128
|
+
if text is None:
|
|
129
|
+
raise FileNotFoundError(f"no such file: {path}")
|
|
130
|
+
return text
|
|
131
|
+
return path.read_text(encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def configuration_file_exists(path: Path) -> bool:
|
|
135
|
+
"""Whether a configuration file is there, staged or on disk."""
|
|
136
|
+
staged = _STAGED.get()
|
|
137
|
+
if staged is not None and path.resolve() in staged:
|
|
138
|
+
return staged[path.resolve()] is not None
|
|
139
|
+
return path.is_file()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def display_path(file: Path, base: Path) -> str:
|
|
143
|
+
"""How messages name ``file``: from the repo root, from home, or in full."""
|
|
144
|
+
for root, prefix in ((base, ""), (Path.home(), "~/")):
|
|
145
|
+
try:
|
|
146
|
+
return prefix + str(file.relative_to(root))
|
|
147
|
+
except ValueError:
|
|
148
|
+
continue
|
|
149
|
+
return str(file)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _levels(
|
|
153
|
+
repo_file: Path, user_name: str, local_name: str
|
|
154
|
+
) -> tuple[ConfigurationLevel, ...]:
|
|
155
|
+
"""The user, repo, and local levels around ``repo_file``.
|
|
156
|
+
|
|
157
|
+
The user file shares the repo file's name, so a user directory that is
|
|
158
|
+
the project root itself contributes no separate level.
|
|
159
|
+
"""
|
|
160
|
+
user = user_directory() / user_name
|
|
161
|
+
return (
|
|
162
|
+
*(
|
|
163
|
+
(ConfigurationLevel("user", user),)
|
|
164
|
+
if user.resolve() != repo_file.resolve()
|
|
165
|
+
else ()
|
|
166
|
+
),
|
|
167
|
+
ConfigurationLevel("repo", repo_file),
|
|
168
|
+
ConfigurationLevel("local", repo_file.with_name(local_name)),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def workflow_levels(repo_file: Path) -> tuple[ConfigurationLevel, ...]:
|
|
173
|
+
"""The YAML levels around ``repo_file``, from user to local."""
|
|
174
|
+
return _levels(repo_file, WORKFLOWS_FILE, LOCAL_WORKFLOWS_FILE)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def settings_levels(repo_file: Path) -> tuple[ConfigurationLevel, ...]:
|
|
178
|
+
"""The JSON levels around ``repo_file``, from user to local."""
|
|
179
|
+
return _levels(repo_file, SETTINGS_FILE, LOCAL_SETTINGS_FILE)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def project_settings_levels(directory: Path) -> tuple[ConfigurationLevel, ...]:
|
|
183
|
+
"""The JSON levels a configured project carries in its own directory.
|
|
184
|
+
|
|
185
|
+
A project has a repo and a local level, like the root; the user level is
|
|
186
|
+
shared by every project of the user and is read once, at the root.
|
|
187
|
+
"""
|
|
188
|
+
return (
|
|
189
|
+
ConfigurationLevel("repo", directory / SETTINGS_FILE),
|
|
190
|
+
ConfigurationLevel("local", directory / LOCAL_SETTINGS_FILE),
|
|
191
|
+
)
|
ww/config_writes.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""File writes that ww makes to configuration, together or not at all.
|
|
3
|
+
|
|
4
|
+
``ww rules add`` and ``ww setup apply`` plan their changes as
|
|
5
|
+
:class:`FileWrite` values and apply them in a :class:`Transaction`, which puts
|
|
6
|
+
every file back as it was when anything fails. ``ww rules add`` loads the
|
|
7
|
+
configuration after writing; ``ww setup apply`` validates its plan in memory
|
|
8
|
+
first and writes only once it is confirmed. The import file each command owns
|
|
9
|
+
is added to a root file's ``imports`` with :func:`import_write`, which changes
|
|
10
|
+
that one list and refuses when it cannot do so without touching anything else.
|
|
11
|
+
|
|
12
|
+
A write goes through a symbolic link to the file it names and keeps that
|
|
13
|
+
file's permissions, so a configuration file kept elsewhere stays linked.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
from ww.errors import StateError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class FileWrite:
|
|
31
|
+
"""One file's new content, or ``None`` to delete it."""
|
|
32
|
+
|
|
33
|
+
path: Path
|
|
34
|
+
content: str | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Transaction:
|
|
38
|
+
"""Files written together and restored together when anything fails."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._saved: list[tuple[Path, bytes | None]] = []
|
|
42
|
+
self._created: list[Path] = []
|
|
43
|
+
self._done = False
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> Transaction:
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, kind: object, error: object, trace: object) -> None:
|
|
49
|
+
if error is not None:
|
|
50
|
+
self.roll_back()
|
|
51
|
+
|
|
52
|
+
def apply(
|
|
53
|
+
self, writes: Iterable[FileWrite], directories: Iterable[Path] = ()
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Write every change, or put back what was written and fail.
|
|
56
|
+
|
|
57
|
+
An ``OSError`` becomes a :class:`StateError` once the files are back.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
for directory in directories:
|
|
61
|
+
self._make_directory(directory)
|
|
62
|
+
for change in writes:
|
|
63
|
+
# A symbolic link stays one: its target is what changes.
|
|
64
|
+
path = change.path.resolve()
|
|
65
|
+
self._saved.append((path, path.read_bytes() if path.exists() else None))
|
|
66
|
+
if change.content is None:
|
|
67
|
+
path.unlink()
|
|
68
|
+
else:
|
|
69
|
+
self._make_directory(path.parent)
|
|
70
|
+
atomic_write(path, change.content)
|
|
71
|
+
except OSError as error:
|
|
72
|
+
self.roll_back()
|
|
73
|
+
raise StateError(
|
|
74
|
+
f"cannot write {error.filename or 'a file'}: {error.strerror or error}"
|
|
75
|
+
"; nothing was written"
|
|
76
|
+
) from error
|
|
77
|
+
|
|
78
|
+
def _make_directory(self, directory: Path) -> None:
|
|
79
|
+
missing = [
|
|
80
|
+
folder for folder in (directory, *directory.parents) if not folder.exists()
|
|
81
|
+
]
|
|
82
|
+
for folder in reversed(missing):
|
|
83
|
+
folder.mkdir()
|
|
84
|
+
self._created.append(folder)
|
|
85
|
+
|
|
86
|
+
def roll_back(self) -> None:
|
|
87
|
+
if self._done:
|
|
88
|
+
return
|
|
89
|
+
self._done = True
|
|
90
|
+
for path, content in reversed(self._saved):
|
|
91
|
+
if content is None:
|
|
92
|
+
path.unlink(missing_ok=True)
|
|
93
|
+
else:
|
|
94
|
+
path.write_bytes(content)
|
|
95
|
+
for folder in reversed(self._created):
|
|
96
|
+
if folder.is_dir() and not any(folder.iterdir()):
|
|
97
|
+
folder.rmdir()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def atomic_write(path: Path, content: str) -> None:
|
|
101
|
+
"""Replace the file ``path`` names whole, keeping its permissions.
|
|
102
|
+
|
|
103
|
+
A symbolic link is written through to its target. The temporary file is
|
|
104
|
+
removed when the write fails.
|
|
105
|
+
"""
|
|
106
|
+
target = path.resolve()
|
|
107
|
+
temporary = target.with_name(f".{target.name}.ww-tmp")
|
|
108
|
+
try:
|
|
109
|
+
temporary.write_text(content, encoding="utf-8")
|
|
110
|
+
if target.exists():
|
|
111
|
+
temporary.chmod(target.stat().st_mode & 0o7777)
|
|
112
|
+
temporary.replace(target)
|
|
113
|
+
except OSError:
|
|
114
|
+
temporary.unlink(missing_ok=True)
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_WIDTH = 80
|
|
119
|
+
_ITEM = re.compile(r" -( |$)")
|
|
120
|
+
_LONG_TEXT = 72
|
|
121
|
+
_SHORT_LIST = 60
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Dumper(yaml.SafeDumper):
|
|
125
|
+
"""Block style YAML a person would write, without anchors or ``null``."""
|
|
126
|
+
|
|
127
|
+
def ignore_aliases(self, data: Any) -> bool:
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
def increase_indent(self, flow: bool = False, indentless: bool = False) -> None:
|
|
131
|
+
super().increase_indent(flow, False)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _represent_none(dumper: _Dumper, _: None) -> yaml.ScalarNode:
|
|
135
|
+
return dumper.represent_scalar("tag:yaml.org,2002:null", "")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _represent_str(dumper: _Dumper, text: str) -> yaml.ScalarNode:
|
|
139
|
+
style = "|" if "\n" in text else ">" if len(text) > _LONG_TEXT else None
|
|
140
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", text, style=style)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_short_scalar(item: Any) -> bool:
|
|
144
|
+
if isinstance(item, str):
|
|
145
|
+
return "\n" not in item and len(item) <= _SHORT_LIST
|
|
146
|
+
return isinstance(item, (bool, int, float))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _represent_list(dumper: _Dumper, items: list[Any]) -> yaml.SequenceNode:
|
|
150
|
+
inline = all(map(_is_short_scalar, items)) and len(str(items)) <= _SHORT_LIST
|
|
151
|
+
return dumper.represent_sequence("tag:yaml.org,2002:seq", items, flow_style=inline)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_Dumper.add_representer(type(None), _represent_none)
|
|
155
|
+
_Dumper.add_representer(str, _represent_str)
|
|
156
|
+
_Dumper.add_representer(list, _represent_list)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _dump(value: Any) -> str:
|
|
160
|
+
return yaml.dump( # type: ignore[no-any-return]
|
|
161
|
+
value,
|
|
162
|
+
Dumper=_Dumper,
|
|
163
|
+
sort_keys=False,
|
|
164
|
+
allow_unicode=True,
|
|
165
|
+
default_flow_style=False,
|
|
166
|
+
width=_WIDTH,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _section(key: Any, value: Any) -> str:
|
|
171
|
+
"""One top-level key, with a blank line between the items of its list.
|
|
172
|
+
|
|
173
|
+
Every other line of a block list's item, block text included, is indented
|
|
174
|
+
deeper than the `` - `` that starts the item.
|
|
175
|
+
"""
|
|
176
|
+
lines = _dump({key: value}).splitlines(True)
|
|
177
|
+
if not isinstance(value, list):
|
|
178
|
+
return "".join(lines)
|
|
179
|
+
return "".join(
|
|
180
|
+
f"\n{line}" if index > 1 and _ITEM.match(line) else line
|
|
181
|
+
for index, line in enumerate(lines)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def dump_yaml(value: dict[str, Any]) -> str:
|
|
186
|
+
"""``value`` as readable YAML that loads back to the same data.
|
|
187
|
+
|
|
188
|
+
Mappings are written in block style, short scalar lists inline, long text
|
|
189
|
+
folded and multi-line text as a literal block. Top-level sections and the
|
|
190
|
+
items of a top-level list are separated by a blank line.
|
|
191
|
+
"""
|
|
192
|
+
return "\n".join(_section(key, item) for key, item in value.items())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def import_write(
|
|
196
|
+
root: Path, text: str, raw: dict[str, Any], entry: str, label: str
|
|
197
|
+
) -> FileWrite:
|
|
198
|
+
"""``root`` with ``entry`` added to its imports, and nothing else changed."""
|
|
199
|
+
updated = with_import(text, raw, entry)
|
|
200
|
+
imports = list(raw.get("imports") or [])
|
|
201
|
+
expected = {"imports": [*imports, entry]}
|
|
202
|
+
expected.update((key, value) for key, value in raw.items() if key != "imports")
|
|
203
|
+
if yaml.safe_load(updated) != expected:
|
|
204
|
+
raise StateError(
|
|
205
|
+
f"ww cannot add {entry} to the imports of {label} without changing "
|
|
206
|
+
"anything else; add it by hand and run the command again"
|
|
207
|
+
)
|
|
208
|
+
return FileWrite(root, updated)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def with_import(text: str, raw: dict[str, Any], entry: str) -> str:
|
|
212
|
+
"""``text`` with ``entry`` appended to its top-level ``imports`` list.
|
|
213
|
+
|
|
214
|
+
Without ``imports``, the list goes before the first top-level key, since
|
|
215
|
+
imports come before every key but ``extends``. An existing list gains one
|
|
216
|
+
line in block style, or one element in a one-line flow list.
|
|
217
|
+
"""
|
|
218
|
+
lines = text.splitlines(keepends=True)
|
|
219
|
+
if lines and not lines[-1].endswith("\n"):
|
|
220
|
+
lines[-1] += "\n"
|
|
221
|
+
if "imports" not in raw:
|
|
222
|
+
index = next(
|
|
223
|
+
(
|
|
224
|
+
position
|
|
225
|
+
for position, line in enumerate(lines)
|
|
226
|
+
if line.strip() and not line.startswith(("#", "---", "%", " ", "\t"))
|
|
227
|
+
),
|
|
228
|
+
len(lines),
|
|
229
|
+
)
|
|
230
|
+
return "".join([*lines[:index], f"imports:\n - {entry}\n", *lines[index:]])
|
|
231
|
+
start = next(
|
|
232
|
+
(
|
|
233
|
+
position
|
|
234
|
+
for position, line in enumerate(lines)
|
|
235
|
+
# The top-level imports key, e.g. "imports:" or "imports :".
|
|
236
|
+
if re.match(r"imports\s*:", line)
|
|
237
|
+
),
|
|
238
|
+
None,
|
|
239
|
+
)
|
|
240
|
+
if start is None:
|
|
241
|
+
return text
|
|
242
|
+
value = lines[start].split(":", 1)[1].split(" #", 1)[0].strip()
|
|
243
|
+
if value.startswith("[") and value.endswith("]"):
|
|
244
|
+
line = lines[start]
|
|
245
|
+
close = line.rindex("]")
|
|
246
|
+
separator = ", " if value[1:-1].strip() else ""
|
|
247
|
+
lines[start] = f"{line[:close]}{separator}{entry}{line[close:]}"
|
|
248
|
+
return "".join(lines)
|
|
249
|
+
if value:
|
|
250
|
+
return text
|
|
251
|
+
last = None
|
|
252
|
+
prefix = " - "
|
|
253
|
+
for position in range(start + 1, len(lines)):
|
|
254
|
+
line = lines[position]
|
|
255
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
256
|
+
continue
|
|
257
|
+
if line.lstrip().startswith("- ") and (line[0] in " \t-"):
|
|
258
|
+
last = position
|
|
259
|
+
prefix = line[: len(line) - len(line.lstrip())] + "- "
|
|
260
|
+
continue
|
|
261
|
+
break
|
|
262
|
+
if last is None:
|
|
263
|
+
return text
|
|
264
|
+
return "".join([*lines[: last + 1], f"{prefix}{entry}\n", *lines[last + 1 :]])
|