compat-check 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.
- compat_check-0.1.0/LICENSE +21 -0
- compat_check-0.1.0/PKG-INFO +144 -0
- compat_check-0.1.0/README.md +121 -0
- compat_check-0.1.0/compat_check/__init__.py +0 -0
- compat_check-0.1.0/compat_check/cache.py +100 -0
- compat_check-0.1.0/compat_check/cli.py +80 -0
- compat_check-0.1.0/compat_check/fetcher.py +152 -0
- compat_check-0.1.0/compat_check/render.py +62 -0
- compat_check-0.1.0/compat_check/runner.py +197 -0
- compat_check-0.1.0/compat_check/tree.py +105 -0
- compat_check-0.1.0/compat_check.egg-info/PKG-INFO +144 -0
- compat_check-0.1.0/compat_check.egg-info/SOURCES.txt +23 -0
- compat_check-0.1.0/compat_check.egg-info/dependency_links.txt +1 -0
- compat_check-0.1.0/compat_check.egg-info/entry_points.txt +2 -0
- compat_check-0.1.0/compat_check.egg-info/top_level.txt +1 -0
- compat_check-0.1.0/pyproject.toml +36 -0
- compat_check-0.1.0/setup.cfg +4 -0
- compat_check-0.1.0/tests/test_backend_selection.py +30 -0
- compat_check-0.1.0/tests/test_cache.py +66 -0
- compat_check-0.1.0/tests/test_cli.py +71 -0
- compat_check-0.1.0/tests/test_fetcher.py +72 -0
- compat_check-0.1.0/tests/test_pip_backend.py +43 -0
- compat_check-0.1.0/tests/test_render.py +57 -0
- compat_check-0.1.0/tests/test_runner.py +50 -0
- compat_check-0.1.0/tests/test_tree.py +62 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jahyunlee00299
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: compat-check
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check whether a GitHub repo or PyPI package would install cleanly in this environment, without actually installing it.
|
|
5
|
+
Author: jahyunlee00299
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jahyunlee00299/compat-check
|
|
8
|
+
Project-URL: Repository, https://github.com/jahyunlee00299/compat-check
|
|
9
|
+
Project-URL: Issues, https://github.com/jahyunlee00299/compat-check/issues
|
|
10
|
+
Keywords: dependencies,packaging,pip,uv,dry-run,resolver
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
18
|
+
Classifier: Topic :: System :: Software Distribution
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# compat-check
|
|
25
|
+
|
|
26
|
+
[](https://github.com/jahyunlee00299/compat-check/actions/workflows/test.yml)
|
|
27
|
+
|
|
28
|
+
Check whether a GitHub repo or PyPI package would install cleanly **in this
|
|
29
|
+
environment** — before you actually install it.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
$ compat-check https://github.com/pallets/flask
|
|
33
|
+
compat-check: https://github.com/pallets/flask
|
|
34
|
+
backend: uv
|
|
35
|
+
requirements checked: blinker>=1.9.0, click>=8.1.3, itsdangerous>=2.2.0, jinja2>=3.1.2, markupsafe>=2.1.1, werkzeug>=3.1.0
|
|
36
|
+
|
|
37
|
+
OK — 6 package(s) would install cleanly:
|
|
38
|
+
+ blinker==1.9.0
|
|
39
|
+
+ click==8.5.0
|
|
40
|
+
...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
$ compat-check some-package-with-a-real-conflict
|
|
45
|
+
PROBLEMS FOUND — 1 package(s) cannot be resolved:
|
|
46
|
+
|
|
47
|
+
[numpy]
|
|
48
|
+
× No solution found when resolving dependencies:
|
|
49
|
+
╰─▶ Because you require numpy>=2.0 and numpy<1.20, we can conclude that your
|
|
50
|
+
requirements are unsatisfiable.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Why this instead of `uv`/`pip` themselves
|
|
54
|
+
|
|
55
|
+
`uv` and `pip` already resolve dependencies — that's not the gap. Two things
|
|
56
|
+
are:
|
|
57
|
+
|
|
58
|
+
- **You still have to actually run the install (or a dry-run) yourself**,
|
|
59
|
+
reading whatever error comes back. `compat-check` does that in a throwaway
|
|
60
|
+
venv and hands you a plain-language pass/fail, without touching your real
|
|
61
|
+
environment.
|
|
62
|
+
- **Both resolvers are fail-fast**: a single dry-run call reports only the
|
|
63
|
+
*first* unsatisfiable requirement. If two unrelated packages in the same
|
|
64
|
+
`requirements.txt` are both broken, one hides behind the other.
|
|
65
|
+
`compat-check` drops each failure and retries until every one surfaces.
|
|
66
|
+
|
|
67
|
+
It does **not** try to out-resolve `uv`/`pip` — it wraps them (preferring
|
|
68
|
+
`uv` when available, falling back to the standard-library `venv` + `pip`
|
|
69
|
+
when it isn't) and reports what actually happened, not a static prediction
|
|
70
|
+
from metadata.
|
|
71
|
+
|
|
72
|
+
## What it checks
|
|
73
|
+
|
|
74
|
+
- Whether every requirement resolves at all (missing versions, yanked
|
|
75
|
+
releases, platform/ABI mismatches — reported with the resolver's own
|
|
76
|
+
explanation)
|
|
77
|
+
- Whether requirements in the same source conflict with each other
|
|
78
|
+
|
|
79
|
+
## What it deliberately does not check (yet)
|
|
80
|
+
|
|
81
|
+
- BLAS/LAPACK backend compatibility — this is a post-install diagnostic
|
|
82
|
+
(`numpy.show_config()`), not something knowable before installing
|
|
83
|
+
- GPU/CUDA driver compatibility beyond what the resolver itself reports —
|
|
84
|
+
PyTorch-style packages that ship on a separate index aren't covered
|
|
85
|
+
- `setup.py`-only packages with no `pyproject.toml`/`requirements.txt`/
|
|
86
|
+
`setup.cfg` (would require unsafe code execution to parse reliably)
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
Not yet published to PyPI — install directly from the repo:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
uv tool install git+https://github.com/jahyunlee00299/compat-check
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
(or `pipx install git+https://github.com/jahyunlee00299/compat-check`, or clone and
|
|
97
|
+
`pip install .` into a venv)
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
compat-check <github-url-or-pypi-package-name> [--python 3.11] [--no-cache] [--tree]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Exit codes: `0` clean, `1` conflicts found, `2` source could not be resolved
|
|
106
|
+
at all (bad URL, nonexistent package).
|
|
107
|
+
|
|
108
|
+
`--tree` shows the full dependency tree (requires `uv` — no pip-backend
|
|
109
|
+
equivalent exists):
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
$ compat-check https://github.com/pallets/flask --tree
|
|
113
|
+
...
|
|
114
|
+
https://github.com/pallets/flask
|
|
115
|
+
├── blinker v1.9.0
|
|
116
|
+
├── click v8.5.0
|
|
117
|
+
├── itsdangerous v2.2.0
|
|
118
|
+
├── jinja2 v3.1.6
|
|
119
|
+
│ └── markupsafe v3.0.3
|
|
120
|
+
├── markupsafe v3.0.3
|
|
121
|
+
└── werkzeug v3.1.8
|
|
122
|
+
└── markupsafe v3.0.3
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Results are cached locally (`~/.cache/compat_check/`, 7-day TTL) since a
|
|
126
|
+
dry-run against the same environment and requirements won't change
|
|
127
|
+
minute-to-minute. Use `--no-cache` to force a fresh probe.
|
|
128
|
+
|
|
129
|
+
## How it works
|
|
130
|
+
|
|
131
|
+
1. Fetch the requirement list — from `pyproject.toml`, `requirements.txt`,
|
|
132
|
+
or `setup.cfg` on the GitHub repo, or from PyPI's JSON API for a bare
|
|
133
|
+
package name.
|
|
134
|
+
2. Create a disposable virtual environment.
|
|
135
|
+
3. Run `pip install --dry-run` (or `uv pip install --dry-run`) against it —
|
|
136
|
+
this resolves and would-download, but never actually installs anything
|
|
137
|
+
or runs arbitrary setup code from the target package.
|
|
138
|
+
4. Report the result, retrying with failing packages dropped one at a time
|
|
139
|
+
so every conflict in a multi-package source gets surfaced, not just the
|
|
140
|
+
first one the resolver hits.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# compat-check
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jahyunlee00299/compat-check/actions/workflows/test.yml)
|
|
4
|
+
|
|
5
|
+
Check whether a GitHub repo or PyPI package would install cleanly **in this
|
|
6
|
+
environment** — before you actually install it.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
$ compat-check https://github.com/pallets/flask
|
|
10
|
+
compat-check: https://github.com/pallets/flask
|
|
11
|
+
backend: uv
|
|
12
|
+
requirements checked: blinker>=1.9.0, click>=8.1.3, itsdangerous>=2.2.0, jinja2>=3.1.2, markupsafe>=2.1.1, werkzeug>=3.1.0
|
|
13
|
+
|
|
14
|
+
OK — 6 package(s) would install cleanly:
|
|
15
|
+
+ blinker==1.9.0
|
|
16
|
+
+ click==8.5.0
|
|
17
|
+
...
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
$ compat-check some-package-with-a-real-conflict
|
|
22
|
+
PROBLEMS FOUND — 1 package(s) cannot be resolved:
|
|
23
|
+
|
|
24
|
+
[numpy]
|
|
25
|
+
× No solution found when resolving dependencies:
|
|
26
|
+
╰─▶ Because you require numpy>=2.0 and numpy<1.20, we can conclude that your
|
|
27
|
+
requirements are unsatisfiable.
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Why this instead of `uv`/`pip` themselves
|
|
31
|
+
|
|
32
|
+
`uv` and `pip` already resolve dependencies — that's not the gap. Two things
|
|
33
|
+
are:
|
|
34
|
+
|
|
35
|
+
- **You still have to actually run the install (or a dry-run) yourself**,
|
|
36
|
+
reading whatever error comes back. `compat-check` does that in a throwaway
|
|
37
|
+
venv and hands you a plain-language pass/fail, without touching your real
|
|
38
|
+
environment.
|
|
39
|
+
- **Both resolvers are fail-fast**: a single dry-run call reports only the
|
|
40
|
+
*first* unsatisfiable requirement. If two unrelated packages in the same
|
|
41
|
+
`requirements.txt` are both broken, one hides behind the other.
|
|
42
|
+
`compat-check` drops each failure and retries until every one surfaces.
|
|
43
|
+
|
|
44
|
+
It does **not** try to out-resolve `uv`/`pip` — it wraps them (preferring
|
|
45
|
+
`uv` when available, falling back to the standard-library `venv` + `pip`
|
|
46
|
+
when it isn't) and reports what actually happened, not a static prediction
|
|
47
|
+
from metadata.
|
|
48
|
+
|
|
49
|
+
## What it checks
|
|
50
|
+
|
|
51
|
+
- Whether every requirement resolves at all (missing versions, yanked
|
|
52
|
+
releases, platform/ABI mismatches — reported with the resolver's own
|
|
53
|
+
explanation)
|
|
54
|
+
- Whether requirements in the same source conflict with each other
|
|
55
|
+
|
|
56
|
+
## What it deliberately does not check (yet)
|
|
57
|
+
|
|
58
|
+
- BLAS/LAPACK backend compatibility — this is a post-install diagnostic
|
|
59
|
+
(`numpy.show_config()`), not something knowable before installing
|
|
60
|
+
- GPU/CUDA driver compatibility beyond what the resolver itself reports —
|
|
61
|
+
PyTorch-style packages that ship on a separate index aren't covered
|
|
62
|
+
- `setup.py`-only packages with no `pyproject.toml`/`requirements.txt`/
|
|
63
|
+
`setup.cfg` (would require unsafe code execution to parse reliably)
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
Not yet published to PyPI — install directly from the repo:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
uv tool install git+https://github.com/jahyunlee00299/compat-check
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
(or `pipx install git+https://github.com/jahyunlee00299/compat-check`, or clone and
|
|
74
|
+
`pip install .` into a venv)
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
compat-check <github-url-or-pypi-package-name> [--python 3.11] [--no-cache] [--tree]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Exit codes: `0` clean, `1` conflicts found, `2` source could not be resolved
|
|
83
|
+
at all (bad URL, nonexistent package).
|
|
84
|
+
|
|
85
|
+
`--tree` shows the full dependency tree (requires `uv` — no pip-backend
|
|
86
|
+
equivalent exists):
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
$ compat-check https://github.com/pallets/flask --tree
|
|
90
|
+
...
|
|
91
|
+
https://github.com/pallets/flask
|
|
92
|
+
├── blinker v1.9.0
|
|
93
|
+
├── click v8.5.0
|
|
94
|
+
├── itsdangerous v2.2.0
|
|
95
|
+
├── jinja2 v3.1.6
|
|
96
|
+
│ └── markupsafe v3.0.3
|
|
97
|
+
├── markupsafe v3.0.3
|
|
98
|
+
└── werkzeug v3.1.8
|
|
99
|
+
└── markupsafe v3.0.3
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Results are cached locally (`~/.cache/compat_check/`, 7-day TTL) since a
|
|
103
|
+
dry-run against the same environment and requirements won't change
|
|
104
|
+
minute-to-minute. Use `--no-cache` to force a fresh probe.
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
1. Fetch the requirement list — from `pyproject.toml`, `requirements.txt`,
|
|
109
|
+
or `setup.cfg` on the GitHub repo, or from PyPI's JSON API for a bare
|
|
110
|
+
package name.
|
|
111
|
+
2. Create a disposable virtual environment.
|
|
112
|
+
3. Run `pip install --dry-run` (or `uv pip install --dry-run`) against it —
|
|
113
|
+
this resolves and would-download, but never actually installs anything
|
|
114
|
+
or runs arbitrary setup code from the target package.
|
|
115
|
+
4. Report the result, retrying with failing packages dropped one at a time
|
|
116
|
+
so every conflict in a multi-package source gets surfaced, not just the
|
|
117
|
+
first one the resolver hits.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""SQLite-backed cache in front of runner.probe_all().
|
|
2
|
+
|
|
3
|
+
runner.py stays cache-unaware by design (separation of concerns) — this
|
|
4
|
+
module wraps it. Keyed on the exact inputs that determine a dry-run's
|
|
5
|
+
outcome: requirements, python_version, and backend name (uv vs pip give
|
|
6
|
+
different resolvers and can disagree).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import sqlite3
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from compat_check.runner import _select_backend, probe_all
|
|
17
|
+
|
|
18
|
+
DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60 # 7 days — see docs/feature-connectivity-ledger.md
|
|
19
|
+
DEFAULT_DB_PATH = Path.home() / ".cache" / "compat_check" / "history.db"
|
|
20
|
+
|
|
21
|
+
_SCHEMA = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS probe_cache (
|
|
23
|
+
cache_key TEXT PRIMARY KEY,
|
|
24
|
+
ok INTEGER NOT NULL,
|
|
25
|
+
failures_json TEXT NOT NULL,
|
|
26
|
+
resolved_json TEXT NOT NULL,
|
|
27
|
+
backend TEXT NOT NULL,
|
|
28
|
+
checked_at REAL NOT NULL
|
|
29
|
+
)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
34
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
conn = sqlite3.connect(str(db_path))
|
|
36
|
+
conn.execute(_SCHEMA)
|
|
37
|
+
return conn
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_cache_key(requirements: list[str], python_version: str, backend: str) -> str:
|
|
41
|
+
normalized = json.dumps(
|
|
42
|
+
{"requirements": sorted(requirements), "python_version": python_version, "backend": backend},
|
|
43
|
+
sort_keys=True,
|
|
44
|
+
)
|
|
45
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cached_probe_all(
|
|
49
|
+
requirements: list[str],
|
|
50
|
+
python_version: str = "3.11",
|
|
51
|
+
max_rounds: int = 20,
|
|
52
|
+
db_path: Path | None = None,
|
|
53
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
54
|
+
) -> dict:
|
|
55
|
+
"""probe_all(), but returns a cached result when one exists and hasn't expired.
|
|
56
|
+
|
|
57
|
+
Result dict gains one extra key not present in raw probe_all() output:
|
|
58
|
+
"cache_hit": bool.
|
|
59
|
+
"""
|
|
60
|
+
db_path = db_path or DEFAULT_DB_PATH
|
|
61
|
+
backend_name = _select_backend().name
|
|
62
|
+
cache_key = make_cache_key(requirements, python_version, backend_name)
|
|
63
|
+
|
|
64
|
+
conn = _connect(db_path)
|
|
65
|
+
try:
|
|
66
|
+
row = conn.execute(
|
|
67
|
+
"SELECT ok, failures_json, resolved_json, backend, checked_at "
|
|
68
|
+
"FROM probe_cache WHERE cache_key = ?",
|
|
69
|
+
(cache_key,),
|
|
70
|
+
).fetchone()
|
|
71
|
+
|
|
72
|
+
if row is not None:
|
|
73
|
+
ok, failures_json, resolved_json, backend, checked_at = row
|
|
74
|
+
if time.time() - checked_at < ttl_seconds:
|
|
75
|
+
return {
|
|
76
|
+
"ok": bool(ok),
|
|
77
|
+
"backend": backend,
|
|
78
|
+
"failures": json.loads(failures_json),
|
|
79
|
+
"resolved": json.loads(resolved_json),
|
|
80
|
+
"cache_hit": True,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
result = probe_all(requirements, python_version=python_version, max_rounds=max_rounds)
|
|
84
|
+
conn.execute(
|
|
85
|
+
"INSERT OR REPLACE INTO probe_cache "
|
|
86
|
+
"(cache_key, ok, failures_json, resolved_json, backend, checked_at) "
|
|
87
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
88
|
+
(
|
|
89
|
+
cache_key,
|
|
90
|
+
int(result["ok"]),
|
|
91
|
+
json.dumps(result["failures"]),
|
|
92
|
+
json.dumps(result["resolved"]),
|
|
93
|
+
result["backend"],
|
|
94
|
+
time.time(),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
conn.commit()
|
|
98
|
+
return {**result, "cache_hit": False}
|
|
99
|
+
finally:
|
|
100
|
+
conn.close()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""compat-check CLI entry point — wires fetcher -> cache -> runner into a
|
|
2
|
+
human-readable report.
|
|
3
|
+
|
|
4
|
+
compat-check <github-url-or-pypi-name> [--python 3.11] [--no-cache]
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from compat_check.cache import cached_probe_all
|
|
12
|
+
from compat_check.fetcher import FetchError, fetch_requirements
|
|
13
|
+
from compat_check.render import render_tree
|
|
14
|
+
from compat_check.runner import probe_all
|
|
15
|
+
from compat_check.tree import build_tree
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _print_report(source: str, requirements: list[str], result: dict) -> None:
|
|
19
|
+
cache_note = " (cached)" if result.get("cache_hit") else ""
|
|
20
|
+
print(f"compat-check: {source}{cache_note}")
|
|
21
|
+
print(f"backend: {result['backend']}")
|
|
22
|
+
print(f"requirements checked: {', '.join(requirements)}")
|
|
23
|
+
print()
|
|
24
|
+
|
|
25
|
+
if result["ok"]:
|
|
26
|
+
print(f"OK — {len(result['resolved'])} package(s) would install cleanly:")
|
|
27
|
+
for pkg in result["resolved"]:
|
|
28
|
+
print(f" + {pkg}")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
print(f"PROBLEMS FOUND — {len(result['failures'])} package(s) cannot be resolved:")
|
|
32
|
+
for f in result["failures"]:
|
|
33
|
+
print(f"\n [{f['package']}]")
|
|
34
|
+
# Indent the raw resolver output so it reads as evidence, not noise.
|
|
35
|
+
for line in f["stderr"].strip().splitlines():
|
|
36
|
+
print(f" {line}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="compat-check",
|
|
42
|
+
description="Check whether a GitHub repo or PyPI package would install "
|
|
43
|
+
"cleanly in this environment, without actually installing it.",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument("source", help="GitHub repo URL or PyPI package name")
|
|
46
|
+
parser.add_argument("--python", default="3.11", dest="python_version",
|
|
47
|
+
help="target Python version (default: 3.11; ignored by the pip fallback)")
|
|
48
|
+
parser.add_argument("--no-cache", action="store_true",
|
|
49
|
+
help="skip the local failure-history cache, always probe fresh")
|
|
50
|
+
parser.add_argument("--tree", action="store_true",
|
|
51
|
+
help="show the full dependency tree (requires uv; no pip fallback)")
|
|
52
|
+
args = parser.parse_args(argv)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
requirements = fetch_requirements(args.source)
|
|
56
|
+
except FetchError as e:
|
|
57
|
+
print(f"compat-check: could not resolve requirements for '{args.source}': {e}", file=sys.stderr)
|
|
58
|
+
return 2
|
|
59
|
+
|
|
60
|
+
if args.no_cache:
|
|
61
|
+
result = probe_all(requirements, python_version=args.python_version)
|
|
62
|
+
result = {**result, "cache_hit": False}
|
|
63
|
+
else:
|
|
64
|
+
result = cached_probe_all(requirements, python_version=args.python_version)
|
|
65
|
+
|
|
66
|
+
_print_report(args.source, requirements, result)
|
|
67
|
+
|
|
68
|
+
if args.tree:
|
|
69
|
+
print()
|
|
70
|
+
tree_result = build_tree(requirements, python_version=args.python_version)
|
|
71
|
+
if tree_result["ok"]:
|
|
72
|
+
print(render_tree(tree_result["roots"], label=args.source))
|
|
73
|
+
else:
|
|
74
|
+
print(f"(tree unavailable: {tree_result['stderr'].strip()})", file=sys.stderr)
|
|
75
|
+
|
|
76
|
+
return 0 if result["ok"] else 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
sys.exit(main())
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Resolve a GitHub repo URL or a PyPI package name into requirement specs.
|
|
2
|
+
|
|
3
|
+
GitHub path tries pyproject.toml, then requirements.txt, then setup.cfg, on
|
|
4
|
+
raw.githubusercontent.com — unauthenticated, no rate limit (confirmed in
|
|
5
|
+
prior research). setup.py is out of scope for this unit (would need AST
|
|
6
|
+
parsing to be safe; deferred).
|
|
7
|
+
|
|
8
|
+
PyPI path reads info.requires_dist from the package's JSON API.
|
|
9
|
+
|
|
10
|
+
fetch_requirements() distinguishes "found nothing" from "found an empty
|
|
11
|
+
list" by raising FetchError instead of ever returning []. Callers must not
|
|
12
|
+
confuse an empty return with runner.probe_all([]), which is a "no
|
|
13
|
+
requirements, trivially ok" result — this module never emits an empty list.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import configparser
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import tomllib
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FetchError(Exception):
|
|
27
|
+
"""Raised when a source could not be resolved to any requirements."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class GitHubRef:
|
|
32
|
+
owner: str
|
|
33
|
+
repo: str
|
|
34
|
+
branch: str | None # None = try main, then master
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_GITHUB_URL_RE = re.compile(
|
|
38
|
+
r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?"
|
|
39
|
+
r"(?:/(?:tree|blob)/(?P<branch>[^/]+))?/?$"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_CANDIDATE_FILES = ("pyproject.toml", "requirements.txt", "setup.cfg")
|
|
43
|
+
_BRANCH_FALLBACKS = ("main", "master")
|
|
44
|
+
_USER_AGENT = "compat-check/0.1 (+https://github.com/)"
|
|
45
|
+
_REQUEST_TIMEOUT = 15
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _http_get(url: str) -> str | None:
|
|
49
|
+
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
|
50
|
+
try:
|
|
51
|
+
with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp:
|
|
52
|
+
return resp.read().decode("utf-8", errors="replace")
|
|
53
|
+
except urllib.error.HTTPError as e:
|
|
54
|
+
if e.code == 404:
|
|
55
|
+
return None
|
|
56
|
+
raise FetchError(f"HTTP {e.code} fetching {url}") from e
|
|
57
|
+
except urllib.error.URLError as e:
|
|
58
|
+
raise FetchError(f"network error fetching {url}: {e.reason}") from e
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_github_url(source: str) -> GitHubRef | None:
|
|
62
|
+
m = _GITHUB_URL_RE.match(source.strip())
|
|
63
|
+
if not m:
|
|
64
|
+
return None
|
|
65
|
+
return GitHubRef(owner=m.group("owner"), repo=m.group("repo"), branch=m.group("branch"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_pyproject_toml(text: str) -> list[str]:
|
|
69
|
+
data = tomllib.loads(text)
|
|
70
|
+
project = data.get("project", {})
|
|
71
|
+
deps = list(project.get("dependencies", []))
|
|
72
|
+
# optional-dependencies groups are extras, not base requirements — skipped intentionally.
|
|
73
|
+
return deps
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_requirements_txt(text: str) -> list[str]:
|
|
77
|
+
reqs = []
|
|
78
|
+
for line in text.splitlines():
|
|
79
|
+
line = line.strip()
|
|
80
|
+
if not line or line.startswith("#"):
|
|
81
|
+
continue
|
|
82
|
+
if line.startswith(("-r ", "-e ", "--")):
|
|
83
|
+
continue # nested includes / editable installs / pip flags out of scope
|
|
84
|
+
reqs.append(line)
|
|
85
|
+
return reqs
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_setup_cfg(text: str) -> list[str]:
|
|
89
|
+
parser = configparser.ConfigParser()
|
|
90
|
+
parser.read_string(text)
|
|
91
|
+
if not parser.has_option("options", "install_requires"):
|
|
92
|
+
return []
|
|
93
|
+
raw = parser.get("options", "install_requires")
|
|
94
|
+
return [line.strip() for line in raw.splitlines() if line.strip()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_PARSERS = {
|
|
98
|
+
"pyproject.toml": _parse_pyproject_toml,
|
|
99
|
+
"requirements.txt": _parse_requirements_txt,
|
|
100
|
+
"setup.cfg": _parse_setup_cfg,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _fetch_from_github(ref: GitHubRef) -> list[str]:
|
|
105
|
+
branches = [ref.branch] if ref.branch else list(_BRANCH_FALLBACKS)
|
|
106
|
+
tried = []
|
|
107
|
+
for branch in branches:
|
|
108
|
+
for filename in _CANDIDATE_FILES:
|
|
109
|
+
url = f"https://raw.githubusercontent.com/{ref.owner}/{ref.repo}/{branch}/{filename}"
|
|
110
|
+
tried.append(url)
|
|
111
|
+
text = _http_get(url)
|
|
112
|
+
if text is None:
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
reqs = _PARSERS[filename](text)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
raise FetchError(f"failed to parse {url}: {e}") from e
|
|
118
|
+
if reqs:
|
|
119
|
+
return reqs
|
|
120
|
+
# file exists but declares no dependencies — keep searching other files/branches
|
|
121
|
+
raise FetchError(
|
|
122
|
+
f"no pyproject.toml/requirements.txt/setup.cfg with dependencies found for "
|
|
123
|
+
f"{ref.owner}/{ref.repo} (tried: {', '.join(tried)})"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _fetch_from_pypi(package_name: str) -> list[str]:
|
|
128
|
+
url = f"https://pypi.org/pypi/{package_name}/json"
|
|
129
|
+
text = _http_get(url)
|
|
130
|
+
if text is None:
|
|
131
|
+
raise FetchError(f"PyPI package not found: {package_name}")
|
|
132
|
+
data = json.loads(text)
|
|
133
|
+
requires_dist = data.get("info", {}).get("requires_dist") or []
|
|
134
|
+
# drop extras-only markers (e.g. 'foo; extra == "dev"') — base install only.
|
|
135
|
+
base = [r for r in requires_dist if "extra ==" not in r]
|
|
136
|
+
if not base:
|
|
137
|
+
raise FetchError(f"PyPI package {package_name} declares no base requires_dist")
|
|
138
|
+
return base
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def fetch_requirements(source: str) -> list[str]:
|
|
142
|
+
"""Resolve `source` (GitHub URL or PyPI package name) to requirement specs.
|
|
143
|
+
|
|
144
|
+
Raises FetchError if nothing could be resolved. Never returns [] —
|
|
145
|
+
that return value is reserved for callers of runner.probe_all() to mean
|
|
146
|
+
"no requirements, trivially ok", which this function must not be
|
|
147
|
+
confused with.
|
|
148
|
+
"""
|
|
149
|
+
ref = parse_github_url(source)
|
|
150
|
+
if ref is not None:
|
|
151
|
+
return _fetch_from_github(ref)
|
|
152
|
+
return _fetch_from_pypi(source)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Render a dependency tree (see tree.py) as a colored ASCII tree for the
|
|
2
|
+
terminal. No external dependency — plain ANSI escape codes, disabled
|
|
3
|
+
automatically when stdout isn't a TTY (e.g. piped to a file) so redirected
|
|
4
|
+
output stays clean text.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
_GREEN = "\033[32m"
|
|
11
|
+
_CYAN = "\033[36m"
|
|
12
|
+
_DIM = "\033[2m"
|
|
13
|
+
_RESET = "\033[0m"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _colors_enabled() -> bool:
|
|
17
|
+
return sys.stdout.isatty()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _colorize(text: str, code: str) -> str:
|
|
21
|
+
if not _colors_enabled():
|
|
22
|
+
return text
|
|
23
|
+
return f"{code}{text}{_RESET}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_tree(roots: list[dict], label: str | None = None) -> str:
|
|
27
|
+
"""roots: list of {"name", "version", "children"} dicts, as returned by
|
|
28
|
+
tree.build_tree()["roots"]. Returns a multi-line string ready to print.
|
|
29
|
+
|
|
30
|
+
When `label` is given, roots are nested one level under a synthetic
|
|
31
|
+
label node instead of being printed as separate top-level trees — used
|
|
32
|
+
to show "what this source pulls in" as a single tree rather than a flat
|
|
33
|
+
list of its direct dependencies.
|
|
34
|
+
"""
|
|
35
|
+
lines: list[str] = []
|
|
36
|
+
if label is not None:
|
|
37
|
+
lines.append(_colorize(label, _CYAN))
|
|
38
|
+
for i, root in enumerate(roots):
|
|
39
|
+
_render_node(root, prefix="", is_last=(i == len(roots) - 1), lines=lines, is_root=False)
|
|
40
|
+
return "\n".join(lines)
|
|
41
|
+
|
|
42
|
+
for i, root in enumerate(roots):
|
|
43
|
+
_render_node(root, prefix="", is_last=(i == len(roots) - 1), lines=lines, is_root=True)
|
|
44
|
+
return "\n".join(lines)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _render_node(node: dict, prefix: str, is_last: bool, lines: list[str], is_root: bool) -> None:
|
|
48
|
+
name = _colorize(node["name"], _GREEN)
|
|
49
|
+
version = _colorize(f"v{node['version']}", _DIM)
|
|
50
|
+
|
|
51
|
+
if is_root:
|
|
52
|
+
lines.append(f"{name} {version}")
|
|
53
|
+
child_prefix = ""
|
|
54
|
+
else:
|
|
55
|
+
connector = "└── " if is_last else "├── "
|
|
56
|
+
lines.append(f"{prefix}{connector}{name} {version}")
|
|
57
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
58
|
+
|
|
59
|
+
children = node.get("children", [])
|
|
60
|
+
for i, child in enumerate(children):
|
|
61
|
+
_render_node(child, child_prefix, is_last=(i == len(children) - 1),
|
|
62
|
+
lines=lines, is_root=False)
|