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.
- pip_check_resolve-0.1.0/PKG-INFO +86 -0
- pip_check_resolve-0.1.0/README.md +63 -0
- pip_check_resolve-0.1.0/pyproject.toml +41 -0
- pip_check_resolve-0.1.0/setup.cfg +4 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve/__init__.py +4 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve/__main__.py +5 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve/analysis.py +383 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve/cli.py +70 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve/probe.py +121 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve/render.py +95 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve.egg-info/PKG-INFO +86 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve.egg-info/SOURCES.txt +16 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve.egg-info/dependency_links.txt +1 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve.egg-info/entry_points.txt +2 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve.egg-info/requires.txt +4 -0
- pip_check_resolve-0.1.0/src/pip_check_resolve.egg-info/top_level.txt +1 -0
- pip_check_resolve-0.1.0/tests/test_analysis.py +190 -0
- pip_check_resolve-0.1.0/tests/test_cli.py +65 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|