-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathforce_uninstall_python_package.py
More file actions
executable file
·316 lines (275 loc) · 10.4 KB
/
Copy pathforce_uninstall_python_package.py
File metadata and controls
executable file
·316 lines (275 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
#!/usr/bin/env python3
"""Safely uninstall one Python distribution using its installed-file metadata.
The fallback remover deliberately does not import the requested package and never
recursively removes a directory. It only unlinks paths recorded by the exact
installed distribution and removes now-empty parent directories.
"""
import argparse
import json
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
class UninstallError(RuntimeError):
"""Raised when a safe, exact removal plan cannot be constructed."""
@dataclass(frozen=True)
class DistributionInfo:
name: str
version: str
files: tuple[Path, ...]
install_roots: tuple[Path, ...]
# This code runs in the selected interpreter. It inspects distribution metadata;
# it intentionally never imports the distribution's modules.
_METADATA_QUERY = r"""
import importlib.metadata
import json
import re
import site
import sys
import sysconfig
def canonicalize(name):
return re.sub(r"[-_.]+", "-", name).lower()
wanted = canonicalize(sys.argv[1])
matches = []
for dist in importlib.metadata.distributions():
name = dist.metadata.get("Name")
if name and canonicalize(name) == wanted:
matches.append(dist)
if not matches:
result = {"status": "not-found"}
elif len(matches) != 1:
result = {
"status": "ambiguous",
"matches": [
{"name": d.metadata.get("Name", ""), "version": d.version}
for d in matches
],
}
else:
dist = matches[0]
files = dist.files
if files is None:
result = {
"status": "missing-files",
"name": dist.metadata.get("Name", ""),
"version": dist.version,
}
else:
roots = []
schemes = [None]
if site.ENABLE_USER_SITE:
try:
schemes.append(sysconfig.get_preferred_scheme("user"))
except (AttributeError, KeyError):
pass
for scheme in schemes:
paths = (
sysconfig.get_paths()
if scheme is None
else sysconfig.get_paths(scheme=scheme)
)
for key in ("purelib", "platlib", "scripts"):
value = paths.get(key)
if value and value not in roots:
roots.append(value)
result = {
"status": "ok",
"name": dist.metadata.get("Name", ""),
"version": dist.version,
"files": [str(dist.locate_file(path)) for path in files],
"roots": roots,
}
print("__DIST_INFO__" + json.dumps(result))
"""
def _absolute(path: Path) -> Path:
"""Return a normalized absolute path without following its final component."""
return Path(os.path.abspath(os.fspath(path)))
def _lexists(path: Path) -> bool:
"""Like Path.exists(), but true for a broken symlink as well."""
return os.path.lexists(path)
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
def inspect_distribution(package: str, python: str) -> DistributionInfo | None:
"""Read exact distribution metadata using *python*, without importing it."""
result = subprocess.run(
[python, "-c", _METADATA_QUERY, package],
capture_output=True,
text=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or "metadata query failed"
raise UninstallError(f"could not inspect {python!r}: {detail}")
marker = "__DIST_INFO__"
payload_line = next(
(line[len(marker):] for line in reversed(result.stdout.splitlines())
if line.startswith(marker)),
None,
)
if payload_line is None:
raise UninstallError("selected interpreter returned no distribution metadata")
try:
payload = json.loads(payload_line)
except json.JSONDecodeError as exc:
raise UninstallError("selected interpreter returned invalid metadata") from exc
status = payload.get("status")
if status == "not-found":
return None
if status == "ambiguous":
matches = ", ".join(
f"{item['name']}=={item['version']}" for item in payload["matches"]
)
raise UninstallError(
f"multiple installed distributions match {package!r}: {matches}"
)
if status == "missing-files":
raise UninstallError(
f"{payload['name']}=={payload['version']} has no installed-file metadata; "
"refusing to guess what to remove"
)
if status != "ok":
raise UninstallError(f"unrecognized metadata response: {status!r}")
return DistributionInfo(
name=payload["name"],
version=payload["version"],
files=tuple(_absolute(Path(path)) for path in payload["files"]),
install_roots=tuple(_absolute(Path(path)) for path in payload["roots"]),
)
def validate_owned_paths(info: DistributionInfo) -> tuple[Path, ...]:
"""Validate and deduplicate all metadata-owned paths before any mutation."""
if not info.files:
raise UninstallError(
f"{info.name}=={info.version} records no installed files; refusing removal"
)
if not info.install_roots:
raise UninstallError("selected interpreter reported no safe install roots")
safe_paths = []
seen = set()
for path in info.files:
path = _absolute(path)
matching_root = next(
(root for root in info.install_roots
if path != root and _is_relative_to(path, root)),
None,
)
if matching_root is None:
raise UninstallError(
f"metadata-owned path is outside the interpreter's package/script "
f"directories: {path}"
)
# A parent symlink could make a lexically safe path target an arbitrary
# location. Resolving the parent still permits safely unlinking a final
# symlink, including a broken one.
resolved_parent = path.parent.resolve()
if not _is_relative_to(resolved_parent, matching_root.resolve()):
raise UninstallError(
f"metadata-owned path traverses a symlink outside its install root: {path}"
)
if path not in seen:
seen.add(path)
safe_paths.append(path)
return tuple(safe_paths)
def remove_owned_paths(paths: tuple[Path, ...], roots: tuple[Path, ...]) -> None:
"""Remove exact owned files and empty directories; never recurse."""
failures = []
for path in sorted(paths, key=lambda p: len(p.parts), reverse=True):
if not _lexists(path):
continue
try:
if path.is_dir() and not path.is_symlink():
path.rmdir()
else:
path.unlink()
except OSError as exc:
failures.append(f"{path}: {exc}")
# RECORD normally lists files, not package directories. Clean up only
# empty ancestors and stop before an installation root.
parents = {path.parent for path in paths}
for parent in sorted(parents, key=lambda p: len(p.parts), reverse=True):
current = parent
while any(current != root and _is_relative_to(current, root) for root in roots):
try:
current.rmdir()
except OSError:
break
current = current.parent
remaining = [path for path in paths if _lexists(path)]
if failures or remaining:
details = failures or [f"still exists: {path}" for path in remaining]
raise UninstallError("could not remove every owned path:\n " + "\n ".join(details))
def force_uninstall(package, python, max_attempts):
"""Uninstall an exact distribution, with a metadata-bounded fallback."""
if max_attempts is not None and max_attempts < 1:
print("ERROR: --max-attempts must be at least 1", file=sys.stderr)
return 1
print(f"Using python: {python}")
print(f"Using uv: uv pip uninstall --python {python}")
try:
info = inspect_distribution(package, python)
if info is None:
print(f"Distribution {package!r} is not installed.")
return 0
owned_paths = validate_owned_paths(info)
except (OSError, UninstallError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(
f"Found {info.name}=={info.version}; metadata records "
f"{len(owned_paths)} owned paths."
)
try:
result = subprocess.run(
["uv", "pip", "uninstall", "--python", python, info.name],
capture_output=True,
text=True,
)
except OSError as exc:
print(f"uv could not be run ({exc}); using the metadata fallback.", file=sys.stderr)
else:
if result.returncode == 0:
print(f"uv uninstalled {info.name!r}.")
else:
detail = result.stderr.strip() or result.stdout.strip()
if detail:
print(detail, file=sys.stderr)
print("uv did not complete the uninstall; using the metadata fallback.",
file=sys.stderr)
remaining = tuple(path for path in owned_paths if _lexists(path))
if remaining:
print(f"Removing {len(remaining)} metadata-owned paths left by uv.")
try:
# Recheck parent symlinks after invoking the external uninstaller.
validate_owned_paths(info)
remove_owned_paths(remaining, info.install_roots)
except UninstallError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"Clean removal of {info.name!r} confirmed from its installed-file metadata.")
return 0
def main():
parser = argparse.ArgumentParser(
description="Safely uninstall a distribution using exact installed-file metadata."
)
parser.add_argument(
"package",
help="Installed distribution name (for example 'PyYAML', not import name 'yaml')",
)
parser.add_argument(
"--python",
default=sys.executable,
help="Python interpreter to operate on (default: this interpreter)",
)
parser.add_argument(
"--max-attempts",
type=lambda s: None if s.lower() in ("inf", "infinity") else int(s),
default=None,
help="Deprecated compatibility option; the safe fallback makes one exact pass",
)
args = parser.parse_args()
return force_uninstall(args.package, args.python, args.max_attempts)
if __name__ == "__main__":
sys.exit(main())