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/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")
|