pip-check-resolve 0.1.0__tar.gz

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.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: pip-check-resolve
3
+ Version: 0.1.0
4
+ Summary: Explain installed Python dependency conflicts and their causal paths
5
+ Project-URL: Homepage, https://github.com/matplo/pip-check-resolve
6
+ Project-URL: Repository, https://github.com/matplo/pip-check-resolve
7
+ Project-URL: Issues, https://github.com/matplo/pip-check-resolve/issues
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: packaging>=24
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=8; extra == "test"
23
+
24
+ # pip-check-resolve
25
+
26
+ `pip-check-resolve` inspects an installed Python environment without changing it.
27
+ It finds missing or incompatible dependencies and traces each problem back to the
28
+ likely top-level packages that caused it.
29
+
30
+ Unlike a resolver, it answers **why the environment that exists now is broken**.
31
+ It does not install packages, try alternative versions, or contact PyPI.
32
+
33
+ ## Usage
34
+
35
+ Run inside the environment to inspect the current interpreter:
36
+
37
+ ```console
38
+ $ pip-check-resolve
39
+ ```
40
+
41
+ Or keep the tool isolated and point it at another environment:
42
+
43
+ ```console
44
+ $ uvx --from pip-check-resolve pip-check-resolve --python .venv/bin/python
45
+ ```
46
+
47
+ Machine-readable output and expanded causal paths are available:
48
+
49
+ ```console
50
+ $ pip-check-resolve --python .venv/bin/python --format json
51
+ $ pip-check-resolve --all-paths --max-paths 100
52
+ ```
53
+
54
+ Exit status is `0` for a consistent environment, `1` when package or metadata
55
+ issues are found, and `2` when the target interpreter cannot be inspected.
56
+
57
+ ## What is checked
58
+
59
+ - missing dependencies;
60
+ - versions that do not satisfy their declared requirements;
61
+ - incompatible `Requires-Python` metadata;
62
+ - duplicate normalized distribution names;
63
+ - malformed package requirements or metadata.
64
+
65
+ Roots are identified from a distribution's `REQUESTED` marker when available and
66
+ otherwise inferred from the installed dependency graph. Python packaging does not
67
+ reliably preserve which extras were requested, so requirements conditional on
68
+ `extra` are skipped and the report records that limitation.
69
+
70
+ ## Releasing
71
+
72
+ Releases are published from GitHub tags using PyPI Trusted Publishing. The tag
73
+ must be the project version prefixed with `v`, for example `v0.1.0`. Update the
74
+ version in both `pyproject.toml` and `src/pip_check_resolve/__init__.py`, commit,
75
+ and then create and push the matching tag:
76
+
77
+ ```console
78
+ $ git tag v0.1.0
79
+ $ git push origin v0.1.0
80
+ ```
81
+
82
+ The `publish.yml` workflow tests the package, builds and checks its wheel and
83
+ source distribution, and publishes them to PyPI using short-lived OIDC
84
+ credentials. The PyPI Trusted Publisher must use owner `matplo`, repository
85
+ `pip-check-resolve`, workflow `publish.yml`, and environment `pypi`.
86
+
@@ -0,0 +1,63 @@
1
+ # pip-check-resolve
2
+
3
+ `pip-check-resolve` inspects an installed Python environment without changing it.
4
+ It finds missing or incompatible dependencies and traces each problem back to the
5
+ likely top-level packages that caused it.
6
+
7
+ Unlike a resolver, it answers **why the environment that exists now is broken**.
8
+ It does not install packages, try alternative versions, or contact PyPI.
9
+
10
+ ## Usage
11
+
12
+ Run inside the environment to inspect the current interpreter:
13
+
14
+ ```console
15
+ $ pip-check-resolve
16
+ ```
17
+
18
+ Or keep the tool isolated and point it at another environment:
19
+
20
+ ```console
21
+ $ uvx --from pip-check-resolve pip-check-resolve --python .venv/bin/python
22
+ ```
23
+
24
+ Machine-readable output and expanded causal paths are available:
25
+
26
+ ```console
27
+ $ pip-check-resolve --python .venv/bin/python --format json
28
+ $ pip-check-resolve --all-paths --max-paths 100
29
+ ```
30
+
31
+ Exit status is `0` for a consistent environment, `1` when package or metadata
32
+ issues are found, and `2` when the target interpreter cannot be inspected.
33
+
34
+ ## What is checked
35
+
36
+ - missing dependencies;
37
+ - versions that do not satisfy their declared requirements;
38
+ - incompatible `Requires-Python` metadata;
39
+ - duplicate normalized distribution names;
40
+ - malformed package requirements or metadata.
41
+
42
+ Roots are identified from a distribution's `REQUESTED` marker when available and
43
+ otherwise inferred from the installed dependency graph. Python packaging does not
44
+ reliably preserve which extras were requested, so requirements conditional on
45
+ `extra` are skipped and the report records that limitation.
46
+
47
+ ## Releasing
48
+
49
+ Releases are published from GitHub tags using PyPI Trusted Publishing. The tag
50
+ must be the project version prefixed with `v`, for example `v0.1.0`. Update the
51
+ version in both `pyproject.toml` and `src/pip_check_resolve/__init__.py`, commit,
52
+ and then create and push the matching tag:
53
+
54
+ ```console
55
+ $ git tag v0.1.0
56
+ $ git push origin v0.1.0
57
+ ```
58
+
59
+ The `publish.yml` workflow tests the package, builds and checks its wheel and
60
+ source distribution, and publishes them to PyPI using short-lived OIDC
61
+ credentials. The PyPI Trusted Publisher must use owner `matplo`, repository
62
+ `pip-check-resolve`, workflow `publish.yml`, and environment `pypi`.
63
+
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pip-check-resolve"
7
+ version = "0.1.0"
8
+ description = "Explain installed Python dependency conflicts and their causal paths"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = ["packaging>=24"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ "Topic :: System :: Systems Administration",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/matplo/pip-check-resolve"
27
+ Repository = "https://github.com/matplo/pip-check-resolve"
28
+ Issues = "https://github.com/matplo/pip-check-resolve/issues"
29
+
30
+ [project.optional-dependencies]
31
+ test = ["pytest>=8"]
32
+
33
+ [project.scripts]
34
+ pip-check-resolve = "pip_check_resolve.cli:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """Explain dependency conflicts in installed Python environments."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ raise SystemExit(main())
5
+
@@ -0,0 +1,383 @@
1
+ """Analyze an installed-distribution snapshot as a dependency graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict, deque
6
+ from dataclasses import dataclass, field
7
+ import re
8
+ from typing import Any, Iterable
9
+
10
+ from packaging.markers import default_environment
11
+ from packaging.requirements import InvalidRequirement, Requirement
12
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
13
+ from packaging.utils import canonicalize_name
14
+ from packaging.version import InvalidVersion, Version
15
+
16
+ from .probe import Snapshot
17
+
18
+
19
+ _EXTRA_MARKER = re.compile(
20
+ r"(?:^|[\s(])extra\s*(?:===|==|!=|~=|<=|>=|<|>|in\b|not\s+in\b)"
21
+ r"|(?:===|==|!=|~=|<=|>=|<|>|in\b|not\s+in\b)\s*extra(?:[\s)]|$)",
22
+ re.IGNORECASE,
23
+ )
24
+
25
+
26
+ @dataclass
27
+ class Distribution:
28
+ name: str
29
+ key: str
30
+ version: str | None
31
+ requires_dist: list[str]
32
+ requires_python: str | None
33
+ requested: bool
34
+ metadata_location: str | None
35
+
36
+
37
+ @dataclass
38
+ class CausalPath:
39
+ packages: list[str]
40
+ root: str
41
+ root_attribution: str
42
+
43
+
44
+ @dataclass
45
+ class Issue:
46
+ code: str
47
+ package: str
48
+ message: str
49
+ requirement: str | None = None
50
+ dependency: str | None = None
51
+ installed_versions: list[str] = field(default_factory=list)
52
+ paths: list[CausalPath] = field(default_factory=list)
53
+ paths_truncated: bool = False
54
+ details: dict[str, Any] = field(default_factory=dict)
55
+
56
+
57
+ @dataclass
58
+ class Report:
59
+ target_python: str
60
+ environment: dict[str, str]
61
+ package_count: int
62
+ roots: dict[str, str]
63
+ issues: list[Issue]
64
+ warnings: list[str]
65
+
66
+ @property
67
+ def clean(self) -> bool:
68
+ return not self.issues
69
+
70
+
71
+ def _marker_environment(snapshot: Snapshot) -> dict[str, str]:
72
+ environment = default_environment()
73
+ environment.update({key: str(value) for key, value in snapshot.environment.items()})
74
+ environment["extra"] = ""
75
+ return environment
76
+
77
+
78
+ def _display_name(distributions: dict[str, list[Distribution]], key: str) -> str:
79
+ values = distributions.get(key)
80
+ return values[0].name if values else key
81
+
82
+
83
+ def _shortest_paths(graph: dict[str, set[str]], root: str, target: str, limit: int) -> tuple[list[list[str]], bool]:
84
+ if root == target:
85
+ return [[root]], False
86
+
87
+ queue: deque[list[str]] = deque([[root]])
88
+ best_depth: dict[str, int] = {root: 0}
89
+ found: list[list[str]] = []
90
+ target_depth: int | None = None
91
+ truncated = False
92
+
93
+ while queue:
94
+ path = queue.popleft()
95
+ depth = len(path) - 1
96
+ if target_depth is not None and depth >= target_depth:
97
+ continue
98
+ for child in sorted(graph.get(path[-1], ())):
99
+ if child in path:
100
+ continue
101
+ next_depth = depth + 1
102
+ known = best_depth.get(child)
103
+ if known is not None and next_depth > known:
104
+ continue
105
+ best_depth[child] = next_depth
106
+ next_path = [*path, child]
107
+ if child == target:
108
+ target_depth = next_depth
109
+ if len(found) < limit:
110
+ found.append(next_path)
111
+ else:
112
+ truncated = True
113
+ else:
114
+ queue.append(next_path)
115
+ return found, truncated
116
+
117
+
118
+ def _all_simple_paths(graph: dict[str, set[str]], root: str, target: str, limit: int) -> tuple[list[list[str]], bool]:
119
+ found: list[list[str]] = []
120
+ truncated = False
121
+
122
+ def visit(node: str, path: list[str]) -> None:
123
+ nonlocal truncated
124
+ if len(found) >= limit:
125
+ truncated = True
126
+ return
127
+ if node == target:
128
+ found.append(path.copy())
129
+ return
130
+ for child in sorted(graph.get(node, ())):
131
+ if child not in path:
132
+ visit(child, [*path, child])
133
+ if truncated:
134
+ return
135
+
136
+ visit(root, [root])
137
+ return found, truncated
138
+
139
+
140
+ def _attach_paths(
141
+ issues: Iterable[Issue],
142
+ graph: dict[str, set[str]],
143
+ roots: dict[str, str],
144
+ distributions: dict[str, list[Distribution]],
145
+ *,
146
+ all_paths: bool,
147
+ max_paths: int,
148
+ ) -> None:
149
+ path_finder = _all_simple_paths if all_paths else _shortest_paths
150
+ for issue in issues:
151
+ target = canonicalize_name(issue.package)
152
+ remaining = max_paths
153
+ attached: list[CausalPath] = []
154
+ truncated = False
155
+ for root in sorted(roots):
156
+ if remaining <= 0:
157
+ truncated = True
158
+ break
159
+ paths, root_truncated = path_finder(graph, root, target, remaining)
160
+ truncated = truncated or root_truncated
161
+ for path in paths:
162
+ names = [_display_name(distributions, key) for key in path]
163
+ attached.append(CausalPath(names, names[0], roots[root]))
164
+ remaining -= len(paths)
165
+
166
+ if not attached:
167
+ name = _display_name(distributions, target)
168
+ attached = [CausalPath([name], name, "unattributed")]
169
+ issue.paths = attached
170
+ issue.paths_truncated = truncated
171
+
172
+
173
+ def analyze(snapshot: Snapshot, *, all_paths: bool = False, max_paths: int = 20) -> Report:
174
+ """Return dependency issues and causal paths for an environment snapshot."""
175
+
176
+ distributions: dict[str, list[Distribution]] = defaultdict(list)
177
+ issues: list[Issue] = []
178
+ warnings: list[str] = []
179
+
180
+ for index, record in enumerate(snapshot.installed):
181
+ location = record.get("metadata_location")
182
+ metadata_error = record.get("metadata_error")
183
+ name_value = record.get("name")
184
+ version_value = record.get("version")
185
+ if metadata_error or not isinstance(name_value, str) or not name_value.strip():
186
+ package = str(name_value or location or f"unknown-distribution-{index + 1}")
187
+ detail = str(metadata_error or "METADATA has no valid Name field")
188
+ issues.append(
189
+ Issue(
190
+ "invalid_metadata",
191
+ package,
192
+ f"{package} has unreadable or incomplete metadata: {detail}",
193
+ details={"metadata_location": location, "error": detail},
194
+ )
195
+ )
196
+ continue
197
+
198
+ name = name_value.strip()
199
+ version = str(version_value).strip() if version_value is not None else None
200
+ raw_requires = record.get("requires_dist")
201
+ requires = [str(item) for item in raw_requires] if isinstance(raw_requires, list) else []
202
+ requires_python_value = record.get("requires_python")
203
+ distribution = Distribution(
204
+ name=name,
205
+ key=canonicalize_name(name),
206
+ version=version,
207
+ requires_dist=requires,
208
+ requires_python=str(requires_python_value) if requires_python_value else None,
209
+ requested=bool(record.get("requested")),
210
+ metadata_location=str(location) if location else None,
211
+ )
212
+ distributions[distribution.key].append(distribution)
213
+
214
+ graph: dict[str, set[str]] = {key: set() for key in distributions}
215
+ incoming: dict[str, set[str]] = {key: set() for key in distributions}
216
+ marker_environment = _marker_environment(snapshot)
217
+ saw_extra_marker = False
218
+
219
+ for key, candidates in sorted(distributions.items()):
220
+ versions = [candidate.version or "<missing>" for candidate in candidates]
221
+ if len(candidates) > 1:
222
+ issues.append(
223
+ Issue(
224
+ "duplicate_distribution",
225
+ candidates[0].name,
226
+ f"{candidates[0].name} is installed more than once: {', '.join(versions)}",
227
+ installed_versions=versions,
228
+ details={"metadata_locations": [item.metadata_location for item in candidates]},
229
+ )
230
+ )
231
+
232
+ for candidate in candidates:
233
+ if candidate.version is None:
234
+ issues.append(
235
+ Issue(
236
+ "invalid_metadata",
237
+ candidate.name,
238
+ f"{candidate.name} metadata has no Version field",
239
+ details={"metadata_location": candidate.metadata_location},
240
+ )
241
+ )
242
+ else:
243
+ try:
244
+ Version(candidate.version)
245
+ except InvalidVersion:
246
+ issues.append(
247
+ Issue(
248
+ "invalid_version",
249
+ candidate.name,
250
+ f"{candidate.name} has invalid installed version {candidate.version!r}",
251
+ installed_versions=[candidate.version],
252
+ )
253
+ )
254
+
255
+ if candidate.requires_python:
256
+ try:
257
+ python_specifier = SpecifierSet(candidate.requires_python)
258
+ target_version = snapshot.environment.get("python_full_version", "")
259
+ if target_version and not python_specifier.contains(target_version, prereleases=True):
260
+ issues.append(
261
+ Issue(
262
+ "incompatible_python",
263
+ candidate.name,
264
+ f"{candidate.name} requires Python {candidate.requires_python}, but the target uses {target_version}",
265
+ requirement=f"Python{candidate.requires_python}",
266
+ installed_versions=[target_version],
267
+ )
268
+ )
269
+ except (InvalidSpecifier, InvalidVersion) as exc:
270
+ issues.append(
271
+ Issue(
272
+ "invalid_requires_python",
273
+ candidate.name,
274
+ f"{candidate.name} has invalid Requires-Python metadata {candidate.requires_python!r}: {exc}",
275
+ requirement=f"Python{candidate.requires_python}",
276
+ )
277
+ )
278
+
279
+ for requirement_text in candidate.requires_dist:
280
+ try:
281
+ requirement = Requirement(requirement_text)
282
+ except InvalidRequirement as exc:
283
+ issues.append(
284
+ Issue(
285
+ "invalid_requirement",
286
+ candidate.name,
287
+ f"{candidate.name} declares invalid requirement {requirement_text!r}: {exc}",
288
+ requirement=requirement_text,
289
+ )
290
+ )
291
+ continue
292
+
293
+ if requirement.marker and _EXTRA_MARKER.search(str(requirement.marker)):
294
+ saw_extra_marker = True
295
+ continue
296
+ if requirement.marker:
297
+ try:
298
+ if not requirement.marker.evaluate(marker_environment):
299
+ continue
300
+ except Exception as exc:
301
+ issues.append(
302
+ Issue(
303
+ "invalid_marker",
304
+ candidate.name,
305
+ f"Could not evaluate marker in {requirement_text!r}: {exc}",
306
+ requirement=requirement_text,
307
+ )
308
+ )
309
+ continue
310
+
311
+ dependency_key = canonicalize_name(requirement.name)
312
+ if dependency_key in distributions:
313
+ graph[key].add(dependency_key)
314
+ incoming[dependency_key].add(key)
315
+
316
+ installed = distributions.get(dependency_key, [])
317
+ if not installed:
318
+ issues.append(
319
+ Issue(
320
+ "missing_dependency",
321
+ candidate.name,
322
+ f"{candidate.name} requires {requirement}, but {requirement.name} is not installed",
323
+ requirement=str(requirement),
324
+ dependency=requirement.name,
325
+ )
326
+ )
327
+ continue
328
+
329
+ valid_versions: list[Version] = []
330
+ installed_versions: list[str] = []
331
+ for dependency in installed:
332
+ if dependency.version is None:
333
+ continue
334
+ installed_versions.append(dependency.version)
335
+ try:
336
+ valid_versions.append(Version(dependency.version))
337
+ except InvalidVersion:
338
+ pass
339
+
340
+ if requirement.specifier and valid_versions and not any(
341
+ requirement.specifier.contains(version, prereleases=True) for version in valid_versions
342
+ ):
343
+ issues.append(
344
+ Issue(
345
+ "incompatible_version",
346
+ candidate.name,
347
+ f"{candidate.name} requires {requirement}, but installed {requirement.name} version(s) are {', '.join(installed_versions)}",
348
+ requirement=str(requirement),
349
+ dependency=requirement.name,
350
+ installed_versions=installed_versions,
351
+ )
352
+ )
353
+
354
+ if saw_extra_marker:
355
+ warnings.append(
356
+ "Requirements conditional on extras were skipped because installed metadata does not record which extras were selected."
357
+ )
358
+
359
+ roots: dict[str, str] = {}
360
+ for key, candidates in distributions.items():
361
+ if any(candidate.requested for candidate in candidates):
362
+ roots[key] = "recorded"
363
+ for key in distributions:
364
+ if not incoming[key] and key not in roots:
365
+ roots[key] = "inferred"
366
+
367
+ _attach_paths(
368
+ issues,
369
+ graph,
370
+ roots,
371
+ distributions,
372
+ all_paths=all_paths,
373
+ max_paths=max_paths,
374
+ )
375
+ issues.sort(key=lambda item: (item.code, canonicalize_name(item.package), item.requirement or ""))
376
+ return Report(
377
+ target_python=snapshot.target_python,
378
+ environment=snapshot.environment,
379
+ package_count=sum(len(values) for values in distributions.values()),
380
+ roots={_display_name(distributions, key): attribution for key, attribution in sorted(roots.items())},
381
+ issues=issues,
382
+ warnings=warnings,
383
+ )
@@ -0,0 +1,70 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from . import __version__
10
+ from .analysis import analyze
11
+ from .probe import ProbeError, inspect_interpreter
12
+ from .render import render_json, render_probe_error, render_text
13
+
14
+
15
+ def _positive_integer(value: str) -> int:
16
+ try:
17
+ result = int(value)
18
+ except ValueError as exc:
19
+ raise argparse.ArgumentTypeError("must be an integer") from exc
20
+ if result < 1:
21
+ raise argparse.ArgumentTypeError("must be at least 1")
22
+ return result
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog="pip-check-resolve",
28
+ description="Explain dependency conflicts in an installed Python environment without changing it.",
29
+ )
30
+ parser.add_argument(
31
+ "--python",
32
+ default=sys.executable,
33
+ metavar="PATH",
34
+ help="target Python interpreter (default: this interpreter)",
35
+ )
36
+ parser.add_argument(
37
+ "--format",
38
+ choices=("text", "json"),
39
+ default="text",
40
+ help="output format (default: text)",
41
+ )
42
+ parser.add_argument(
43
+ "--all-paths",
44
+ action="store_true",
45
+ help="show all simple causal paths instead of only shortest paths",
46
+ )
47
+ parser.add_argument(
48
+ "--max-paths",
49
+ type=_positive_integer,
50
+ default=20,
51
+ metavar="N",
52
+ help="maximum causal paths per issue (default: 20)",
53
+ )
54
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
55
+ return parser
56
+
57
+
58
+ def main(argv: Sequence[str] | None = None) -> int:
59
+ args = build_parser().parse_args(argv)
60
+ try:
61
+ snapshot = inspect_interpreter(args.python)
62
+ except ProbeError as exc:
63
+ output = render_probe_error(str(exc), as_json=args.format == "json")
64
+ print(output, file=sys.stdout if args.format == "json" else sys.stderr)
65
+ return 2
66
+
67
+ report = analyze(snapshot, all_paths=args.all_paths, max_paths=args.max_paths)
68
+ print(render_json(report) if args.format == "json" else render_text(report))
69
+ return 0 if report.clean else 1
70
+
@@ -0,0 +1,121 @@
1
+ """Collect metadata from a target interpreter without importing this package there."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+
11
+ class ProbeError(RuntimeError):
12
+ """The target interpreter could not return an environment snapshot."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Snapshot:
17
+ target_python: str
18
+ environment: dict[str, str]
19
+ installed: list[dict[str, object]]
20
+
21
+
22
+ # Keep this script compatible with Python 3.8: it intentionally has no dependency
23
+ # outside the target interpreter's standard library.
24
+ _PROBE_SCRIPT = r'''
25
+ import importlib.metadata
26
+ import json
27
+ import os
28
+ import platform
29
+ import sys
30
+
31
+
32
+ def text_version(info):
33
+ version = "%s.%s.%s" % (info.major, info.minor, info.micro)
34
+ if info.releaselevel != "final":
35
+ version += info.releaselevel[0] + str(info.serial)
36
+ return version
37
+
38
+
39
+ environment = {
40
+ "implementation_name": sys.implementation.name,
41
+ "implementation_version": text_version(sys.implementation.version),
42
+ "os_name": os.name,
43
+ "platform_machine": platform.machine(),
44
+ "platform_release": platform.release(),
45
+ "platform_system": platform.system(),
46
+ "platform_version": platform.version(),
47
+ "platform_python_implementation": platform.python_implementation(),
48
+ "python_full_version": platform.python_version(),
49
+ "python_version": ".".join(platform.python_version_tuple()[:2]),
50
+ "sys_platform": sys.platform,
51
+ }
52
+
53
+ installed = []
54
+ for dist in importlib.metadata.distributions():
55
+ record = {}
56
+ dist_path = getattr(dist, "_path", None)
57
+ if dist_path is not None:
58
+ record["metadata_location"] = str(dist_path)
59
+ try:
60
+ record["requested"] = (dist_path / "REQUESTED").is_file()
61
+ except (OSError, TypeError):
62
+ record["requested"] = False
63
+ else:
64
+ record["metadata_location"] = None
65
+ record["requested"] = False
66
+
67
+ try:
68
+ metadata = dist.metadata
69
+ record["name"] = metadata.get("Name")
70
+ record["version"] = metadata.get("Version")
71
+ record["requires_dist"] = metadata.get_all("Requires-Dist") or []
72
+ record["requires_python"] = metadata.get("Requires-Python")
73
+ except Exception as exc:
74
+ record["metadata_error"] = type(exc).__name__ + ": " + str(exc)
75
+ installed.append(record)
76
+
77
+ json.dump({
78
+ "probe_version": 1,
79
+ "executable": sys.executable,
80
+ "environment": environment,
81
+ "installed": installed,
82
+ }, sys.stdout, sort_keys=True)
83
+ '''
84
+
85
+
86
+ def inspect_interpreter(python: str, *, timeout: float = 30.0) -> Snapshot:
87
+ """Run the metadata probe under *python* and return its parsed snapshot."""
88
+
89
+ try:
90
+ completed = subprocess.run(
91
+ [python, "-I", "-c", _PROBE_SCRIPT],
92
+ check=False,
93
+ capture_output=True,
94
+ text=True,
95
+ timeout=timeout,
96
+ )
97
+ except FileNotFoundError as exc:
98
+ raise ProbeError(f"Python interpreter not found: {python}") from exc
99
+ except PermissionError as exc:
100
+ raise ProbeError(f"Python interpreter is not executable: {python}") from exc
101
+ except subprocess.TimeoutExpired as exc:
102
+ raise ProbeError(f"Timed out inspecting {python} after {timeout:g} seconds") from exc
103
+ except OSError as exc:
104
+ raise ProbeError(f"Could not run {python}: {exc}") from exc
105
+
106
+ if completed.returncode != 0:
107
+ detail = completed.stderr.strip() or completed.stdout.strip() or "no error output"
108
+ raise ProbeError(f"Target interpreter exited with {completed.returncode}: {detail}")
109
+
110
+ try:
111
+ data = json.loads(completed.stdout)
112
+ except (json.JSONDecodeError, TypeError) as exc:
113
+ raise ProbeError(f"Target interpreter returned invalid probe data: {exc}") from exc
114
+
115
+ if data.get("probe_version") != 1:
116
+ raise ProbeError("Target interpreter returned an unsupported probe version")
117
+ if not isinstance(data.get("environment"), dict) or not isinstance(data.get("installed"), list):
118
+ raise ProbeError("Target interpreter returned an incomplete probe snapshot")
119
+
120
+ executable = str(data.get("executable") or Path(python))
121
+ return Snapshot(executable, data["environment"], data["installed"])
@@ -0,0 +1,95 @@
1
+ """Render analysis reports for humans and automation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from .analysis import Report
8
+
9
+
10
+ def report_data(report: Report) -> dict[str, object]:
11
+ return {
12
+ "schema_version": 1,
13
+ "status": "clean" if report.clean else "issues",
14
+ "target_python": report.target_python,
15
+ "environment": report.environment,
16
+ "summary": {
17
+ "package_count": report.package_count,
18
+ "issue_count": len(report.issues),
19
+ "warning_count": len(report.warnings),
20
+ },
21
+ "roots": [
22
+ {"name": name, "attribution": attribution}
23
+ for name, attribution in sorted(report.roots.items(), key=lambda item: item[0].lower())
24
+ ],
25
+ "issues": [
26
+ {
27
+ "code": issue.code,
28
+ "package": issue.package,
29
+ "message": issue.message,
30
+ "requirement": issue.requirement,
31
+ "dependency": issue.dependency,
32
+ "installed_versions": issue.installed_versions,
33
+ "paths": [
34
+ {
35
+ "root": path.root,
36
+ "root_attribution": path.root_attribution,
37
+ "packages": path.packages,
38
+ }
39
+ for path in issue.paths
40
+ ],
41
+ "paths_truncated": issue.paths_truncated,
42
+ "details": issue.details,
43
+ }
44
+ for issue in report.issues
45
+ ],
46
+ "warnings": report.warnings,
47
+ }
48
+
49
+
50
+ def render_json(report: Report) -> str:
51
+ return json.dumps(report_data(report), indent=2, sort_keys=True)
52
+
53
+
54
+ def render_text(report: Report) -> str:
55
+ python_version = report.environment.get("python_full_version", "unknown")
56
+ if report.clean:
57
+ lines = [
58
+ f"No dependency conflicts found among {report.package_count} packages ",
59
+ f"in Python {python_version} ({report.target_python}).",
60
+ ]
61
+ # Keep the success sentence together while preserving a readable source file.
62
+ lines = ["".join(lines)]
63
+ else:
64
+ noun = "issue" if len(report.issues) == 1 else "issues"
65
+ lines = [
66
+ f"Found {len(report.issues)} dependency {noun} among {report.package_count} packages ",
67
+ f"in Python {python_version} ({report.target_python}).",
68
+ ]
69
+ lines = ["".join(lines), ""]
70
+ for issue in report.issues:
71
+ lines.append(f"[{issue.code.upper()}] {issue.message}")
72
+ for path in issue.paths:
73
+ chain = " -> ".join(path.packages)
74
+ lines.append(f" via ({path.root_attribution} root) {chain}")
75
+ if issue.paths_truncated:
76
+ lines.append(" Additional causal paths were truncated.")
77
+ lines.append("")
78
+ if lines[-1] == "":
79
+ lines.pop()
80
+
81
+ if report.warnings:
82
+ lines.extend(["", "Warnings:"])
83
+ lines.extend(f" - {warning}" for warning in report.warnings)
84
+ return "\n".join(lines)
85
+
86
+
87
+ def render_probe_error(message: str, *, as_json: bool) -> str:
88
+ if as_json:
89
+ return json.dumps(
90
+ {"schema_version": 1, "status": "error", "error": {"code": "probe_failed", "message": message}},
91
+ indent=2,
92
+ sort_keys=True,
93
+ )
94
+ return f"error: {message}"
95
+
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: pip-check-resolve
3
+ Version: 0.1.0
4
+ Summary: Explain installed Python dependency conflicts and their causal paths
5
+ Project-URL: Homepage, https://github.com/matplo/pip-check-resolve
6
+ Project-URL: Repository, https://github.com/matplo/pip-check-resolve
7
+ Project-URL: Issues, https://github.com/matplo/pip-check-resolve/issues
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: packaging>=24
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=8; extra == "test"
23
+
24
+ # pip-check-resolve
25
+
26
+ `pip-check-resolve` inspects an installed Python environment without changing it.
27
+ It finds missing or incompatible dependencies and traces each problem back to the
28
+ likely top-level packages that caused it.
29
+
30
+ Unlike a resolver, it answers **why the environment that exists now is broken**.
31
+ It does not install packages, try alternative versions, or contact PyPI.
32
+
33
+ ## Usage
34
+
35
+ Run inside the environment to inspect the current interpreter:
36
+
37
+ ```console
38
+ $ pip-check-resolve
39
+ ```
40
+
41
+ Or keep the tool isolated and point it at another environment:
42
+
43
+ ```console
44
+ $ uvx --from pip-check-resolve pip-check-resolve --python .venv/bin/python
45
+ ```
46
+
47
+ Machine-readable output and expanded causal paths are available:
48
+
49
+ ```console
50
+ $ pip-check-resolve --python .venv/bin/python --format json
51
+ $ pip-check-resolve --all-paths --max-paths 100
52
+ ```
53
+
54
+ Exit status is `0` for a consistent environment, `1` when package or metadata
55
+ issues are found, and `2` when the target interpreter cannot be inspected.
56
+
57
+ ## What is checked
58
+
59
+ - missing dependencies;
60
+ - versions that do not satisfy their declared requirements;
61
+ - incompatible `Requires-Python` metadata;
62
+ - duplicate normalized distribution names;
63
+ - malformed package requirements or metadata.
64
+
65
+ Roots are identified from a distribution's `REQUESTED` marker when available and
66
+ otherwise inferred from the installed dependency graph. Python packaging does not
67
+ reliably preserve which extras were requested, so requirements conditional on
68
+ `extra` are skipped and the report records that limitation.
69
+
70
+ ## Releasing
71
+
72
+ Releases are published from GitHub tags using PyPI Trusted Publishing. The tag
73
+ must be the project version prefixed with `v`, for example `v0.1.0`. Update the
74
+ version in both `pyproject.toml` and `src/pip_check_resolve/__init__.py`, commit,
75
+ and then create and push the matching tag:
76
+
77
+ ```console
78
+ $ git tag v0.1.0
79
+ $ git push origin v0.1.0
80
+ ```
81
+
82
+ The `publish.yml` workflow tests the package, builds and checks its wheel and
83
+ source distribution, and publishes them to PyPI using short-lived OIDC
84
+ credentials. The PyPI Trusted Publisher must use owner `matplo`, repository
85
+ `pip-check-resolve`, workflow `publish.yml`, and environment `pypi`.
86
+
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/pip_check_resolve/__init__.py
4
+ src/pip_check_resolve/__main__.py
5
+ src/pip_check_resolve/analysis.py
6
+ src/pip_check_resolve/cli.py
7
+ src/pip_check_resolve/probe.py
8
+ src/pip_check_resolve/render.py
9
+ src/pip_check_resolve.egg-info/PKG-INFO
10
+ src/pip_check_resolve.egg-info/SOURCES.txt
11
+ src/pip_check_resolve.egg-info/dependency_links.txt
12
+ src/pip_check_resolve.egg-info/entry_points.txt
13
+ src/pip_check_resolve.egg-info/requires.txt
14
+ src/pip_check_resolve.egg-info/top_level.txt
15
+ tests/test_analysis.py
16
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pip-check-resolve = pip_check_resolve.cli:main
@@ -0,0 +1,4 @@
1
+ packaging>=24
2
+
3
+ [test]
4
+ pytest>=8
@@ -0,0 +1 @@
1
+ pip_check_resolve
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ from pip_check_resolve.analysis import analyze
4
+ from pip_check_resolve.probe import Snapshot
5
+ from pip_check_resolve.render import report_data, render_text
6
+
7
+
8
+ ENVIRONMENT = {
9
+ "implementation_name": "cpython",
10
+ "implementation_version": "3.12.4",
11
+ "os_name": "posix",
12
+ "platform_machine": "x86_64",
13
+ "platform_release": "test",
14
+ "platform_system": "Linux",
15
+ "platform_version": "test",
16
+ "platform_python_implementation": "CPython",
17
+ "python_full_version": "3.12.4",
18
+ "python_version": "3.12",
19
+ "sys_platform": "linux",
20
+ }
21
+
22
+
23
+ def dist(
24
+ name: str,
25
+ version: str = "1.0",
26
+ *,
27
+ requires: list[str] | None = None,
28
+ requested: bool = False,
29
+ requires_python: str | None = None,
30
+ location: str | None = None,
31
+ ) -> dict[str, object]:
32
+ return {
33
+ "name": name,
34
+ "version": version,
35
+ "requires_dist": requires or [],
36
+ "requires_python": requires_python,
37
+ "requested": requested,
38
+ "metadata_location": location or f"/site/{name}-{version}.dist-info",
39
+ }
40
+
41
+
42
+ def snapshot(*installed: dict[str, object]) -> Snapshot:
43
+ return Snapshot("/venv/bin/python", ENVIRONMENT.copy(), list(installed))
44
+
45
+
46
+ def test_clean_transitive_graph() -> None:
47
+ report = analyze(
48
+ snapshot(
49
+ dist("app", requires=["middle>=1"], requested=True),
50
+ dist("middle", requires=["leaf<2"]),
51
+ dist("leaf", "1.5"),
52
+ )
53
+ )
54
+
55
+ assert report.clean
56
+ assert report.roots == {"app": "recorded"}
57
+ assert report_data(report)["status"] == "clean"
58
+
59
+
60
+ def test_conflict_has_shortest_path_from_recorded_root() -> None:
61
+ report = analyze(
62
+ snapshot(
63
+ dist("app", requires=["middle"], requested=True),
64
+ dist("middle", requires=["leaf<2"]),
65
+ dist("leaf", "3.0"),
66
+ )
67
+ )
68
+
69
+ assert len(report.issues) == 1
70
+ issue = report.issues[0]
71
+ assert issue.code == "incompatible_version"
72
+ assert issue.paths[0].packages == ["app", "middle"]
73
+ assert issue.paths[0].root_attribution == "recorded"
74
+ assert "app -> middle" in render_text(report)
75
+
76
+
77
+ def test_missing_dependency_and_inferred_root() -> None:
78
+ report = analyze(snapshot(dist("plugin", requires=["missing>=4"])))
79
+
80
+ assert report.roots == {"plugin": "inferred"}
81
+ assert report.issues[0].code == "missing_dependency"
82
+ assert report.issues[0].dependency == "missing"
83
+ assert report.issues[0].paths[0].packages == ["plugin"]
84
+
85
+
86
+ def test_environment_markers_and_extras() -> None:
87
+ report = analyze(
88
+ snapshot(
89
+ dist(
90
+ "app",
91
+ requires=[
92
+ "windows-only; sys_platform == 'win32'",
93
+ "speedup; extra == 'fast'",
94
+ "present; python_version >= '3.10'",
95
+ ],
96
+ requested=True,
97
+ ),
98
+ dist("present"),
99
+ )
100
+ )
101
+
102
+ assert report.clean
103
+ assert len(report.warnings) == 1
104
+ assert "extras" in report.warnings[0]
105
+
106
+
107
+ def test_literal_word_extra_is_not_treated_as_an_extras_marker() -> None:
108
+ environment = ENVIRONMENT.copy()
109
+ environment["os_name"] = "extra"
110
+ report = analyze(
111
+ Snapshot(
112
+ "/venv/bin/python",
113
+ environment,
114
+ [dist("app", requires=['required; os_name == "extra"'], requested=True)],
115
+ )
116
+ )
117
+
118
+ assert report.issues[0].code == "missing_dependency"
119
+ assert not report.warnings
120
+
121
+
122
+ def test_duplicate_and_incompatible_python_are_reported() -> None:
123
+ report = analyze(
124
+ snapshot(
125
+ dist("same-name", "1", requested=True, location="/one"),
126
+ dist("Same_Name", "2", location="/two"),
127
+ dist("future", requires_python=">=3.13"),
128
+ )
129
+ )
130
+
131
+ assert {issue.code for issue in report.issues} == {
132
+ "duplicate_distribution",
133
+ "incompatible_python",
134
+ }
135
+
136
+
137
+ def test_invalid_metadata_requirement_and_version() -> None:
138
+ broken_metadata = {"metadata_location": "/broken", "metadata_error": "ValueError: bad"}
139
+ report = analyze(
140
+ snapshot(
141
+ broken_metadata,
142
+ dist("bad-version", "not a version"),
143
+ dist("bad-requirement", requires=["???"]),
144
+ )
145
+ )
146
+
147
+ assert {issue.code for issue in report.issues} == {
148
+ "invalid_metadata",
149
+ "invalid_requirement",
150
+ "invalid_version",
151
+ }
152
+
153
+
154
+ def test_all_paths_are_cycle_safe_and_capped() -> None:
155
+ graph_snapshot = snapshot(
156
+ dist("root", requires=["left", "right"], requested=True),
157
+ dist("left", requires=["middle"]),
158
+ dist("right", requires=["middle"]),
159
+ dist("middle", requires=["left", "missing"]),
160
+ )
161
+
162
+ complete = analyze(graph_snapshot, all_paths=True, max_paths=10)
163
+ issue = next(item for item in complete.issues if item.code == "missing_dependency")
164
+ assert {tuple(path.packages) for path in issue.paths} == {
165
+ ("root", "left", "middle"),
166
+ ("root", "right", "middle"),
167
+ }
168
+ assert not issue.paths_truncated
169
+
170
+ capped = analyze(graph_snapshot, all_paths=True, max_paths=1)
171
+ capped_issue = next(item for item in capped.issues if item.code == "missing_dependency")
172
+ assert len(capped_issue.paths) == 1
173
+ assert capped_issue.paths_truncated
174
+
175
+
176
+ def test_multiple_shortest_paths_for_one_root() -> None:
177
+ report = analyze(
178
+ snapshot(
179
+ dist("root", requires=["left", "right"], requested=True),
180
+ dist("left", requires=["target"]),
181
+ dist("right", requires=["target"]),
182
+ dist("target", requires=["absent"]),
183
+ )
184
+ )
185
+ issue = report.issues[0]
186
+
187
+ assert {tuple(path.packages) for path in issue.paths} == {
188
+ ("root", "left", "target"),
189
+ ("root", "right", "target"),
190
+ }
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import subprocess
6
+ import sys
7
+ import venv
8
+
9
+ from pip_check_resolve.analysis import analyze
10
+ from pip_check_resolve.cli import main
11
+ from pip_check_resolve.probe import inspect_interpreter
12
+
13
+
14
+ def test_probe_current_interpreter() -> None:
15
+ result = inspect_interpreter(sys.executable)
16
+
17
+ assert result.target_python
18
+ assert result.environment["python_full_version"]
19
+ assert isinstance(result.installed, list)
20
+
21
+
22
+ def test_missing_interpreter_is_operational_error(capsys) -> None:
23
+ exit_code = main(["--python", "/definitely/not/a/python", "--format", "json"])
24
+ output = json.loads(capsys.readouterr().out)
25
+
26
+ assert exit_code == 2
27
+ assert output["status"] == "error"
28
+ assert output["error"]["code"] == "probe_failed"
29
+
30
+
31
+ def test_invalid_max_paths_is_usage_error() -> None:
32
+ try:
33
+ main(["--max-paths", "0"])
34
+ except SystemExit as exc:
35
+ assert exc.code == 2
36
+ else:
37
+ raise AssertionError("argparse did not reject --max-paths 0")
38
+
39
+
40
+ def test_external_environment_without_pip_is_inspected_without_changes(tmp_path: Path) -> None:
41
+ environment_dir = tmp_path / "target environment"
42
+ venv.EnvBuilder(with_pip=False).create(environment_dir)
43
+ executable = environment_dir / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
44
+ purelib = Path(
45
+ subprocess.check_output(
46
+ [str(executable), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"],
47
+ text=True,
48
+ ).strip()
49
+ )
50
+ metadata_dir = purelib / "demo_app-1.0.dist-info"
51
+ metadata_dir.mkdir()
52
+ metadata = metadata_dir / "METADATA"
53
+ metadata.write_text(
54
+ "Metadata-Version: 2.1\nName: demo-app\nVersion: 1.0\nRequires-Dist: absent>=2\n",
55
+ encoding="utf-8",
56
+ )
57
+ (metadata_dir / "REQUESTED").touch()
58
+ before = metadata.read_bytes()
59
+
60
+ report = analyze(inspect_interpreter(str(executable)))
61
+
62
+ assert report.issues[0].code == "missing_dependency"
63
+ assert report.issues[0].paths[0].root_attribution == "recorded"
64
+ assert metadata.read_bytes() == before
65
+ assert not (purelib / "pip").exists()