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,382 @@
1
+ """Repository and isolated-workspace abstractions.
2
+
3
+ Two classes, with a deliberate asymmetry:
4
+
5
+ * :class:`Repository` is **read-only**. It is how PatchAhead sees the user's
6
+ actual source tree. It has no write method, so no amount of later refactoring
7
+ can accidentally introduce a path that mutates the user's files.
8
+ * :class:`Workspace` is a writable *copy*, created in a temporary directory.
9
+ All patching and all test execution happen there.
10
+
11
+ This is the structural version of the safety promise. The prototype called
12
+ ``Path(...).write_text()`` on the user's repository and relied on a fixture-only
13
+ restore function to undo it (``docs/assessment.md`` §2.4).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import logging
20
+ import os
21
+ import shutil
22
+ import signal
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ from patchahead.analysis import edits as edit_utils
30
+ from patchahead.analysis import index as repo_index
31
+ from patchahead.config import Config
32
+ from patchahead.domain.plan import TextEdit
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+
37
+ #: Environment variables that authenticate PatchAhead to its model provider.
38
+ #: Withheld from test commands once model-written code is in the workspace.
39
+ MODEL_CREDENTIALS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
40
+
41
+
42
+ class WorkspaceError(Exception):
43
+ """Raised when an isolated workspace cannot be created or used."""
44
+
45
+
46
+ class RepositoryError(Exception):
47
+ """Raised when the target path is not a usable repository."""
48
+
49
+
50
+ def _is_within(child: Path, parent: Path) -> bool:
51
+ """Whether ``child`` is inside ``parent`` after resolving symlinks."""
52
+ try:
53
+ child.resolve().relative_to(parent.resolve())
54
+ except ValueError:
55
+ return False
56
+ return True
57
+
58
+
59
+ @dataclass
60
+ class Repository:
61
+ """A read-only view of the repository the user pointed PatchAhead at."""
62
+
63
+ root: Path
64
+ config: Config
65
+
66
+ @classmethod
67
+ def open(cls, path: str | os.PathLike[str], config: Config | None = None) -> Repository:
68
+ """Open a local repository, loading its PatchAhead configuration."""
69
+ from patchahead import config as config_module
70
+
71
+ root = Path(path).expanduser()
72
+ if not root.exists():
73
+ raise RepositoryError(f"no such directory: {root}")
74
+ if not root.is_dir():
75
+ raise RepositoryError(f"not a directory: {root}")
76
+ root = root.resolve()
77
+ return cls(root=root, config=config or config_module.load(root))
78
+
79
+ @property
80
+ def is_git_repo(self) -> bool:
81
+ return (self.root / ".git").exists()
82
+
83
+ def read(self, relative: str) -> str:
84
+ """Read a file by repo-relative path.
85
+
86
+ Refuses to read outside the repository root, so a crafted path in a
87
+ change document cannot make PatchAhead read arbitrary files.
88
+ """
89
+ target = (self.root / relative).resolve()
90
+ if not _is_within(target, self.root):
91
+ raise RepositoryError(f"path escapes the repository root: {relative}")
92
+ return edit_utils.read_source(target)
93
+
94
+ def index(self) -> repo_index.RepoIndex:
95
+ """Discover and parse every Python file. Cached by the caller, not here."""
96
+ return repo_index.build(self.root, self.config)
97
+
98
+ def count_files(self) -> int:
99
+ """Total files that would be copied into a workspace."""
100
+ total = 0
101
+ stack = [self.root]
102
+ while stack:
103
+ directory = stack.pop()
104
+ try:
105
+ entries = list(directory.iterdir())
106
+ except OSError:
107
+ continue
108
+ for entry in entries:
109
+ try:
110
+ relative = entry.relative_to(self.root).as_posix()
111
+ except ValueError:
112
+ continue
113
+ if repo_index.is_excluded(relative, self.config.exclude):
114
+ continue
115
+ if entry.is_symlink():
116
+ continue
117
+ if entry.is_dir():
118
+ stack.append(entry)
119
+ else:
120
+ total += 1
121
+ return total
122
+
123
+
124
+ @dataclass
125
+ class Workspace:
126
+ """A writable temporary copy of a repository.
127
+
128
+ Created by :meth:`materialize`, cleaned up by :meth:`cleanup` or by using it
129
+ as a context manager. Never shares a path with the source repository.
130
+ """
131
+
132
+ root: Path
133
+ source_root: Path
134
+ config: Config
135
+ #: relative path -> original contents, captured lazily on first write, so
136
+ #: :meth:`restore` and :meth:`diff` always have a true baseline.
137
+ _originals: dict[str, str] = field(default_factory=dict, repr=False)
138
+ _cleaned_up: bool = field(default=False, repr=False)
139
+ #: Set once a model-written function has been written into the copy. From
140
+ #: then on, commands run here do not receive the model's credentials.
141
+ contains_model_code: bool = field(default=False, repr=False)
142
+
143
+ # -- lifecycle ---------------------------------------------------------
144
+
145
+ @classmethod
146
+ def materialize(cls, repository: Repository, prefix: str = "patchahead-") -> Workspace:
147
+ """Copy ``repository`` into a fresh temporary directory.
148
+
149
+ A plain directory copy is used rather than ``git worktree`` for two
150
+ reasons: it works on repositories that are not git repositories or have
151
+ uncommitted work, and it captures exactly the state the user is looking
152
+ at. ``git worktree`` would silently analyze committed state instead.
153
+ """
154
+ file_count = repository.count_files()
155
+ limit = repository.config.max_workspace_files
156
+ if limit and file_count > limit:
157
+ raise WorkspaceError(
158
+ f"repository has {file_count} files, above the "
159
+ f"`max_workspace_files` limit of {limit}. Narrow the scope with "
160
+ f"`source_dirs`/`exclude`, or raise the limit, in the "
161
+ f"repository's PatchAhead configuration."
162
+ )
163
+
164
+ exclude = list(repository.config.exclude)
165
+
166
+ def ignore(directory: str, names: list[str]) -> set[str]:
167
+ try:
168
+ relative_dir = Path(directory).resolve().relative_to(repository.root)
169
+ except ValueError:
170
+ return set()
171
+ ignored = set()
172
+ for name in names:
173
+ # `Path(".") / ".git"` is already `.git`. Stripping "./" as a
174
+ # character set used to strip the dot too, so `.git`, `.tox`
175
+ # and every other dot-directory escaped the exclusion list.
176
+ relative = (relative_dir / name).as_posix()
177
+ if repo_index.is_excluded(relative, exclude):
178
+ ignored.add(name)
179
+ return ignored
180
+
181
+ temp_root = Path(tempfile.mkdtemp(prefix=prefix))
182
+ destination = temp_root / repository.root.name
183
+ try:
184
+ shutil.copytree(
185
+ repository.root,
186
+ destination,
187
+ ignore=ignore,
188
+ symlinks=True,
189
+ ignore_dangling_symlinks=True,
190
+ )
191
+ except OSError as exc:
192
+ shutil.rmtree(temp_root, ignore_errors=True)
193
+ raise WorkspaceError(f"could not copy repository into a workspace: {exc}") from exc
194
+
195
+ log.debug("materialized workspace at %s (%d files)", destination, file_count)
196
+ return cls(root=destination, source_root=repository.root, config=repository.config)
197
+
198
+ def __enter__(self) -> Workspace:
199
+ return self
200
+
201
+ def __exit__(self, *_exc_info: object) -> None:
202
+ self.cleanup()
203
+
204
+ def cleanup(self) -> None:
205
+ """Delete the workspace. Safe to call twice; never touches the source."""
206
+ if self._cleaned_up:
207
+ return
208
+ self._cleaned_up = True
209
+ if self.root == self.source_root: # pragma: no cover - defensive
210
+ raise WorkspaceError("refusing to delete the source repository")
211
+ shutil.rmtree(self.root.parent, ignore_errors=True)
212
+ log.debug("cleaned up workspace %s", self.root)
213
+
214
+ def keep(self) -> Path:
215
+ """Leave the workspace on disk and return its path."""
216
+ self._cleaned_up = True
217
+ return self.root
218
+
219
+ # -- file access -------------------------------------------------------
220
+
221
+ def _resolve(self, relative: str) -> Path:
222
+ target = (self.root / relative).resolve()
223
+ if not _is_within(target, self.root):
224
+ raise WorkspaceError(f"path escapes the workspace root: {relative}")
225
+ return target
226
+
227
+ def read(self, relative: str) -> str:
228
+ return edit_utils.read_source(self._resolve(relative))
229
+
230
+ def write(self, relative: str, contents: str) -> None:
231
+ """Write a file, recording its original contents the first time."""
232
+ target = self._resolve(relative)
233
+ if relative not in self._originals:
234
+ try:
235
+ self._originals[relative] = edit_utils.read_source(target)
236
+ except OSError:
237
+ self._originals[relative] = ""
238
+ target.parent.mkdir(parents=True, exist_ok=True)
239
+ edit_utils.write_source(target, contents)
240
+ log.debug("wrote %s in workspace", relative)
241
+
242
+ def apply_edits(self, relative: str, text_edits: list[TextEdit]) -> str:
243
+ """Apply range edits to one file and write the result back."""
244
+ original = self.read(relative)
245
+ patched = edit_utils.apply_edits(original, text_edits)
246
+ self.write(relative, patched)
247
+ return patched
248
+
249
+ def original(self, relative: str) -> str:
250
+ """The contents a file had before this workspace modified it."""
251
+ if relative in self._originals:
252
+ return self._originals[relative]
253
+ return self.read(relative)
254
+
255
+ def index(self) -> repo_index.RepoIndex:
256
+ return repo_index.build(self.root, self.config)
257
+
258
+ # -- change tracking ---------------------------------------------------
259
+
260
+ def changed_files(self) -> list[str]:
261
+ """Files whose current contents differ from their originals."""
262
+ changed = []
263
+ for relative, original in sorted(self._originals.items()):
264
+ try:
265
+ current = self.read(relative)
266
+ except OSError:
267
+ changed.append(relative)
268
+ continue
269
+ if current != original:
270
+ changed.append(relative)
271
+ return changed
272
+
273
+ def diff(self, context: int = 3) -> str:
274
+ """A unified diff of everything this workspace changed."""
275
+ entries = []
276
+ for relative in self.changed_files():
277
+ try:
278
+ current = self.read(relative)
279
+ except OSError:
280
+ current = ""
281
+ entries.append((relative, self._originals[relative], current))
282
+ return edit_utils.combined_diff(entries, context=context)
283
+
284
+ def restore(self, relative: str | None = None) -> None:
285
+ """Undo modifications -- one file, or all of them."""
286
+ targets = [relative] if relative else list(self._originals)
287
+ for path in targets:
288
+ if path not in self._originals:
289
+ continue
290
+ edit_utils.write_source(self._resolve(path), self._originals[path])
291
+ del self._originals[path]
292
+ log.debug("restored %d file(s) in workspace", len(targets))
293
+
294
+ # -- command execution -------------------------------------------------
295
+
296
+ def run(
297
+ self,
298
+ command: str,
299
+ timeout: int | None = None,
300
+ extra_env: dict[str, str] | None = None,
301
+ ) -> subprocess.CompletedProcess[str]:
302
+ """Run a shell command with the workspace as the working directory.
303
+
304
+ This executes code from the repository under analysis. That is
305
+ unavoidable for a tool whose central claim is "tests verify", and it is
306
+ why ``docs/safety.md`` states plainly that PatchAhead must only be
307
+ pointed at repositories and test commands the user trusts. PatchAhead
308
+ does not sandbox the command; it runs with the caller's privileges.
309
+ """
310
+ env = dict(os.environ)
311
+ # Keep the workspace importable the way a developer running tests from
312
+ # the repository root would have it, and stop stray .pyc files from
313
+ # being written into the copy.
314
+ env["PYTHONPATH"] = os.pathsep.join(
315
+ [str(self.root)] + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else [])
316
+ )
317
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
318
+
319
+ # Resolve bare `python`/`pytest` to the environment PatchAhead itself is
320
+ # running in. Without this, a default `python -m pytest` finds whatever
321
+ # `python` happens to be first on PATH -- often a system interpreter
322
+ # with no pytest installed -- and every migration fails validation for a
323
+ # reason that has nothing to do with the migration. Explicit paths and
324
+ # other interpreters in a configured `test_command` still win, because
325
+ # this only prepends a directory rather than rewriting the command.
326
+ interpreter_dir = str(Path(sys.executable).parent)
327
+ env["PATH"] = os.pathsep.join(
328
+ [interpreter_dir] + ([env["PATH"]] if env.get("PATH") else [])
329
+ )
330
+ if self.contains_model_code:
331
+ # The model's output is shaped by text PatchAhead did not write --
332
+ # a vendor's release note, the repository's own source. Code it
333
+ # wrote must not be handed the key that pays for it.
334
+ for name in MODEL_CREDENTIALS:
335
+ env.pop(name, None)
336
+ env.update(extra_env or {})
337
+
338
+ log.debug("running in workspace: %s", command)
339
+ with subprocess.Popen(
340
+ command,
341
+ shell=True,
342
+ cwd=str(self.root),
343
+ stdout=subprocess.PIPE,
344
+ stderr=subprocess.PIPE,
345
+ # Test output is whatever the test command prints. A byte that is
346
+ # not UTF-8 must not end the run with a decoding traceback.
347
+ encoding="utf-8",
348
+ errors="replace",
349
+ env=env,
350
+ # Its own process group, so a timeout can stop the test runner and
351
+ # not just the shell that started it.
352
+ start_new_session=os.name == "posix",
353
+ ) as process:
354
+ try:
355
+ stdout, stderr = process.communicate(timeout=timeout)
356
+ except subprocess.TimeoutExpired:
357
+ _kill_process_tree(process)
358
+ raise
359
+ return subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
360
+
361
+
362
+ def _kill_process_tree(process: subprocess.Popen[str]) -> None:
363
+ """Stop a timed-out command and everything it started.
364
+
365
+ ``subprocess.run(shell=True, timeout=...)`` kills only the shell, leaving
366
+ the test runner it launched running on in a workspace about to be deleted.
367
+ On POSIX the command leads its own process group, so the group is killed.
368
+ """
369
+ if os.name == "posix":
370
+ with contextlib.suppress(ProcessLookupError, PermissionError):
371
+ os.killpg(process.pid, signal.SIGKILL)
372
+ else: # pragma: no cover - exercised on Windows only
373
+ subprocess.run(
374
+ ["taskkill", "/F", "/T", "/PID", str(process.pid)],
375
+ capture_output=True,
376
+ check=False,
377
+ )
378
+ process.kill()
379
+ try:
380
+ process.communicate(timeout=5)
381
+ except subprocess.TimeoutExpired: # pragma: no cover - a stuck pipe
382
+ log.warning("timed-out test command did not exit after being killed")