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.
- patchahead/__init__.py +8 -0
- patchahead/analysis/__init__.py +52 -0
- patchahead/analysis/edits.py +143 -0
- patchahead/analysis/index.py +203 -0
- patchahead/analysis/python_ast.py +457 -0
- patchahead/apidiff/__init__.py +23 -0
- patchahead/apidiff/compare.py +366 -0
- patchahead/apidiff/download.py +95 -0
- patchahead/apidiff/surface.py +337 -0
- patchahead/ci.py +301 -0
- patchahead/cli.py +627 -0
- patchahead/config.py +284 -0
- patchahead/demo/__init__.py +256 -0
- patchahead/demo/fixtures/changes/field-rename.md +14 -0
- patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
- patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
- patchahead/demo/fixtures/changes/method-rename.md +12 -0
- patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
- patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
- patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
- patchahead/demo/fixtures/orders-service/README.md +51 -0
- patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/app/client.py +15 -0
- patchahead/demo/fixtures/orders-service/app/models.py +10 -0
- patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
- patchahead/demo/fixtures/orders-service/conftest.py +6 -0
- patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
- patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
- patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
- patchahead/demo/serve.py +189 -0
- patchahead/domain/__init__.py +67 -0
- patchahead/domain/change.py +269 -0
- patchahead/domain/completeness.py +91 -0
- patchahead/domain/impact.py +248 -0
- patchahead/domain/patch.py +81 -0
- patchahead/domain/plan.py +170 -0
- patchahead/domain/result.py +210 -0
- patchahead/domain/validation.py +200 -0
- patchahead/engine.py +609 -0
- patchahead/handlers/__init__.py +35 -0
- patchahead/handlers/base.py +211 -0
- patchahead/handlers/field_rename.py +425 -0
- patchahead/handlers/kwarg_rename.py +201 -0
- patchahead/handlers/method_rename.py +608 -0
- patchahead/handlers/pagination.py +582 -0
- patchahead/ingest/__init__.py +32 -0
- patchahead/ingest/base.py +102 -0
- patchahead/ingest/markdown.py +1138 -0
- patchahead/ingest/structured.py +218 -0
- patchahead/llm/__init__.py +28 -0
- patchahead/llm/client.py +152 -0
- patchahead/llm/proposer.py +620 -0
- patchahead/observability.py +223 -0
- patchahead/reporting.py +451 -0
- patchahead/testing/__init__.py +22 -0
- patchahead/testing/discovery.py +113 -0
- patchahead/testing/runner.py +138 -0
- patchahead/validation/__init__.py +5 -0
- patchahead/validation/completeness.py +265 -0
- patchahead/validation/engine.py +531 -0
- patchahead/web/__init__.py +13 -0
- patchahead/web/server.py +279 -0
- patchahead/web/static/index.html +650 -0
- patchahead/workspace.py +382 -0
- patchahead-0.3.0.dist-info/METADATA +368 -0
- patchahead-0.3.0.dist-info/RECORD +75 -0
- patchahead-0.3.0.dist-info/WHEEL +5 -0
- patchahead-0.3.0.dist-info/entry_points.txt +2 -0
- patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
- patchahead-0.3.0.dist-info/top_level.txt +1 -0
patchahead/workspace.py
ADDED
|
@@ -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")
|