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
patchahead/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """PatchAhead — find and migrate downstream code broken by upstream API changes.
2
+
3
+ Static evidence identifies risk. AI can propose. Tests verify. Humans approve.
4
+ """
5
+
6
+ __version__ = "0.3.0"
7
+
8
+ __all__ = ["__version__"]
@@ -0,0 +1,52 @@
1
+ """Static analysis: parsing Python source and applying range-based edits."""
2
+
3
+ from patchahead.analysis.edits import (
4
+ EditError,
5
+ apply_edits,
6
+ combined_diff,
7
+ is_parseable,
8
+ unified_diff,
9
+ )
10
+ from patchahead.analysis.index import RepoIndex, build, discover_python_files, is_test_path
11
+ from patchahead.analysis.python_ast import (
12
+ MODULE_SCOPE,
13
+ AttributeAccess,
14
+ CallSite,
15
+ ColumnMap,
16
+ GetCallAccess,
17
+ ModuleAnalysis,
18
+ ParseError,
19
+ SourceRange,
20
+ SubscriptAccess,
21
+ analyze_source,
22
+ base_name,
23
+ iter_own_scope,
24
+ receiver_matches_owner,
25
+ receiver_name,
26
+ )
27
+
28
+ __all__ = [
29
+ "MODULE_SCOPE",
30
+ "AttributeAccess",
31
+ "CallSite",
32
+ "ColumnMap",
33
+ "EditError",
34
+ "GetCallAccess",
35
+ "ModuleAnalysis",
36
+ "ParseError",
37
+ "RepoIndex",
38
+ "SourceRange",
39
+ "SubscriptAccess",
40
+ "analyze_source",
41
+ "apply_edits",
42
+ "base_name",
43
+ "build",
44
+ "combined_diff",
45
+ "discover_python_files",
46
+ "is_parseable",
47
+ "is_test_path",
48
+ "iter_own_scope",
49
+ "receiver_matches_owner",
50
+ "receiver_name",
51
+ "unified_diff",
52
+ ]
@@ -0,0 +1,143 @@
1
+ """Applying source ranges edits, and rendering diffs.
2
+
3
+ Edits are applied to the original text by range, not by regenerating the module
4
+ from its AST. ``ast.unparse`` would discard every comment, blank line, and
5
+ formatting choice in the file, turning a two-token rename into a whole-file
6
+ rewrite that no reviewer would approve. Range edits keep the diff to exactly the
7
+ tokens that changed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import difflib
14
+ from pathlib import Path
15
+
16
+ from patchahead.domain.plan import TextEdit
17
+
18
+
19
+ class EditError(Exception):
20
+ """Raised when a set of edits cannot be applied safely."""
21
+
22
+
23
+ def read_source(path: Path) -> str:
24
+ """Read a file as UTF-8 text with its line endings exactly as they are.
25
+
26
+ ``Path.read_text`` translates ``\r\n`` to ``\n``, and writing that back
27
+ converts a whole Windows-style file to Unix endings: a two-token rename
28
+ becomes a diff of every line, and the patch no longer applies to the file
29
+ it was made from.
30
+ """
31
+ return path.read_bytes().decode("utf-8")
32
+
33
+
34
+ def write_source(path: Path, text: str) -> None:
35
+ """Write UTF-8 text without translating line endings. See :func:`read_source`."""
36
+ path.write_bytes(text.encode("utf-8"))
37
+
38
+
39
+ def newline_of(text: str) -> str:
40
+ """The line ending a file uses: ``\r\n`` if its first line ends that way."""
41
+ first = text.find("\n")
42
+ return "\r\n" if first > 0 and text[first - 1] == "\r" else "\n"
43
+
44
+
45
+ def _offset(line_starts: list[int], line: int, col: int, length: int) -> int:
46
+ """Absolute character offset for a 1-indexed line and 0-indexed column."""
47
+ if line < 1 or line > len(line_starts):
48
+ raise EditError(f"line {line} is out of range (file has {len(line_starts)} lines)")
49
+ offset = line_starts[line - 1] + col
50
+ if offset > length:
51
+ raise EditError(f"position {line}:{col} is past the end of the file")
52
+ return offset
53
+
54
+
55
+ def apply_edits(source: str, edits: list[TextEdit]) -> str:
56
+ """Apply ``edits`` to ``source`` and return the new text.
57
+
58
+ Edits are applied from the end of the file backwards so earlier offsets stay
59
+ valid. Overlapping edits are rejected rather than silently resolved: two
60
+ handlers disagreeing about the same range is a bug, and producing a
61
+ plausible-looking merge would hide it.
62
+ """
63
+ if not edits:
64
+ return source
65
+
66
+ line_starts = [0]
67
+ for index, char in enumerate(source):
68
+ if char == "\n":
69
+ line_starts.append(index + 1)
70
+ length = len(source)
71
+
72
+ spans: list[tuple[int, int, str]] = []
73
+ for edit in edits:
74
+ start = _offset(line_starts, edit.line, edit.col, length)
75
+ end = _offset(line_starts, edit.end_line, edit.end_col, length)
76
+ if end < start:
77
+ raise EditError(
78
+ f"edit at {edit.line}:{edit.col} ends before it starts "
79
+ f"({edit.end_line}:{edit.end_col})"
80
+ )
81
+ spans.append((start, end, edit.new_text))
82
+
83
+ spans.sort(key=lambda span: (span[0], span[1]))
84
+ for (_, end, _), (next_start, _, _) in zip(spans, spans[1:], strict=False):
85
+ if next_start < end:
86
+ raise EditError(
87
+ f"overlapping edits: span ending at offset {end} overlaps the span "
88
+ f"starting at offset {next_start}"
89
+ )
90
+
91
+ result = source
92
+ for start, end, new_text in reversed(spans):
93
+ result = result[:start] + new_text + result[end:]
94
+ return result
95
+
96
+
97
+ def is_parseable(source: str, path: str = "<patched>") -> tuple[bool, str]:
98
+ """Whether ``source`` parses as Python, and the error if it does not.
99
+
100
+ Used by the syntax gate. ``ast.parse`` rather than ``compile`` because it
101
+ catches the same syntax errors without executing any compile-time side
102
+ effects, and gives a cleaner message.
103
+ """
104
+ try:
105
+ ast.parse(source, filename=path)
106
+ except SyntaxError as exc:
107
+ return False, f"{path}:{exc.lineno or '?'}:{exc.offset or '?'}: {exc.msg}"
108
+ except ValueError as exc:
109
+ return False, f"{path}: {exc}"
110
+ return True, ""
111
+
112
+
113
+ def unified_diff(old: str, new: str, path: str, context: int = 3) -> str:
114
+ """A git-style unified diff for one file.
115
+
116
+ ``a/``-``b/`` prefixes and a trailing newline make the output directly
117
+ consumable by ``git apply`` and ``patch -p1``.
118
+ """
119
+ if old == new:
120
+ return ""
121
+ diff = difflib.unified_diff(
122
+ old.splitlines(keepends=True),
123
+ new.splitlines(keepends=True),
124
+ fromfile=f"a/{path}",
125
+ tofile=f"b/{path}",
126
+ n=context,
127
+ )
128
+ text = "".join(diff)
129
+ if text and not text.endswith("\n"):
130
+ text += "\n"
131
+ return text
132
+
133
+
134
+ def combined_diff(files: list[tuple[str, str, str]], context: int = 3) -> str:
135
+ """A single diff across several files, ordered by path.
136
+
137
+ ``files`` is a list of ``(path, old_source, new_source)``.
138
+ """
139
+ parts = [
140
+ unified_diff(old, new, path, context=context)
141
+ for path, old, new in sorted(files, key=lambda item: item[0])
142
+ ]
143
+ return "".join(part for part in parts if part)
@@ -0,0 +1,203 @@
1
+ """A parsed, cached view of one repository's Python source.
2
+
3
+ Each file is read and parsed at most once per run, no matter how many handlers
4
+ analyze it. The prototype re-read and re-scanned every file for every pattern of
5
+ every change; this does one pass and shares it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import logging
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path, PurePosixPath
14
+
15
+ from patchahead.analysis.edits import read_source
16
+ from patchahead.analysis.python_ast import ModuleAnalysis, ParseError, analyze_source
17
+ from patchahead.config import Config
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ #: Files larger than this are skipped. A multi-megabyte generated module is
22
+ #: never the hand-written integration code a migration targets, and parsing it
23
+ #: costs more than it can possibly return.
24
+ MAX_FILE_BYTES = 2_000_000
25
+
26
+
27
+ @dataclass
28
+ class RepoIndex:
29
+ """Discovered and parsed Python modules under a repository root."""
30
+
31
+ root: Path
32
+ config: Config
33
+ #: relative POSIX path -> parsed module
34
+ modules: dict[str, ModuleAnalysis] = field(default_factory=dict)
35
+ #: relative POSIX path -> why it was skipped
36
+ skipped: dict[str, str] = field(default_factory=dict)
37
+
38
+ @property
39
+ def file_count(self) -> int:
40
+ return len(self.modules)
41
+
42
+ def paths(self) -> list[str]:
43
+ return sorted(self.modules)
44
+
45
+ def get(self, path: str) -> ModuleAnalysis | None:
46
+ return self.modules.get(path)
47
+
48
+ def source_of(self, path: str) -> str:
49
+ module = self.modules.get(path)
50
+ return module.source if module else ""
51
+
52
+ def test_paths(self) -> list[str]:
53
+ """Paths that look like pytest test modules."""
54
+ return [p for p in self.paths() if is_test_path(p)]
55
+
56
+ def non_test_paths(self) -> list[str]:
57
+ return [p for p in self.paths() if not is_test_path(p)]
58
+
59
+
60
+ def is_test_path(path: str) -> bool:
61
+ """Whether a relative path looks like a pytest test module.
62
+
63
+ Follows pytest's own default discovery rules (``test_*.py`` / ``*_test.py``),
64
+ plus the near-universal convention of a ``tests/`` directory.
65
+ """
66
+ pure = PurePosixPath(path)
67
+ name = pure.name
68
+ if name.startswith("test_") or name.endswith("_test.py"):
69
+ return True
70
+ return any(part in ("test", "tests") for part in pure.parts[:-1])
71
+
72
+
73
+ def is_excluded(relative: str, patterns: list[str]) -> bool:
74
+ """Whether a relative POSIX path is excluded by name or glob.
75
+
76
+ A bare name like ``node_modules`` excludes any directory with that name at
77
+ any depth; a pattern containing ``/`` or ``*`` is matched as a glob against
78
+ the whole relative path.
79
+ """
80
+ parts = PurePosixPath(relative).parts
81
+ for pattern in patterns:
82
+ if "/" in pattern or "*" in pattern or "?" in pattern:
83
+ if fnmatch.fnmatch(relative, pattern):
84
+ return True
85
+ if fnmatch.fnmatch(relative, pattern.rstrip("/") + "/*"):
86
+ return True
87
+ elif pattern in parts:
88
+ return True
89
+ return False
90
+
91
+
92
+ def discover_python_files(root: Path, config: Config) -> list[Path]:
93
+ """Every analyzable ``.py`` file under ``root``, honouring the config.
94
+
95
+ Directories are pruned during the walk rather than filtered afterwards, so
96
+ a ``node_modules`` or ``.venv`` inside the repository costs nothing.
97
+ """
98
+ root = Path(root).resolve()
99
+ roots: list[Path] = []
100
+ if config.source_dirs:
101
+ for entry in config.source_dirs:
102
+ candidate = (root / entry).resolve()
103
+ if not candidate.is_dir():
104
+ log.warning("configured source_dir does not exist: %s", entry)
105
+ continue
106
+ if root not in candidate.parents and candidate != root:
107
+ log.warning("ignoring source_dir outside the repository: %s", entry)
108
+ continue
109
+ roots.append(candidate)
110
+ if not roots:
111
+ log.warning("no configured source_dirs exist; scanning the whole repository")
112
+ roots = [root]
113
+ else:
114
+ roots = [root]
115
+
116
+ # When `source_dirs` narrows analysis, test files are still discovered from
117
+ # the whole repository. `source_dirs` exists to stop PatchAhead proposing
118
+ # edits outside a project's own source; it is not a statement about where
119
+ # the tests live, and narrowing test discovery with it would silently
120
+ # disable the targeted-test validation gate.
121
+ scan_roots = list(roots)
122
+ if config.source_dirs and root not in scan_roots:
123
+ scan_roots.append(root)
124
+
125
+ found: list[Path] = []
126
+ seen: set[Path] = set()
127
+ for base in scan_roots:
128
+ restrict_to_tests = base == root and config.source_dirs
129
+ stack = [base]
130
+ while stack:
131
+ directory = stack.pop()
132
+ try:
133
+ entries = sorted(directory.iterdir())
134
+ except OSError as exc:
135
+ log.debug("cannot list %s: %s", directory, exc)
136
+ continue
137
+ for entry in entries:
138
+ try:
139
+ relative = entry.resolve().relative_to(root).as_posix()
140
+ except ValueError:
141
+ # A symlink pointing outside the repository. Following it
142
+ # could make PatchAhead read or propose edits to files the
143
+ # user did not point it at.
144
+ log.debug("skipping path outside the repository: %s", entry)
145
+ continue
146
+ if is_excluded(relative, config.exclude):
147
+ continue
148
+ if entry.is_symlink():
149
+ log.debug("skipping symlink: %s", relative)
150
+ continue
151
+ if entry.is_dir():
152
+ stack.append(entry)
153
+ elif entry.suffix == ".py" and entry not in seen:
154
+ if restrict_to_tests and not is_test_path(relative):
155
+ continue
156
+ seen.add(entry)
157
+ found.append(entry)
158
+ return sorted(found)
159
+
160
+
161
+ def build(root: Path, config: Config) -> RepoIndex:
162
+ """Discover, read, and parse every Python file under ``root``.
163
+
164
+ Unreadable and unparseable files are recorded in
165
+ :attr:`RepoIndex.skipped` with a reason and reported to the user, rather
166
+ than dropped. A repository with a syntax error in one module is still
167
+ analyzable, but the user should know which module was not looked at.
168
+ """
169
+ root = Path(root).resolve()
170
+ index = RepoIndex(root=root, config=config)
171
+
172
+ for path in discover_python_files(root, config):
173
+ relative = path.relative_to(root).as_posix()
174
+ try:
175
+ size = path.stat().st_size
176
+ except OSError as exc:
177
+ index.skipped[relative] = f"cannot stat: {exc}"
178
+ continue
179
+ if size > MAX_FILE_BYTES:
180
+ index.skipped[relative] = f"file is {size} bytes (limit {MAX_FILE_BYTES})"
181
+ continue
182
+ try:
183
+ source = read_source(path)
184
+ except UnicodeDecodeError as exc:
185
+ index.skipped[relative] = f"not valid UTF-8: {exc.reason}"
186
+ continue
187
+ except OSError as exc:
188
+ index.skipped[relative] = f"cannot read: {exc}"
189
+ continue
190
+ try:
191
+ index.modules[relative] = analyze_source(source, relative)
192
+ except ParseError as exc:
193
+ index.skipped[relative] = f"syntax error: {exc}"
194
+
195
+ log.debug(
196
+ "indexed %d module(s), skipped %d, under %s",
197
+ len(index.modules),
198
+ len(index.skipped),
199
+ root,
200
+ )
201
+ for relative, reason in index.skipped.items():
202
+ log.debug("skipped %s: %s", relative, reason)
203
+ return index