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
@@ -0,0 +1,337 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Markdown for the rule commands: ``check``, ``rule`` and ``rules``.
3
+
4
+ ``ww check`` reads like the fix page a completion would produce, so a worker
5
+ sees the same failures in the same shape before it completes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ww.instructions.commands import dispute_command
11
+ from ww.instructions.models import CheckPreview
12
+ from ww.output_adapters.markdown import (
13
+ Lines,
14
+ _append_section,
15
+ _document,
16
+ _fix_failures,
17
+ _waivers,
18
+ )
19
+ from ww.rule_conversion import StoreChange, scriptize_state
20
+ from ww.rule_store import (
21
+ CheckEntry,
22
+ RuleAutomation,
23
+ describe_command,
24
+ )
25
+ from ww.rule_views import ListedRule, Orphans, RulesListing, RuleView
26
+ from ww.workflow_config import RuleDefinition
27
+
28
+
29
+ def _ids(ids: tuple[str, ...]) -> str:
30
+ return ", ".join(f"`{item}`" for item in ids)
31
+
32
+
33
+ def render_check_preview(preview: CheckPreview) -> str:
34
+ lines: Lines = [f"# {preview.task_id} · check `{preview.step}`", ""]
35
+ if not preview.checks and not preview.judged:
36
+ lines.append(
37
+ "This step has no checks to run and no rules for a verifier to judge."
38
+ )
39
+ return _document(lines)
40
+ if preview.failures:
41
+ lines.extend(
42
+ [
43
+ f"## {len(preview.failures)} of {preview.checks} checks failed",
44
+ "",
45
+ "Completing now would be rejected for these checks, on the files "
46
+ "this step changed so far:",
47
+ ]
48
+ )
49
+ _fix_failures(lines, preview.failures)
50
+ lines.extend(
51
+ [
52
+ "",
53
+ "Fix the causes, or, when a check that already rejected a "
54
+ "completion is wrong for this change, dispute it:",
55
+ "",
56
+ "```console",
57
+ dispute_command(preview.task_id, assignment="<your-assignment-token>"),
58
+ "```",
59
+ ]
60
+ )
61
+ elif preview.checks:
62
+ lines.append(f"All checks pass ({preview.checks} run).")
63
+ others = [
64
+ (label, ids)
65
+ for label, ids in (
66
+ ("Passed", preview.passed),
67
+ (
68
+ "Not applicable (their globs match no changed file)",
69
+ preview.not_applicable,
70
+ ),
71
+ )
72
+ if ids
73
+ ]
74
+ if others:
75
+ lines.append("")
76
+ lines.extend(f"{label}: {_ids(ids)}." for label, ids in others)
77
+ if preview.judged:
78
+ lines.extend(
79
+ [
80
+ "",
81
+ f"Judged at completion: {_ids(preview.judged)}. A verifier, not "
82
+ "you, judges these rules after you complete.",
83
+ ]
84
+ )
85
+ for reason, waived in _waivers(preview.waived):
86
+ lines.extend(["", f"Waived by the operator ({_ids(tuple(waived))}): {reason}."])
87
+ if preview.all_files:
88
+ lines.extend(
89
+ [
90
+ "",
91
+ "There is no change set (no git), so the checks saw every file "
92
+ "of the project their globs select.",
93
+ ]
94
+ )
95
+ lines.extend(
96
+ [
97
+ "",
98
+ "Nothing was recorded: this does not count as an attempt, and the "
99
+ "checks' output was not kept.",
100
+ ]
101
+ )
102
+ return _document(lines)
103
+
104
+
105
+ def render_rule_view(view: RuleView) -> str:
106
+ kind = {
107
+ "rule": "Rule",
108
+ "hook": "Hook check",
109
+ "derived": "Derived check",
110
+ }.get(view.kind, "Check")
111
+ lines: Lines = [f"# {kind} `{view.id}`", "", view.text]
112
+ facts: Lines = []
113
+ if view.paths:
114
+ facts.append(f"- Applies to: {', '.join(view.paths)}")
115
+ if view.source:
116
+ facts.append(f"- Rule file: {view.source}")
117
+ elif view.kind == "rule":
118
+ facts.append("- Written in the step's own `rules` list.")
119
+ elif view.kind == "hook":
120
+ facts.append("- A `before_complete` hook with `on_failure: fix`.")
121
+ elif view.kind == "derived":
122
+ facts.append(
123
+ "- An approved check from the rule-automation store, covering "
124
+ + _ids(view.covers)
125
+ + "."
126
+ )
127
+ if view.steps:
128
+ facts.append(f"- Steps of this task: {_ids(view.steps)}")
129
+ if view.command:
130
+ facts.append(f"- Command: `{view.command}`")
131
+ if view.assertion:
132
+ facts.append(f"- {view.assertion}")
133
+ if view.max_fixes is not None:
134
+ facts.append(f"- Fix rounds allowed: {view.max_fixes}")
135
+ elif view.kind == "rule":
136
+ facts.append("- No command: a verifier judges it when the step completes.")
137
+ if view.store_status:
138
+ store = f"- Rule-automation store: {view.store_status}"
139
+ if view.store_check:
140
+ store += f" (check `{view.store_check}`)"
141
+ facts.append(store)
142
+ elif view.kind == "rule" and not view.command:
143
+ facts.append("- Rule-automation store: nothing yet")
144
+ if view.interpretation:
145
+ facts.append(f"- Interpretation: {view.interpretation}")
146
+ if view.resolution:
147
+ facts.append(f"- Enforced in the current step as: {view.resolution}")
148
+ if view.text_hash:
149
+ facts.append(f"- Wording hash: {view.text_hash[:12]}")
150
+ lines.extend(["", *facts])
151
+ return _document(lines)
152
+
153
+
154
+ def render_rules_listing(listing: RulesListing) -> str:
155
+ lines: Lines = ["# Rules"]
156
+ if not listing.groups and not listing.steps:
157
+ lines.extend(["", "No rules are declared."])
158
+ return _document(lines)
159
+ for group in listing.groups:
160
+ _append_section(lines, f"Group `{group.name}`")
161
+ filters = [
162
+ f"{label} {_ids(names.listed)}"
163
+ for label, names in (("workflows", group.workflows), ("steps", group.steps))
164
+ if not names.admits_all
165
+ ]
166
+ if group.workflows.admits_none or group.steps.admits_none:
167
+ scope = "only where a step names it"
168
+ elif filters:
169
+ scope = "; ".join(filters)
170
+ else:
171
+ scope = "every step"
172
+ lines.append(f"Applies to: {scope}.")
173
+ if group.origin != "configuration":
174
+ lines.append(f"Shipped by: {group.origin}.")
175
+ if group.hints:
176
+ lines.append(
177
+ "Verifier hints: "
178
+ + ", ".join(f"{key} {value}" for key, value in group.hints.items())
179
+ + "."
180
+ )
181
+ _rule_lines(lines, group.rules)
182
+ for step in listing.steps:
183
+ _append_section(lines, f"Step `{step.step}`")
184
+ if step.groups:
185
+ lines.append(f"Names the groups {_ids(step.groups)}.")
186
+ _rule_lines(lines, step.rules)
187
+ return _document(lines)
188
+
189
+
190
+ # How a rule without a command is enforced when no store check runs for it.
191
+ _JUDGED_STATES = {
192
+ "not_convertible": " (judged: declined for scriptizing)",
193
+ "rejected": " (judged: its check was rejected)",
194
+ "unscriptized": " (judged: not scriptized yet)",
195
+ }
196
+
197
+
198
+ def _rule_lines(lines: Lines, rules: tuple[ListedRule, ...]) -> None:
199
+ if not rules:
200
+ return
201
+ lines.append("")
202
+ for rule in rules:
203
+ scope = f" — {', '.join(rule.paths)}" if rule.paths else ""
204
+ check = (
205
+ " (checked by its command)"
206
+ if rule.has_check
207
+ else f" (checked by store check `{rule.store_check}`)"
208
+ if rule.store_check
209
+ else _JUDGED_STATES.get(rule.scriptize, "")
210
+ )
211
+ disputed = (
212
+ f" (disputed {rule.disputes} time{'s' if rule.disputes != 1 else ''})"
213
+ if rule.disputes
214
+ else ""
215
+ )
216
+ lines.append(f"- `{rule.id}`{scope} — {rule.summary}{check}{disputed}")
217
+
218
+
219
+ def render_orphans(listed: Orphans, automation: RuleAutomation) -> str:
220
+ """The store entries ``ww rules prune`` would delete."""
221
+ if not listed:
222
+ return "The rule-automation store has no orphan entries.\n"
223
+ lines = ["Orphan entries in the rule-automation store:"]
224
+ lines.extend(
225
+ f"- rule {key[:12]} ({automation.rules[key].status}): "
226
+ f"{automation.rules[key].text}"
227
+ for key in listed.rules
228
+ )
229
+ lines.extend(
230
+ f"- check {name} ({automation.checks[name].status})" for name in listed.checks
231
+ )
232
+ return "\n".join(lines) + "\n"
233
+
234
+
235
+ def render_convert_preview(
236
+ name: str,
237
+ rules: tuple[RuleDefinition, ...],
238
+ current: RuleAutomation,
239
+ change: StoreChange,
240
+ ) -> str:
241
+ """What ``ww rules convert`` would record, command in full."""
242
+ spec = change.automation.checks[name].spec
243
+ action = "replaces" if name in current.checks else "creates"
244
+ lines = [
245
+ f"Check {name} ({action} the store's entry): {describe_command(spec.command)}",
246
+ ]
247
+ if spec.command.assertion is not None:
248
+ lines.append(f"- output must be: {spec.command.assertion.describe()}")
249
+ lines.append(
250
+ "- config files: " + (", ".join(spec.config) if spec.config else "none")
251
+ )
252
+ lines.append(f"- proven: {'yes' if spec.proven else 'no'}")
253
+ lines.append(f"- covers {len(rules)} rule(s):")
254
+ _covered_rules(lines, rules, current)
255
+ _side_effects(lines, rules, current, change)
256
+ return "\n".join(lines) + "\n"
257
+
258
+
259
+ def render_decline_preview(
260
+ rules: tuple[RuleDefinition, ...],
261
+ reason: str,
262
+ current: RuleAutomation,
263
+ change: StoreChange,
264
+ ) -> str:
265
+ """What ``ww rules decline`` would record."""
266
+ lines = [f"Rules to record as not convertible ({reason}):"]
267
+ _covered_rules(lines, rules, current)
268
+ _side_effects(lines, rules, current, change)
269
+ return "\n".join(lines) + "\n"
270
+
271
+
272
+ def _covered_rules(
273
+ lines: Lines, rules: tuple[RuleDefinition, ...], current: RuleAutomation
274
+ ) -> None:
275
+ """Each rule with where it stands in the store now."""
276
+ for rule in rules:
277
+ entry = current.rules.get(rule.text_hash)
278
+ state: str = scriptize_state(current, rule)
279
+ if state == "converted" and entry is not None and entry.check:
280
+ state = f"converted by check {entry.check}"
281
+ lines.append(f" - `{rule.id}`: {rule.summary} (now: {state})")
282
+
283
+
284
+ def _side_effects(
285
+ lines: Lines,
286
+ rules: tuple[RuleDefinition, ...],
287
+ current: RuleAutomation,
288
+ change: StoreChange,
289
+ ) -> None:
290
+ """The store's other changes: moved rules, unscriptized ones, drops."""
291
+ ids = {rule.text_hash: rule.id for rule in rules}
292
+ for key, check in change.moved:
293
+ lines.append(f"- takes `{ids[key]}` out of check {check}")
294
+ for key in change.unscriptized:
295
+ lines.append(
296
+ f"- returns {_rule_name(current, key)} to not scriptized: no longer covered"
297
+ )
298
+ for check in change.dropped_checks:
299
+ status = current.checks[check].status
300
+ lines.append(f"- removes check {check} ({status}): it covers nothing more")
301
+ for check in change.dropped_revisions:
302
+ lines.append(f"- drops the pending revision of check {check}")
303
+
304
+
305
+ def _rule_name(automation: RuleAutomation, key: str) -> str:
306
+ entry = automation.rules.get(key)
307
+ text = entry.text.splitlines()[0] if entry is not None and entry.text else ""
308
+ return f"rule {key[:12]}" + (f" ({text})" if text else "")
309
+
310
+
311
+ def render_revoke_preview(name: str, check: CheckEntry) -> str:
312
+ """The check ``ww rules revoke`` would reject, as the operator reads it."""
313
+ spec = check.pending or check.spec
314
+ lines = [
315
+ f"Check {name} ({check.status}): {describe_command(spec.command)}",
316
+ f"- covers {len(spec.covers)} rule(s)",
317
+ ]
318
+ if check.approved_by is not None:
319
+ lines.append(f"- approved by {check.approved_by}")
320
+ if spec.config:
321
+ lines.append("- config files: " + ", ".join(spec.config))
322
+ return "\n".join(lines) + "\n"
323
+
324
+
325
+ def render_revoked(name: str, rules: tuple[str, ...], config: tuple[str, ...]) -> str:
326
+ """What ``ww rules revoke`` changed, and what it left for the operator."""
327
+ listed = " (" + ", ".join(key[:12] for key in rules) + ")" if rules else ""
328
+ files = f" and the check's config files ({', '.join(config)})" if config else ""
329
+ lines = [
330
+ f"Revoked check {name}: it is rejected in the rule-automation store "
331
+ "and no longer runs.",
332
+ f"Rules rejected with it, judged by a verifier from now on: "
333
+ f"{len(rules)}{listed}",
334
+ f"Nothing else changed: the YAML and rule files{files} stay as they "
335
+ "are, for the operator to keep or remove.",
336
+ ]
337
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,21 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Terminal presentation shared by init prompts and completion output."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+
9
+
10
+ def terminal_accent(text: str) -> str:
11
+ if (
12
+ sys.stdout.isatty()
13
+ and "NO_COLOR" not in os.environ
14
+ and os.environ.get("TERM") != "dumb"
15
+ ):
16
+ return f"\033[1;36m{text}\033[0m"
17
+ return text
18
+
19
+
20
+ def initialization_progress(percent: int) -> str:
21
+ return terminal_accent(f"[{str(percent).rjust(6, '.')}%]")
ww/package_updates.py ADDED
@@ -0,0 +1,167 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Compatible PyPI versions and cached notices for packaged installations."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import platform
9
+ import sys
10
+ from dataclasses import asdict, dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.error import URLError
15
+ from urllib.request import Request, urlopen
16
+
17
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
18
+ from packaging.tags import sys_tags
19
+ from packaging.utils import InvalidWheelFilename, parse_wheel_filename
20
+ from packaging.version import InvalidVersion, Version
21
+
22
+ from ww import __version__
23
+ from ww.executable import ww_command
24
+ from ww.updates import interval_seconds, state_path
25
+
26
+ PACKAGE_NAME = "ww-agentic-workflows"
27
+ PYPI_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
28
+ HTTP_TIMEOUT_SECONDS = 2.0
29
+ MAX_RESPONSE_BYTES = 2_000_000
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class PackageUpdateNotice:
34
+ installed_version: str
35
+ available_version: str
36
+
37
+ def to_dict(self) -> dict[str, str]:
38
+ return asdict(self)
39
+
40
+ def render(self) -> str:
41
+ return (
42
+ "## A newer ww is available\n\n"
43
+ f"{self.installed_version} → {self.available_version}\n"
44
+ f"Run: `{ww_command()} upgrade`\n\n"
45
+ "This notice is shown once. `ww updates` prints it again.\n\n---\n\n"
46
+ )
47
+
48
+
49
+ def allows_prereleases(installed: str, *, pre: bool = False) -> bool:
50
+ return pre or Version(installed).is_prerelease
51
+
52
+
53
+ def latest_compatible(
54
+ data: dict[str, Any], installed: str, *, pre: bool = False
55
+ ) -> str | None:
56
+ """Select only usable, non-yanked releases newer than this installation."""
57
+ current = Version(installed)
58
+ releases = data.get("releases")
59
+ if not isinstance(releases, dict):
60
+ return None
61
+ candidates: list[Version] = []
62
+ supported_tags = set(sys_tags())
63
+ for text, files in releases.items():
64
+ try:
65
+ candidate = Version(text)
66
+ except (InvalidVersion, TypeError):
67
+ continue
68
+ if candidate <= current or (
69
+ candidate.is_prerelease and not allows_prereleases(installed, pre=pre)
70
+ ):
71
+ continue
72
+ if not isinstance(files, list):
73
+ continue
74
+ for file in files:
75
+ if not isinstance(file, dict) or file.get("yanked", False):
76
+ continue
77
+ try:
78
+ requirement = file.get("requires_python") or ""
79
+ if not SpecifierSet(requirement).contains(platform.python_version()):
80
+ continue
81
+ if file.get("packagetype") == "bdist_wheel":
82
+ _, _, _, wheel_tags = parse_wheel_filename(file["filename"])
83
+ if not wheel_tags.intersection(supported_tags):
84
+ continue
85
+ elif file.get("packagetype") != "sdist":
86
+ continue
87
+ except (InvalidSpecifier, InvalidWheelFilename, KeyError, TypeError):
88
+ continue
89
+ candidates.append(candidate)
90
+ break
91
+ return str(max(candidates)) if candidates else None
92
+
93
+
94
+ def fetch_releases() -> dict[str, Any] | None:
95
+ """Bound the network request; offline or malformed responses stay quiet."""
96
+ try:
97
+ request = Request(PYPI_URL, headers={"User-Agent": f"ww/{__version__}"})
98
+ with urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
99
+ body = response.read(MAX_RESPONSE_BYTES + 1)
100
+ if len(body) > MAX_RESPONSE_BYTES:
101
+ return None
102
+ data = json.loads(body)
103
+ except (OSError, URLError, ValueError):
104
+ return None
105
+ return data if isinstance(data, dict) else None
106
+
107
+
108
+ def package_state_path() -> Path:
109
+ # Keep separate installations and stable/prerelease preferences independent.
110
+ key = hashlib.sha256(f"{sys.prefix}:{__version__}".encode()).hexdigest()[:16]
111
+ return state_path().with_name(f"package-updates-{key}.json")
112
+
113
+
114
+ def _load(path: Path) -> dict[str, Any]:
115
+ try:
116
+ data = json.loads(path.read_text())
117
+ except (OSError, ValueError):
118
+ return {}
119
+ return data if isinstance(data, dict) else {}
120
+
121
+
122
+ def _save(path: Path, data: dict[str, Any]) -> None:
123
+ try:
124
+ path.parent.mkdir(parents=True, exist_ok=True)
125
+ temporary = path.with_suffix(".tmp")
126
+ temporary.write_text(json.dumps(data))
127
+ temporary.replace(path)
128
+ except OSError:
129
+ return
130
+
131
+
132
+ def last_package_notice() -> PackageUpdateNotice | None:
133
+ data = _load(package_state_path())
134
+ available = data.get("available_version")
135
+ try:
136
+ if isinstance(available, str) and Version(available) > Version(__version__):
137
+ return PackageUpdateNotice(__version__, available)
138
+ except InvalidVersion:
139
+ pass
140
+ return None
141
+
142
+
143
+ def pending_package_notice(*, force: bool = False) -> PackageUpdateNotice | None:
144
+ path = package_state_path()
145
+ data = _load(path)
146
+ now = datetime.now(timezone.utc)
147
+ try:
148
+ checked = datetime.fromisoformat(data["last_checked"])
149
+ due = (now - checked).total_seconds() >= interval_seconds()
150
+ except (KeyError, ValueError, TypeError):
151
+ due = True
152
+ if force or due:
153
+ releases = fetch_releases()
154
+ available = latest_compatible(releases, __version__) if releases else None
155
+ data.update(last_checked=now.isoformat(), available_version=available)
156
+ _save(path, data)
157
+ notice = last_package_notice()
158
+ if notice and (force or data.get("announced_version") != notice.available_version):
159
+ return notice
160
+ return None
161
+
162
+
163
+ def mark_package_announced(notice: PackageUpdateNotice) -> None:
164
+ path = package_state_path()
165
+ data = _load(path)
166
+ data["announced_version"] = notice.available_version
167
+ _save(path, data)
ww/plan/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Public plan data and compilation API."""
3
+
4
+ from ww.operations import ChildWorkflowRun, LoopBoundary, PlanOperation, WorkflowHandoff
5
+
6
+ from .compiler import (
7
+ ExecutionHints,
8
+ PlanCompilationOptions,
9
+ WorkflowPlanCompiler,
10
+ compile_workflow_plan,
11
+ )
12
+ from .models import (
13
+ PlanItem,
14
+ PlannedCheck,
15
+ PlannedMode,
16
+ PlannedRule,
17
+ VerificationTarget,
18
+ WorkflowPlan,
19
+ number_step_paths,
20
+ )
21
+
22
+ __all__ = [
23
+ "ExecutionHints",
24
+ "ChildWorkflowRun",
25
+ "LoopBoundary",
26
+ "PlanOperation",
27
+ "PlanCompilationOptions",
28
+ "PlanItem",
29
+ "PlannedCheck",
30
+ "PlannedMode",
31
+ "PlannedRule",
32
+ "VerificationTarget",
33
+ "WorkflowPlan",
34
+ "WorkflowHandoff",
35
+ "WorkflowPlanCompiler",
36
+ "compile_workflow_plan",
37
+ "number_step_paths",
38
+ ]