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.
@@ -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
+ [![test](https://github.com/jahyunlee00299/compat-check/actions/workflows/test.yml/badge.svg)](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
+ [![test](https://github.com/jahyunlee00299/compat-check/actions/workflows/test.yml/badge.svg)](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)