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

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