patchahead 0.3.0__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 (75) hide show
  1. patchahead/__init__.py +8 -0
  2. patchahead/analysis/__init__.py +52 -0
  3. patchahead/analysis/edits.py +143 -0
  4. patchahead/analysis/index.py +203 -0
  5. patchahead/analysis/python_ast.py +457 -0
  6. patchahead/apidiff/__init__.py +23 -0
  7. patchahead/apidiff/compare.py +366 -0
  8. patchahead/apidiff/download.py +95 -0
  9. patchahead/apidiff/surface.py +337 -0
  10. patchahead/ci.py +301 -0
  11. patchahead/cli.py +627 -0
  12. patchahead/config.py +284 -0
  13. patchahead/demo/__init__.py +256 -0
  14. patchahead/demo/fixtures/changes/field-rename.md +14 -0
  15. patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
  16. patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
  17. patchahead/demo/fixtures/changes/method-rename.md +12 -0
  18. patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
  19. patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
  20. patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
  21. patchahead/demo/fixtures/orders-service/README.md +51 -0
  22. patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
  23. patchahead/demo/fixtures/orders-service/app/client.py +15 -0
  24. patchahead/demo/fixtures/orders-service/app/models.py +10 -0
  25. patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
  26. patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
  27. patchahead/demo/fixtures/orders-service/conftest.py +6 -0
  28. patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
  29. patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
  30. patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
  31. patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
  32. patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
  33. patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
  34. patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
  35. patchahead/demo/serve.py +189 -0
  36. patchahead/domain/__init__.py +67 -0
  37. patchahead/domain/change.py +269 -0
  38. patchahead/domain/completeness.py +91 -0
  39. patchahead/domain/impact.py +248 -0
  40. patchahead/domain/patch.py +81 -0
  41. patchahead/domain/plan.py +170 -0
  42. patchahead/domain/result.py +210 -0
  43. patchahead/domain/validation.py +200 -0
  44. patchahead/engine.py +609 -0
  45. patchahead/handlers/__init__.py +35 -0
  46. patchahead/handlers/base.py +211 -0
  47. patchahead/handlers/field_rename.py +425 -0
  48. patchahead/handlers/kwarg_rename.py +201 -0
  49. patchahead/handlers/method_rename.py +608 -0
  50. patchahead/handlers/pagination.py +582 -0
  51. patchahead/ingest/__init__.py +32 -0
  52. patchahead/ingest/base.py +102 -0
  53. patchahead/ingest/markdown.py +1138 -0
  54. patchahead/ingest/structured.py +218 -0
  55. patchahead/llm/__init__.py +28 -0
  56. patchahead/llm/client.py +152 -0
  57. patchahead/llm/proposer.py +620 -0
  58. patchahead/observability.py +223 -0
  59. patchahead/reporting.py +451 -0
  60. patchahead/testing/__init__.py +22 -0
  61. patchahead/testing/discovery.py +113 -0
  62. patchahead/testing/runner.py +138 -0
  63. patchahead/validation/__init__.py +5 -0
  64. patchahead/validation/completeness.py +265 -0
  65. patchahead/validation/engine.py +531 -0
  66. patchahead/web/__init__.py +13 -0
  67. patchahead/web/server.py +279 -0
  68. patchahead/web/static/index.html +650 -0
  69. patchahead/workspace.py +382 -0
  70. patchahead-0.3.0.dist-info/METADATA +368 -0
  71. patchahead-0.3.0.dist-info/RECORD +75 -0
  72. patchahead-0.3.0.dist-info/WHEEL +5 -0
  73. patchahead-0.3.0.dist-info/entry_points.txt +2 -0
  74. patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
  75. patchahead-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,218 @@
