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/updates.py ADDED
@@ -0,0 +1,399 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Notice that the checkout this ww runs from is behind its remote.
3
+
4
+ ww is installed from a Git clone in editable mode, so "is there an update" is
5
+ a local question. The checkout is found from this package's own location, its
6
+ tracking branch is fetched, and the result is compared with the commit the
7
+ checkout is on. Nothing is reported anywhere and no service of the project's
8
+ is contacted: the only network call is a ``git fetch`` against the remote the
9
+ user cloned from, and it is skipped entirely when the check is turned off.
10
+
11
+ The notice is announced, never enforced. It is written before the command's
12
+ own output and recorded as seen, so it appears once rather than on every
13
+ invocation; ``ww updates`` reprints the last one, and ``ww updates --now``
14
+ looks again ahead of the interval.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import subprocess
23
+ from dataclasses import dataclass
24
+ from datetime import datetime, timedelta, timezone
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from ww.executable import ww_command
29
+
30
+ # One check a day. The state file records the last attempt, so ordinary
31
+ # invocations between two checks read a small JSON file and run no Git at all.
32
+ DEFAULT_INTERVAL_SECONDS = 24 * 60 * 60
33
+ # A fetch against an unreachable remote must not hold a command open.
34
+ FETCH_TIMEOUT_SECONDS = 5.0
35
+ GIT_TIMEOUT_SECONDS = 5.0
36
+ # Enough of the changelog to judge whether to pull, not the whole diff.
37
+ MAX_ENTRIES = 6
38
+ MAX_ENTRY_LENGTH = 180
39
+ CHANGELOG = "CHANGELOG.md"
40
+ FALSE_VALUES = frozenset({"0", "false", "no", "off"})
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class UpdateNotice:
45
+ """What one update announcement says."""
46
+
47
+ checkout: Path
48
+ remote_ref: str
49
+ commit: str
50
+ commits_behind: int
51
+ entries: tuple[str, ...] = ()
52
+ truncated: int = 0
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return {
56
+ "checkout": str(self.checkout),
57
+ "remote_ref": self.remote_ref,
58
+ "commit": self.commit,
59
+ "commits_behind": self.commits_behind,
60
+ "entries": list(self.entries),
61
+ "truncated": self.truncated,
62
+ }
63
+
64
+ @classmethod
65
+ def from_dict(cls, value: dict[str, Any]) -> UpdateNotice | None:
66
+ try:
67
+ return cls(
68
+ Path(str(value["checkout"])),
69
+ str(value["remote_ref"]),
70
+ str(value["commit"]),
71
+ int(value["commits_behind"]),
72
+ tuple(str(entry) for entry in value.get("entries", ())),
73
+ int(value.get("truncated", 0)),
74
+ )
75
+ except (KeyError, TypeError, ValueError):
76
+ return None
77
+
78
+ def render(self) -> str:
79
+ """Render the announcement an agent relays to the person running it."""
80
+ commits = "commit" if self.commits_behind == 1 else "commits"
81
+ lines = [
82
+ "## A newer ww is available",
83
+ "",
84
+ f"This checkout is {self.commits_behind} {commits} behind "
85
+ f"`{self.remote_ref}`.",
86
+ ]
87
+ if self.entries:
88
+ lines += ["", "What changed:", ""]
89
+ lines += [f"- {entry}" for entry in self.entries]
90
+ if self.truncated:
91
+ more = "entry" if self.truncated == 1 else "entries"
92
+ lines.append(f"- ...and {self.truncated} more {more} in {CHANGELOG}.")
93
+ lines += [
94
+ "",
95
+ "**Tell the person you are working for about this before you "
96
+ "continue**, and let them decide whether to update. To update:",
97
+ "",
98
+ "```console",
99
+ f"{ww_command()} upgrade",
100
+ "```",
101
+ "",
102
+ "This notice is shown once. `ww updates` prints it again.",
103
+ "",
104
+ "---",
105
+ "",
106
+ ]
107
+ return "\n".join(lines)
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class UpdateState:
112
+ """What the last check found, kept per user rather than per project."""
113
+
114
+ last_checked: datetime | None = None
115
+ announced_commit: str = ""
116
+ notice: UpdateNotice | None = None
117
+
118
+ def to_dict(self) -> dict[str, Any]:
119
+ return {
120
+ "last_checked": (
121
+ self.last_checked.isoformat().replace("+00:00", "Z")
122
+ if self.last_checked
123
+ else None
124
+ ),
125
+ "announced_commit": self.announced_commit,
126
+ "notice": self.notice.to_dict() if self.notice else None,
127
+ }
128
+
129
+
130
+ def state_path() -> Path:
131
+ """The per-user state file, following XDG when it is configured.
132
+
133
+ The update concerns the ww installation, which every project on the
134
+ machine shares, so the record of what was already announced does not
135
+ belong under any one project's ``.ww``.
136
+ """
137
+ configured = os.environ.get("WW_STATE_HOME")
138
+ if configured:
139
+ return Path(configured) / "updates.json"
140
+ base = os.environ.get("XDG_CONFIG_HOME")
141
+ root = Path(base) if base else Path.home() / ".config"
142
+ return root / "ww" / "updates.json"
143
+
144
+
145
+ def load_state(path: Path | None = None) -> UpdateState:
146
+ """Read the saved state, treating anything unreadable as no state."""
147
+ location = path or state_path()
148
+ try:
149
+ raw = json.loads(location.read_text(encoding="utf-8"))
150
+ except (OSError, json.JSONDecodeError):
151
+ return UpdateState()
152
+ if not isinstance(raw, dict):
153
+ return UpdateState()
154
+ return UpdateState(
155
+ _parse_timestamp(raw.get("last_checked")),
156
+ str(raw.get("announced_commit") or ""),
157
+ (
158
+ UpdateNotice.from_dict(raw["notice"])
159
+ if isinstance(raw.get("notice"), dict)
160
+ else None
161
+ ),
162
+ )
163
+
164
+
165
+ def save_state(state: UpdateState, path: Path | None = None) -> None:
166
+ """Persist the state, silently accepting a read-only home directory."""
167
+ location = path or state_path()
168
+ try:
169
+ location.parent.mkdir(parents=True, exist_ok=True)
170
+ temporary = location.with_name(location.name + ".tmp")
171
+ temporary.write_text(
172
+ json.dumps(state.to_dict(), indent=2) + "\n", encoding="utf-8"
173
+ )
174
+ temporary.replace(location)
175
+ except OSError:
176
+ return
177
+
178
+
179
+ def installation_checkout() -> Path | None:
180
+ """The Git checkout this ww was installed from, if it is one.
181
+
182
+ An editable install resolves back to the clone; a wheel installed into
183
+ site-packages does not, and then there is nothing to compare against.
184
+ """
185
+ here = Path(__file__).resolve()
186
+ for candidate in here.parents:
187
+ if (candidate / ".git").exists() and (
188
+ candidate / "src/ww/updates.py"
189
+ ).resolve() == here:
190
+ return candidate
191
+ return None
192
+
193
+
194
+ def check_enabled(update_check: bool = True) -> bool:
195
+ """Whether to look at all, from the environment and project setting."""
196
+ configured = os.environ.get("WW_UPDATE_CHECK")
197
+ if configured is not None:
198
+ return configured.strip().lower() not in FALSE_VALUES
199
+ return update_check
200
+
201
+
202
+ def interval_seconds() -> float:
203
+ """Seconds between checks, overridable for slower or faster cadence."""
204
+ configured = os.environ.get("WW_UPDATE_CHECK_INTERVAL")
205
+ if configured:
206
+ try:
207
+ return max(0.0, float(configured))
208
+ except ValueError:
209
+ return DEFAULT_INTERVAL_SECONDS
210
+ return DEFAULT_INTERVAL_SECONDS
211
+
212
+
213
+ def pending_notice(
214
+ checkout: Path | None,
215
+ *,
216
+ state_file: Path | None = None,
217
+ force: bool = False,
218
+ now: datetime | None = None,
219
+ ) -> UpdateNotice | None:
220
+ """Return an update to announce, or ``None`` when there is nothing to say.
221
+
222
+ Between checks this reads one small file and runs no Git. A notice already
223
+ announced stays quiet unless ``force`` asks for it again.
224
+ """
225
+ moment = now or datetime.now(timezone.utc)
226
+ state = load_state(state_file)
227
+ if not force and not _due(state, moment):
228
+ return state.notice if _unannounced(state) else None
229
+ if checkout is None:
230
+ return None
231
+ notice = _look(checkout)
232
+ save_state(UpdateState(moment, state.announced_commit, notice), state_file)
233
+ if notice is None:
234
+ return None
235
+ if not force and state.announced_commit == notice.commit:
236
+ return None
237
+ return notice
238
+
239
+
240
+ def mark_announced(notice: UpdateNotice, *, state_file: Path | None = None) -> None:
241
+ """Record that this notice was printed, so it is not repeated."""
242
+ state = load_state(state_file)
243
+ save_state(UpdateState(state.last_checked, notice.commit, notice), state_file)
244
+
245
+
246
+ def last_notice(*, state_file: Path | None = None) -> UpdateNotice | None:
247
+ """The most recent notice, announced or not, for ``ww updates``."""
248
+ return load_state(state_file).notice
249
+
250
+
251
+ def _due(state: UpdateState, now: datetime) -> bool:
252
+ if state.last_checked is None:
253
+ return True
254
+ return now - state.last_checked >= timedelta(seconds=interval_seconds())
255
+
256
+
257
+ def _unannounced(state: UpdateState) -> bool:
258
+ return state.notice is not None and state.announced_commit != state.notice.commit
259
+
260
+
261
+ def _look(checkout: Path) -> UpdateNotice | None:
262
+ """Fetch and compare, returning what an announcement would say."""
263
+ remote_ref = _tracking_ref(checkout)
264
+ if remote_ref is None:
265
+ return None
266
+ remote_name = remote_ref.partition("/")[0]
267
+ _git(checkout, "fetch", "--quiet", remote_name, timeout=FETCH_TIMEOUT_SECONDS)
268
+ local = _git(checkout, "rev-parse", "HEAD")
269
+ remote = _git(checkout, "rev-parse", remote_ref)
270
+ if not local or not remote or local == remote:
271
+ return None
272
+ behind = _git(checkout, "rev-list", "--count", f"{local}..{remote}")
273
+ if not behind or not behind.isdigit() or behind == "0":
274
+ return None
275
+ entries, truncated = _changes(checkout, local, remote)
276
+ return UpdateNotice(checkout, remote_ref, remote, int(behind), entries, truncated)
277
+
278
+
279
+ def _tracking_ref(checkout: Path) -> str | None:
280
+ """The upstream the checkout tracks, falling back to the default remote.
281
+
282
+ Someone following ``dev`` should be told about ``dev``, not about ``main``.
283
+ """
284
+ upstream = _git(
285
+ checkout, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"
286
+ )
287
+ if upstream and "/" in upstream:
288
+ return upstream
289
+ for candidate in ("origin/main", "origin/master"):
290
+ if _git(checkout, "rev-parse", "--verify", "--quiet", candidate):
291
+ return candidate
292
+ return None
293
+
294
+
295
+ def _changes(checkout: Path, local: str, remote: str) -> tuple[tuple[str, ...], int]:
296
+ """Summarize the update from the changelog, or from commit subjects."""
297
+ entries = _changelog_entries(checkout, local, remote)
298
+ if not entries:
299
+ entries = _commit_subjects(checkout, local, remote)
300
+ if len(entries) > MAX_ENTRIES:
301
+ return tuple(entries[:MAX_ENTRIES]), len(entries) - MAX_ENTRIES
302
+ return tuple(entries), 0
303
+
304
+
305
+ def _changelog_entries(checkout: Path, local: str, remote: str) -> list[str]:
306
+ """Bullets added to the changelog between the two commits.
307
+
308
+ This is the summary the project already writes by hand, so there is no
309
+ second place to keep an announcement up to date.
310
+ """
311
+ diff = _git(
312
+ checkout,
313
+ "diff",
314
+ "--unified=0",
315
+ f"{local}..{remote}",
316
+ "--",
317
+ CHANGELOG,
318
+ timeout=GIT_TIMEOUT_SECONDS,
319
+ )
320
+ entries: list[str] = []
321
+ current: list[str] = []
322
+ for line in diff.splitlines():
323
+ if not line.startswith("+") or line.startswith("+++"):
324
+ continue
325
+ body = line[1:]
326
+ if body.startswith("- "):
327
+ if current:
328
+ entries.append(" ".join(current))
329
+ current = [body[2:].strip()]
330
+ elif current and body.startswith(" "):
331
+ current.append(body.strip())
332
+ elif current:
333
+ entries.append(" ".join(current))
334
+ current = []
335
+ if current:
336
+ entries.append(" ".join(current))
337
+ # Each run of whitespace, e.g. " \n ", becomes one space.
338
+ return [_shorten(re.sub(r"\s+", " ", entry).strip()) for entry in entries if entry]
339
+
340
+
341
+ def _commit_subjects(checkout: Path, local: str, remote: str) -> list[str]:
342
+ log = _git(
343
+ checkout,
344
+ "log",
345
+ "--no-merges",
346
+ "--format=%s",
347
+ f"{local}..{remote}",
348
+ timeout=GIT_TIMEOUT_SECONDS,
349
+ )
350
+ return [_shorten(line.strip()) for line in log.splitlines() if line.strip()]
351
+
352
+
353
+ def _shorten(text: str) -> str:
354
+ if len(text) <= MAX_ENTRY_LENGTH:
355
+ return text
356
+ return text[: MAX_ENTRY_LENGTH - 1].rstrip() + "…"
357
+
358
+
359
+ def _git(checkout: Path, *arguments: str, timeout: float = GIT_TIMEOUT_SECONDS) -> str:
360
+ """Run one read-only Git command, returning "" for anything that fails.
361
+
362
+ An update notice is a convenience. No failure here -- no Git, no network,
363
+ a remote that wants credentials -- may disturb the command being run.
364
+ """
365
+ environment = {
366
+ **os.environ,
367
+ # Never block a ww command on a credential prompt.
368
+ "GIT_TERMINAL_PROMPT": "0",
369
+ "GIT_ASKPASS": "",
370
+ "GIT_SSH_COMMAND": os.environ.get(
371
+ "GIT_SSH_COMMAND", "ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new"
372
+ ),
373
+ "GIT_OPTIONAL_LOCKS": "0",
374
+ }
375
+ try:
376
+ result = subprocess.run(
377
+ ["git", *arguments],
378
+ cwd=checkout,
379
+ capture_output=True,
380
+ text=True,
381
+ check=False,
382
+ timeout=timeout,
383
+ env=environment,
384
+ )
385
+ except (OSError, subprocess.SubprocessError):
386
+ return ""
387
+ if result.returncode != 0:
388
+ return ""
389
+ return result.stdout.strip()
390
+
391
+
392
+ def _parse_timestamp(value: Any) -> datetime | None:
393
+ if not isinstance(value, str) or not value:
394
+ return None
395
+ try:
396
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
397
+ except ValueError:
398
+ return None
399
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
ww/upgrade.py ADDED
@@ -0,0 +1,95 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Explicit upgrades through the installer that owns this ww installation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from importlib.metadata import PackageNotFoundError, distribution
12
+ from pathlib import Path
13
+
14
+ from ww import __version__
15
+ from ww.errors import StateError
16
+ from ww.package_updates import PACKAGE_NAME, allows_prereleases
17
+ from ww.updates import installation_checkout
18
+
19
+
20
+ def _run(
21
+ command: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None
22
+ ) -> str:
23
+ try:
24
+ result = subprocess.run(
25
+ command, cwd=cwd, env=env, capture_output=True, text=True, check=False
26
+ )
27
+ except OSError as error:
28
+ raise StateError(f"Cannot run {command[0]}: {error}") from error
29
+ if result.returncode:
30
+ raise StateError(
31
+ result.stderr.strip() or result.stdout.strip() or "Upgrade failed"
32
+ )
33
+ return result.stdout.strip()
34
+
35
+
36
+ def _editable_install() -> bool:
37
+ try:
38
+ direct = distribution(PACKAGE_NAME).read_text("direct_url.json")
39
+ data = json.loads(direct) if direct else {}
40
+ except (PackageNotFoundError, ValueError):
41
+ return False
42
+ directory = data.get("dir_info") if isinstance(data, dict) else None
43
+ return isinstance(directory, dict) and directory.get("editable") is True
44
+
45
+
46
+ def upgrade(*, pre: bool = False) -> str:
47
+ """Upgrade through the installer or Git checkout that owns this installation."""
48
+ checkout = installation_checkout()
49
+ if checkout is not None:
50
+ if _run(["git", "status", "--porcelain"], cwd=checkout):
51
+ raise StateError(
52
+ "The ww checkout has local changes; commit or stash them first"
53
+ )
54
+ # Require the actual tracking branch: never pull a default into a feature.
55
+ _run(["git", "symbolic-ref", "--quiet", "HEAD"], cwd=checkout)
56
+ _run(["git", "rev-parse", "--verify", "@{upstream}"], cwd=checkout)
57
+ output = _run(["git", "pull", "--ff-only"], cwd=checkout)
58
+ return f"Updated the ww checkout at {checkout}.\n{output}\n"
59
+ if _editable_install():
60
+ raise StateError("This editable ww installation has no Git checkout to update")
61
+ include_pre = allows_prereleases(__version__, pre=pre)
62
+ metadata = Path(sys.prefix) / "pipx_metadata.json"
63
+ if metadata.is_file():
64
+ try:
65
+ data = json.loads(metadata.read_text())
66
+ package = data["main_package"]["package"]
67
+ environment = data.get("environment") or Path(sys.prefix).name
68
+ except (OSError, ValueError, KeyError, TypeError) as error:
69
+ raise StateError(
70
+ f"Cannot read pipx installation metadata: {error}"
71
+ ) from error
72
+ if package != PACKAGE_NAME:
73
+ raise StateError(
74
+ "ww is injected into another pipx environment; upgrade it with pipx"
75
+ )
76
+ if not isinstance(environment, str) or not environment:
77
+ raise StateError("pipx installation metadata has no environment name")
78
+ pipx = shutil.which("pipx")
79
+ if pipx is None:
80
+ raise StateError("This ww is managed by pipx, but pipx is not on PATH")
81
+ command = [pipx, "upgrade", environment]
82
+ if include_pre:
83
+ command.append("--pip-args=--pre")
84
+ else:
85
+ command = [sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE_NAME]
86
+ if include_pre:
87
+ command.append("--pre")
88
+ environment_variables = None
89
+ if metadata.is_file():
90
+ environment_variables = {
91
+ **os.environ,
92
+ "PIPX_HOME": str(Path(sys.prefix).parent.parent),
93
+ }
94
+ output = _run(command, env=environment_variables)
95
+ return f"ww upgrade finished.\n{output}\n"
ww/validation.py ADDED
@@ -0,0 +1,168 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Primitive value checks shared by YAML parsing and persisted-record decoding.
3
+
4
+ Both layers reject rather than coerce. They differ only in the exception they
5
+ raise: authored YAML reports ``ConfigurationError`` with a document path, saved
6
+ records report ``ValueError`` with a field context. Every helper therefore
7
+ takes the exception type as the ``error`` keyword. This module is a leaf and
8
+ imports nothing else from ``ww``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections.abc import Iterable, Mapping
15
+ from typing import Any, TypeGuard, get_args
16
+
17
+ # A normalized name, dots and hyphens allowed, e.g. "code-review" or "ww.git";
18
+ # "-review" does not match.
19
+ NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_.-]*")
20
+
21
+
22
+ def is_strict_int(value: object) -> TypeGuard[int]:
23
+ """Return whether ``value`` is an ``int`` and not a ``bool``.
24
+
25
+ ``bool`` subclasses ``int``, so a plain ``isinstance`` check would accept
26
+ ``True`` where a count or version is expected.
27
+ """
28
+ return isinstance(value, int) and not isinstance(value, bool)
29
+
30
+
31
+ def is_positive_int(value: object) -> TypeGuard[int]:
32
+ return is_strict_int(value) and value > 0
33
+
34
+
35
+ def expect_mapping(
36
+ value: object, context: str, *, error: type[Exception] = ValueError
37
+ ) -> dict[str, Any]:
38
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
39
+ raise error(f"{context} must be a mapping")
40
+ return value
41
+
42
+
43
+ def expect_optional_mapping(
44
+ value: object, context: str, *, error: type[Exception] = ValueError
45
+ ) -> dict[str, object] | None:
46
+ if value is None:
47
+ return None
48
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
49
+ raise error(f"{context} must be an object or null")
50
+ return dict(value)
51
+
52
+
53
+ def expect_string(
54
+ value: object, context: str, *, error: type[Exception] = ValueError
55
+ ) -> str:
56
+ if not isinstance(value, str):
57
+ raise error(f"{context} must be a string")
58
+ return value
59
+
60
+
61
+ def expect_optional_string(
62
+ value: object, context: str, *, error: type[Exception] = ValueError
63
+ ) -> str | None:
64
+ if value is not None and not isinstance(value, str):
65
+ raise error(f"{context} must be a string or null")
66
+ return value
67
+
68
+
69
+ def expect_nonempty_string(
70
+ value: object, context: str, *, error: type[Exception] = ValueError
71
+ ) -> str:
72
+ if not isinstance(value, str) or not value.strip():
73
+ raise error(f"{context} must be a non-empty string")
74
+ return value
75
+
76
+
77
+ def expect_normalized_name(
78
+ value: object, context: str, *, error: type[Exception] = ValueError
79
+ ) -> str:
80
+ if not isinstance(value, str) or not NAME_PATTERN.fullmatch(value):
81
+ raise error(f"{context} must be a non-empty normalized name")
82
+ return value
83
+
84
+
85
+ def expect_bool(
86
+ value: object, context: str, *, error: type[Exception] = ValueError
87
+ ) -> bool:
88
+ if not isinstance(value, bool):
89
+ raise error(f"{context} must be a boolean")
90
+ return value
91
+
92
+
93
+ def expect_positive_int(
94
+ value: object, context: str, *, error: type[Exception] = ValueError
95
+ ) -> int:
96
+ if not is_positive_int(value):
97
+ raise error(f"{context} must be a positive integer")
98
+ return value
99
+
100
+
101
+ def expect_nonnegative_int(
102
+ value: object, context: str, *, error: type[Exception] = ValueError
103
+ ) -> int:
104
+ if not is_strict_int(value) or value < 0:
105
+ raise error(f"{context} must be a non-negative integer")
106
+ return value
107
+
108
+
109
+ def expect_optional_int(
110
+ value: object, context: str, *, error: type[Exception] = ValueError
111
+ ) -> int | None:
112
+ if value is not None and not is_strict_int(value):
113
+ raise error(f"{context} must be an integer or null")
114
+ return value
115
+
116
+
117
+ def expect_literal(
118
+ value: object, literal: Any, context: str, *, error: type[Exception] = ValueError
119
+ ) -> Any:
120
+ """Return ``value`` if it is one of the members of a ``Literal`` alias.
121
+
122
+ The result is typed ``Any`` because a ``Literal`` alias is not a runtime
123
+ type; callers narrow it with the alias they passed.
124
+ """
125
+ allowed = get_args(literal)
126
+ if value not in allowed:
127
+ raise error(f"{context} must be one of {', '.join(map(repr, allowed))}")
128
+ return value
129
+
130
+
131
+ def require_keys(
132
+ data: Mapping[str, Any],
133
+ required: Iterable[str],
134
+ context: str,
135
+ *,
136
+ error: type[Exception] = ValueError,
137
+ ) -> None:
138
+ missing = set(required) - data.keys()
139
+ if missing:
140
+ raise error(f"{context} missing field(s): {', '.join(sorted(missing))}")
141
+
142
+
143
+ def reject_unknown_keys(
144
+ data: Mapping[str, Any],
145
+ allowed: Iterable[str],
146
+ context: str,
147
+ *,
148
+ error: type[Exception] = ValueError,
149
+ ) -> None:
150
+ unknown = data.keys() - set(allowed)
151
+ if unknown:
152
+ raise error(f"{context} has unknown key(s): {', '.join(sorted(unknown))}")
153
+
154
+
155
+ def expect_keys(
156
+ data: Mapping[str, Any],
157
+ expected: Iterable[str],
158
+ context: str,
159
+ *,
160
+ error: type[Exception] = ValueError,
161
+ ) -> None:
162
+ """Require every ``expected`` key; leave any other key alone.
163
+
164
+ Used for stored data: a field ww does not know, such as one a newer
165
+ version wrote, is ignored rather than refused.
166
+ """
167
+ if not set(expected) <= data.keys():
168
+ raise error(f"{context} has invalid fields")