1
+ """Parsing structured change descriptions (JSON and YAML).
2
+
3
+ The escape hatch for when release-note prose is not good enough: a user (or a
4
+ future spec-diff tool) states the change explicitly and PatchAhead does no
5
+ guessing. Structured input therefore defaults to ``Confidence.HIGH`` -- the
6
+ uncertainty in the Markdown path is entirely about *reading English*, and that
7
+ uncertainty is absent here.
8
+
9
+ Schema, as a single change or a list of them under ``changes``::
10
+
11
+ {
12
+ "title": "Order field renamed", # required
13
+ "kind": "field_rename", # required
14
+ "old_behavior": "...",
15
+ "new_behavior": "...",
16
+ "migration_hint": "...",
17
+ "severity": "high" | "medium" | "low",
18
+ "confidence": "high" | "medium" | "low",
19
+ "target": {"symbol": "total", "replacement": "amount", "owner": "order",
20
+ "owner_explicit": true},
21
+ "pagination": {"page_param": "page", "total_pages_key": "total_pages", ...},
22
+ "evidence": ["quoted line", {"quote": "...", "line": 12, "note": "..."}]
23
+ }
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ from typing import Any
31
+
32
+ from patchahead.domain.change import (
33
+ BreakingChange,
34
+ ChangeKind,
35
+ Confidence,
36
+ Evidence,
37
+ PaginationContract,
38
+ Severity,
39
+ SymbolTarget,
40
+ )
41
+ from patchahead.ingest.base import ChangeDocument, ChangeParser, IngestError, register
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+ _VALID_KINDS = ", ".join(sorted(k.value for k in ChangeKind))
46
+
47
+
48
+ def _parse_yaml(text: str, path: str) -> Any:
49
+ try:
50
+ import yaml
51
+ except ImportError: # pragma: no cover - depends on the environment
52
+ raise IngestError(
53
+ f"cannot parse {path}: YAML support requires PyYAML "
54
+ "(pip install 'patchahead[yaml]'). JSON change documents work without it."
55
+ ) from None
56
+ try:
57
+ return yaml.safe_load(text)
58
+ except Exception as exc:
59
+ raise IngestError(f"{path} is not valid YAML: {exc}") from exc
60
+
61
+
62
+ def _evidence_from(raw: Any) -> list[Evidence]:
63
+ if not isinstance(raw, list):
64
+ return []
65
+ evidence: list[Evidence] = []
66
+ for item in raw:
67
+ if isinstance(item, str):
68
+ evidence.append(Evidence(quote=item))
69
+ elif isinstance(item, dict):
70
+ line = item.get("line")
71
+ evidence.append(
72
+ Evidence(
73
+ quote=str(item.get("quote", "")),
74
+ line=int(line) if isinstance(line, int) else None,
75
+ note=str(item.get("note", "")),
76
+ )
77
+ )
78
+ return evidence
79
+
80
+
81
+ def change_from_mapping(data: dict[str, Any], path: str, source: str) -> BreakingChange:
82
+ """Build one :class:`BreakingChange` from a mapping, validating as we go."""
83
+ title = data.get("title")
84
+ if not isinstance(title, str) or not title.strip():
85
+ raise IngestError(f"{path}: each change needs a non-empty `title`")
86
+
87
+ raw_kind = data.get("kind") or data.get("change_type")
88
+ if not isinstance(raw_kind, str):
89
+ raise IngestError(f"{path}: change {title!r} needs a `kind`. One of: {_VALID_KINDS}")
90
+ try:
91
+ kind = ChangeKind(raw_kind.strip().lower())
92
+ except ValueError:
93
+ raise IngestError(
94
+ f"{path}: change {title!r} has unknown kind {raw_kind!r}. One of: {_VALID_KINDS}"
95
+ ) from None
96
+
97
+ raw_target = data.get("target") or {}
98
+ if not isinstance(raw_target, dict):
99
+ raise IngestError(f"{path}: change {title!r} has a non-mapping `target`")
100
+ owner = str(raw_target.get("owner", "") or "")
101
+ explicit = raw_target.get("owner_explicit", True)
102
+ if not isinstance(explicit, bool):
103
+ raise IngestError(f"{path}: change {title!r} has a non-boolean `owner_explicit`")
104
+ target = SymbolTarget(
105
+ symbol=str(raw_target.get("symbol", "") or ""),
106
+ replacement=str(raw_target.get("replacement", "") or ""),
107
+ owner=owner,
108
+ # Someone typed this into a field named `owner`, which is an assertion
109
+ # by construction -- a receiver mismatch refuses rather than guessing.
110
+ # A generator that knows the class but not what callers name its
111
+ # instance (`api-diff`) writes `"owner_explicit": false` instead.
112
+ owner_is_explicit=bool(owner) and explicit,
113
+ )
114
+
115
+ raw_pagination = data.get("pagination")
116
+ if raw_pagination is not None and not isinstance(raw_pagination, dict):
117
+ raise IngestError(f"{path}: change {title!r} has a non-mapping `pagination`")
118
+
119
+ return BreakingChange(
120
+ title=title.strip(),
121
+ kind=kind,
122
+ target=target,
123
+ old_behavior=str(data.get("old_behavior", "") or ""),
124
+ new_behavior=str(data.get("new_behavior", "") or ""),
125
+ migration_hint=str(data.get("migration_hint", "") or ""),
126
+ severity=Severity.parse(data.get("severity"), Severity.MEDIUM),
127
+ # Structured input is an explicit assertion by whoever wrote it; take it
128
+ # at face value unless it says otherwise.
129
+ confidence=Confidence.parse(data.get("confidence"), Confidence.HIGH),
130
+ evidence=_evidence_from(data.get("evidence")),
131
+ pagination=PaginationContract.from_dict(raw_pagination),
132
+ source=source,
133
+ classification_reason=str(
134
+ data.get("classification_reason")
135
+ or f"declared explicitly as `{kind.value}` in a structured change document"
136
+ ),
137
+ )
138
+
139
+
140
+ def change_to_mapping(change: BreakingChange) -> dict[str, Any]:
141
+ """The inverse of :func:`change_from_mapping`: a change as a structured document entry."""
142
+ entry: dict[str, Any] = {
143
+ "title": change.title,
144
+ "kind": change.kind.value,
145
+ "severity": change.severity.value,
146
+ "confidence": change.confidence.value,
147
+ "classification_reason": change.classification_reason,
148
+ }
149
+ if change.target.symbol or change.target.owner:
150
+ entry["target"] = {
151
+ "symbol": change.target.symbol,
152
+ "replacement": change.target.replacement,
153
+ "owner": change.target.owner,
154
+ "owner_explicit": change.target.owner_is_explicit,
155
+ }
156
+ if change.kind is ChangeKind.PAGINATION_PAGE_TO_CURSOR:
157
+ entry["pagination"] = change.pagination.to_dict()
158
+ if change.evidence:
159
+ entry["evidence"] = [
160
+ {"quote": e.quote, "line": e.line, "note": e.note}
161
+ if e.line
162
+ else {"quote": e.quote, "note": e.note}
163
+ for e in change.evidence
164
+ ]
165
+ return entry
166
+
167
+
168
+ class StructuredChangeParser(ChangeParser):
169
+ """Parses JSON and YAML change descriptions."""
170
+
171
+ name = "structured"
172
+ suffixes = (".json", ".yaml", ".yml")
173
+
174
+ def supports(self, document: ChangeDocument) -> bool:
175
+ return document.suffix in self.suffixes
176
+
177
+ def parse(self, document: ChangeDocument) -> list[BreakingChange]:
178
+ if document.suffix == ".json":
179
+ try:
180
+ data = json.loads(document.text)
181
+ except json.JSONDecodeError as exc:
182
+ raise IngestError(
183
+ f"{document.path} is not valid JSON: {exc.msg} "
184
+ f"(line {exc.lineno}, column {exc.colno})"
185
+ ) from exc
186
+ else:
187
+ data = _parse_yaml(document.text, document.path)
188
+
189
+ if isinstance(data, dict) and "changes" in data:
190
+ entries = data["changes"]
191
+ elif isinstance(data, list):
192
+ entries = data
193
+ elif isinstance(data, dict):
194
+ entries = [data]
195
+ else:
196
+ raise IngestError(
197
+ f"{document.path}: expected an object, a list of objects, or an "
198
+ f"object with a `changes` list; got {type(data).__name__}"
199
+ )
200
+
201
+ if not isinstance(entries, list):
202
+ raise IngestError(f"{document.path}: `changes` must be a list")
203
+ if not entries:
204
+ raise IngestError(f"{document.path}: contains no changes")
205
+
206
+ changes = []
207
+ for entry in entries:
208
+ if not isinstance(entry, dict):
209
+ raise IngestError(
210
+ f"{document.path}: each change must be an object, got {type(entry).__name__}"
211
+ )
212
+ changes.append(change_from_mapping(entry, document.path, self.name))
213
+
214
+ log.debug("parsed %s: %d structured change(s)", document.path, len(changes))
215
+ return changes
216
+
217
+
218
+ register(StructuredChangeParser())
@@ -0,0 +1,28 @@
1
+ """Optional LLM assistance. PatchAhead has no runtime dependency on it.
2
+
3
+ The model is a proposal engine, never the authority: everything it returns is
4
+ structurally checked by :mod:`patchahead.llm.proposer` and then put through the
5
+ same validation gates as a deterministic patch.
6
+ """
7
+
8
+ from patchahead.llm.client import (
9
+ DEFAULT_MODEL,
10
+ LLMClient,
11
+ LLMError,
12
+ LLMResponse,
13
+ LLMUnavailable,
14
+ available,
15
+ model_name,
16
+ )
17
+ from patchahead.llm.proposer import LLMProposer
18
+
19
+ __all__ = [
20
+ "DEFAULT_MODEL",
21
+ "LLMClient",
22
+ "LLMError",
23
+ "LLMProposer",
24
+ "LLMResponse",
25
+ "LLMUnavailable",
26
+ "available",
27
+ "model_name",
28
+ ]
@@ -0,0 +1,152 @@
1
+ """A thin, honest wrapper over the Anthropic API.
2
+
3
+ Two things this does that the prototype did not:
4
+
5
+ **It distinguishes failure modes.** The prototype wrapped every call in
6
+ ``except Exception: return None``, so a bad API key, a network outage, a
7
+ malformed response, and "the LLM is switched off" were indistinguishable
8
+ (``docs/assessment.md`` §2.6). Here each raises or returns a typed
9
+ :class:`LLMError` with a message a user can act on.
10
+
11
+ **It never sends more than it was given.** The caller assembles the prompt from
12
+ a named, bounded set of inputs. This module adds nothing, reads no files, and
13
+ logs no prompt content above debug level.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ from dataclasses import dataclass
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ #: Default model. Overridable with ``PATCHAHEAD_MODEL``.
25
+ DEFAULT_MODEL = "claude-opus-5"
26
+ DEFAULT_MAX_TOKENS = 8000
27
+
28
+
29
+ class LLMError(Exception):
30
+ """A structured LLM failure. Carries a message meant for the user."""
31
+
32
+ def __init__(self, message: str, *, retryable: bool = False) -> None:
33
+ super().__init__(message)
34
+ self.retryable = retryable
35
+
36
+
37
+ class LLMUnavailable(LLMError):
38
+ """The LLM path cannot run at all: no SDK, or no credentials."""
39
+
40
+
41
+ @dataclass
42
+ class LLMResponse:
43
+ """A completed model response."""
44
+
45
+ text: str
46
+ model: str
47
+ input_tokens: int = 0
48
+ output_tokens: int = 0
49
+ stop_reason: str = ""
50
+
51
+
52
+ def available() -> tuple[bool, str]:
53
+ """Whether the LLM path can run, and why not if it cannot."""
54
+ try:
55
+ import anthropic # noqa: F401
56
+ except ImportError:
57
+ return False, ("the `anthropic` package is not installed (pip install 'patchahead[llm]')")
58
+ if not (os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")):
59
+ return False, "no ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN) is set"
60
+ return True, ""
61
+
62
+
63
+ def model_name() -> str:
64
+ return os.environ.get("PATCHAHEAD_MODEL", DEFAULT_MODEL)
65
+
66
+
67
+ class LLMClient:
68
+ """Sends one request and returns the text, or raises :class:`LLMError`."""
69
+
70
+ def __init__(self, model: str | None = None, max_tokens: int = DEFAULT_MAX_TOKENS) -> None:
71
+ self.model = model or model_name()
72
+ self.max_tokens = max_tokens
73
+ self._client = None
74
+
75
+ def _ensure_client(self):
76
+ if self._client is not None:
77
+ return self._client
78
+ ok, reason = available()
79
+ if not ok:
80
+ raise LLMUnavailable(reason)
81
+ import anthropic
82
+
83
+ try:
84
+ self._client = anthropic.Anthropic()
85
+ except Exception as exc:
86
+ raise LLMUnavailable(f"could not construct the Anthropic client: {exc}") from exc
87
+ return self._client
88
+
89
+ def complete(self, system: str, user: str) -> LLMResponse:
90
+ """Send one request. Raises :class:`LLMError` on any failure."""
91
+ # `_ensure_client` first: it turns a missing package or missing
92
+ # credentials into an LLMUnavailable the caller can report. Importing
93
+ # `anthropic` before that check would raise ModuleNotFoundError straight
94
+ # past every handler in the stack.
95
+ client = self._ensure_client()
96
+ import anthropic
97
+
98
+ log.debug("llm request: model=%s, %d chars of input", self.model, len(user))
99
+
100
+ try:
101
+ message = client.messages.create(
102
+ model=self.model,
103
+ max_tokens=self.max_tokens,
104
+ system=system,
105
+ thinking={"type": "adaptive"},
106
+ messages=[{"role": "user", "content": user}],
107
+ )
108
+ except anthropic.AuthenticationError as exc:
109
+ raise LLMError(f"the Anthropic API rejected the credentials: {exc}") from exc
110
+ except anthropic.RateLimitError as exc:
111
+ raise LLMError(f"rate limited by the Anthropic API: {exc}", retryable=True) from exc
112
+ except anthropic.APIConnectionError as exc:
113
+ raise LLMError(f"could not reach the Anthropic API: {exc}", retryable=True) from exc
114
+ except anthropic.APIStatusError as exc:
115
+ raise LLMError(
116
+ f"the Anthropic API returned {exc.status_code}: {exc}",
117
+ retryable=exc.status_code >= 500,
118
+ ) from exc
119
+ except Exception as exc: # unexpected SDK-level failure
120
+ raise LLMError(f"unexpected error calling the Anthropic API: {exc}") from exc
121
+
122
+ if getattr(message, "stop_reason", "") == "refusal":
123
+ details = getattr(message, "stop_details", None)
124
+ category = getattr(details, "category", "") if details else ""
125
+ raise LLMError(
126
+ "the model declined to answer" + (f" (category: {category})" if category else "")
127
+ )
128
+
129
+ text = "".join(
130
+ block.text for block in message.content if getattr(block, "type", "") == "text"
131
+ )
132
+ if not text.strip():
133
+ raise LLMError(
134
+ f"the model returned no text (stop_reason: "
135
+ f"{getattr(message, 'stop_reason', 'unknown')})"
136
+ )
137
+
138
+ usage = getattr(message, "usage", None)
139
+ response = LLMResponse(
140
+ text=text,
141
+ model=getattr(message, "model", self.model),
142
+ input_tokens=getattr(usage, "input_tokens", 0) if usage else 0,
143
+ output_tokens=getattr(usage, "output_tokens", 0) if usage else 0,
144
+ stop_reason=getattr(message, "stop_reason", "") or "",
145
+ )
146
+ log.debug(
147
+ "llm response: %d in / %d out tokens, stop_reason=%s",
148
+ response.input_tokens,
149
+ response.output_tokens,
150
+ response.stop_reason,
151
+ )
152
+ return response