peep-install 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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: peep-install
3
+ Version: 0.1.0
4
+ Summary: A smarter pip that auto-resolves version conflicts, build errors, and platform issues
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: requests>=2.28.0
7
+ Requires-Dist: packaging>=23.0
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+ __author__ = "peep-install"
@@ -0,0 +1,154 @@
1
+ import shutil
2
+ import subprocess
3
+ import sys
4
+ import platform
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class BuildRequirement:
10
+ name: str # Human-readable name e.g. "GCC compiler"
11
+ check_command: str # Command to check if it exists e.g. "gcc"
12
+ install_apt: str | None # apt package name
13
+ install_brew: str | None # brew package name
14
+ install_hint: str | None # Fallback manual hint if auto-install fails
15
+
16
+
17
+ # Known build requirements mapped to PyPI package name fragments
18
+ KNOWN_BUILD_DEPS: dict[str, list[BuildRequirement]] = {
19
+ "numpy": [
20
+ BuildRequirement("GCC compiler", "gcc", "gcc", "gcc", None),
21
+ BuildRequirement("Python headers", "python3-config", "python3-dev", None, "Install python3-dev"),
22
+ ],
23
+ "pillow": [
24
+ BuildRequirement("libjpeg", None, "libjpeg-dev", "jpeg", None),
25
+ BuildRequirement("zlib", None, "zlib1g-dev", "zlib", None),
26
+ BuildRequirement("GCC compiler", "gcc", "gcc", "gcc", None),
27
+ ],
28
+ "cryptography": [
29
+ BuildRequirement("Rust toolchain", "rustc", None, "rust", "Install from https://rustup.rs"),
30
+ BuildRequirement("GCC compiler", "gcc", "gcc", "gcc", None),
31
+ BuildRequirement("OpenSSL headers", None, "libssl-dev", "openssl", None),
32
+ ],
33
+ "lxml": [
34
+ BuildRequirement("libxml2", None, "libxml2-dev", "libxml2", None),
35
+ BuildRequirement("libxslt", None, "libxslt-dev", "libxslt", None),
36
+ BuildRequirement("GCC compiler", "gcc", "gcc", "gcc", None),
37
+ ],
38
+ "psycopg2": [
39
+ BuildRequirement("PostgreSQL headers", "pg_config", "libpq-dev", "postgresql", None),
40
+ BuildRequirement("GCC compiler", "gcc", "gcc", "gcc", None),
41
+ ],
42
+ }
43
+
44
+ # Generic build tools needed for any source compilation
45
+ GENERIC_BUILD_DEPS = [
46
+ BuildRequirement("GCC compiler", "gcc", "build-essential", "gcc", None),
47
+ BuildRequirement("Python headers", "python3-config", "python3-dev", None, "Install python3-dev or python-dev"),
48
+ BuildRequirement("Make", "make", "make", "make", None),
49
+ ]
50
+
51
+
52
+ def get_package_manager() -> str | None:
53
+ """Detect the available system package manager."""
54
+ if platform.system() == "Darwin":
55
+ if shutil.which("brew"):
56
+ return "brew"
57
+ return None
58
+ elif platform.system() == "Linux":
59
+ if shutil.which("apt-get"):
60
+ return "apt"
61
+ elif shutil.which("dnf"):
62
+ return "dnf"
63
+ elif shutil.which("pacman"):
64
+ return "pacman"
65
+ return None
66
+ return None
67
+
68
+
69
+ def _is_present(req: BuildRequirement) -> bool:
70
+ """Check if a build requirement is already satisfied."""
71
+ if req.check_command:
72
+ return shutil.which(req.check_command) is not None
73
+ return False
74
+
75
+
76
+ def _install_dep(req: BuildRequirement, pkg_manager: str) -> bool:
77
+ """Attempt to install a build dependency via the system package manager."""
78
+ pkg = None
79
+
80
+ if pkg_manager == "apt":
81
+ pkg = req.install_apt
82
+ cmd = ["sudo", "apt-get", "install", "-y", pkg] if pkg else None
83
+ elif pkg_manager == "brew":
84
+ pkg = req.install_brew
85
+ cmd = ["brew", "install", pkg] if pkg else None
86
+ elif pkg_manager == "dnf":
87
+ pkg = req.install_apt # dnf package names are often the same as apt
88
+ cmd = ["sudo", "dnf", "install", "-y", pkg] if pkg else None
89
+ elif pkg_manager == "pacman":
90
+ pkg = req.install_apt
91
+ cmd = ["sudo", "pacman", "-S", "--noconfirm", pkg] if pkg else None
92
+ else:
93
+ return False
94
+
95
+ if not cmd:
96
+ return False
97
+
98
+ try:
99
+ result = subprocess.run(cmd, capture_output=True, text=True)
100
+ return result.returncode == 0
101
+ except Exception:
102
+ return False
103
+
104
+
105
+ def check_and_fix_build_deps(package_name: str, needs_compilation: bool) -> dict:
106
+ """
107
+ Check for and silently fix missing build dependencies for a package.
108
+ Returns a report of what was found and what was done.
109
+ """
110
+ if not needs_compilation:
111
+ return {"needed": False}
112
+
113
+ pkg_manager = get_package_manager()
114
+ pkg_lower = package_name.lower()
115
+
116
+ # Gather required build deps: generic ones + any package-specific ones
117
+ required: list[BuildRequirement] = list(GENERIC_BUILD_DEPS)
118
+ for key, deps in KNOWN_BUILD_DEPS.items():
119
+ if key in pkg_lower or pkg_lower in key:
120
+ required.extend(deps)
121
+
122
+ # Deduplicate by name
123
+ seen = set()
124
+ unique_required = []
125
+ for r in required:
126
+ if r.name not in seen:
127
+ seen.add(r.name)
128
+ unique_required.append(r)
129
+
130
+ missing = [r for r in unique_required if not _is_present(r)]
131
+
132
+ if not missing:
133
+ return {"needed": True, "missing": [], "fixed": [], "failed": []}
134
+
135
+ fixed = []
136
+ failed = []
137
+
138
+ for req in missing:
139
+ if pkg_manager:
140
+ success = _install_dep(req, pkg_manager)
141
+ if success:
142
+ fixed.append(req.name)
143
+ else:
144
+ failed.append({"dep": req.name, "hint": req.install_hint})
145
+ else:
146
+ failed.append({"dep": req.name, "hint": req.install_hint})
147
+
148
+ return {
149
+ "needed": True,
150
+ "missing": [r.name for r in missing],
151
+ "fixed": fixed,
152
+ "failed": failed,
153
+ "pkg_manager": pkg_manager,
154
+ }
@@ -0,0 +1,129 @@
1
+ import argparse
2
+ import sys
3
+ from .installer import install, install_many
4
+
5
+
6
+ def main():
7
+ parser = argparse.ArgumentParser(
8
+ prog="peep",
9
+ description="A smarter pip — handles version conflicts, build errors, and platform issues automatically.",
10
+ )
11
+
12
+ subparsers = parser.add_subparsers(dest="command", required=True)
13
+
14
+ # ── peep install ──────────────────────────────────────────────────────────
15
+ install_parser = subparsers.add_parser("install", help="Install one or more packages")
16
+ install_parser.add_argument("packages", nargs="+", help="Package(s) to install, e.g. requests or requests==2.28.0")
17
+ install_parser.add_argument("-v", "--verbose", action="store_true", help="Show detailed output")
18
+ install_parser.add_argument("--force", action="store_true", help="Force reinstall even if already installed")
19
+
20
+ # ── peep check ───────────────────────────────────────────────────────────
21
+ check_parser = subparsers.add_parser("check", help="Check a package for compatibility without installing")
22
+ check_parser.add_argument("package", help="Package to check")
23
+ check_parser.add_argument("-v", "--verbose", action="store_true")
24
+
25
+ args = parser.parse_args()
26
+
27
+ if args.command == "install":
28
+ _handle_install(args)
29
+ elif args.command == "check":
30
+ _handle_check(args)
31
+
32
+
33
+ def _handle_install(args):
34
+ # Parse packages — support both `requests` and `requests==2.28.0`
35
+ parsed = [_parse_package_arg(p) for p in args.packages]
36
+
37
+ if len(parsed) == 1:
38
+ name, version = parsed[0]
39
+ _print_status(f"Installing {name}" + (f"=={version}" if version else "") + "...")
40
+ report = install(name, version=version, verbose=args.verbose)
41
+ _print_report(report)
42
+ else:
43
+ _print_status(f"Installing {len(parsed)} packages...")
44
+ reports = install_many([p for p, _ in parsed], verbose=args.verbose)
45
+ for report in reports:
46
+ _print_report(report)
47
+
48
+ # Exit with error code if any install failed
49
+ if isinstance(reports if len(parsed) > 1 else [report], list):
50
+ results = reports if len(parsed) > 1 else [report]
51
+ if any(not r["success"] for r in results):
52
+ sys.exit(1)
53
+
54
+
55
+ def _handle_check(args):
56
+ from .environment import check_python_compatibility, check_platform_compatibility
57
+ from .resolver import resolve_conflicts
58
+
59
+ name, version = _parse_package_arg(args.package)
60
+ _print_status(f"Checking {name}" + (f"=={version}" if version else "") + "...")
61
+
62
+ py = check_python_compatibility(name, version)
63
+ plat = check_platform_compatibility(name, version)
64
+ deps = resolve_conflicts(name, version)
65
+
66
+ print()
67
+ _section("Python compatibility")
68
+ if py.get("compatible") is True:
69
+ _ok(f"Compatible with Python {py.get('current_python')}")
70
+ elif py.get("compatible") is False:
71
+ _warn(f"Requires Python {py.get('requires_python')}, you have {py.get('current_python')}")
72
+ if py.get("suggested_version"):
73
+ _info(f"Suggested version: {name}=={py['suggested_version']}")
74
+ else:
75
+ _info("Could not determine Python compatibility")
76
+
77
+ _section("Platform compatibility")
78
+ if plat.get("has_compatible_wheel"):
79
+ _ok("Compatible wheel available for your platform")
80
+ elif plat.get("needs_compilation"):
81
+ _warn("No wheel available — package will be compiled from source")
82
+ elif not plat.get("has_sdist"):
83
+ _err("No compatible distribution found for your platform")
84
+
85
+ _section("Dependency conflicts")
86
+ if deps["clean"]:
87
+ _ok("No conflicts detected")
88
+ else:
89
+ for conflict in deps["conflicts"]:
90
+ _err(f"{conflict['package']} {conflict['installed']} installed, but {conflict['required']} required by {conflict['required_by']}")
91
+ for upgrade in deps["to_upgrade"]:
92
+ _warn(f"{upgrade['package']} will be upgraded {upgrade['from']} → {upgrade['to']}")
93
+
94
+
95
+ def _print_report(report: dict):
96
+ pkg = report["package"]
97
+ ver = report.get("final_version")
98
+
99
+ print()
100
+ if report["success"]:
101
+ label = f"{pkg}=={ver}" if ver else pkg
102
+ _ok(f"Successfully installed {label}")
103
+ else:
104
+ _err(f"Failed to install {pkg}: {report.get('error', 'Unknown error')}")
105
+
106
+
107
+ def _parse_package_arg(pkg_str: str) -> tuple[str, str | None]:
108
+ """Parse 'requests==2.28.0' into ('requests', '2.28.0'), or 'requests' into ('requests', None)."""
109
+ if "==" in pkg_str:
110
+ name, version = pkg_str.split("==", 1)
111
+ return name.strip(), version.strip()
112
+ return pkg_str.strip(), None
113
+
114
+
115
+ # ── Terminal output helpers ────────────────────────────────────────────────────
116
+
117
+ RESET = "\033[0m"
118
+ GREEN = "\033[32m"
119
+ YELLOW = "\033[33m"
120
+ RED = "\033[31m"
121
+ CYAN = "\033[36m"
122
+ BOLD = "\033[1m"
123
+
124
+ def _print_status(msg): print(f"{BOLD}{CYAN}→ {msg}{RESET}")
125
+ def _section(label): print(f"\n{BOLD}{label}{RESET}")
126
+ def _ok(msg): print(f" {GREEN}✔ {msg}{RESET}")
127
+ def _warn(msg): print(f" {YELLOW}⚠ {msg}{RESET}")
128
+ def _err(msg): print(f" {RED}✘ {msg}{RESET}")
129
+ def _info(msg): print(f" {CYAN}ℹ {msg}{RESET}")
@@ -0,0 +1,181 @@
1
+ import subprocess
2
+ import sys
3
+ import json
4
+
5
+
6
+ def verify_install(package_name: str) -> dict:
7
+ """
8
+ Verify a package is actually importable after installation.
9
+ Runs in a subprocess to get a clean import environment.
10
+ Returns a result dict with importable, version, and error if any.
11
+ """
12
+ # Normalize package name to import name
13
+ import_name = _resolve_import_name(package_name)
14
+
15
+ probe = f"""
16
+ import sys
17
+ import json
18
+
19
+ try:
20
+ mod = __import__("{import_name}")
21
+ version = getattr(mod, "__version__", None)
22
+ print(json.dumps({{"importable": True, "version": version}}))
23
+ except ImportError as e:
24
+ print(json.dumps({{"importable": False, "error": str(e)}}))
25
+ except Exception as e:
26
+ print(json.dumps({{"importable": False, "error": f"Unexpected error: {{e}}"}}))
27
+ """
28
+
29
+ try:
30
+ result = subprocess.run(
31
+ [sys.executable, "-c", probe],
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=15,
35
+ )
36
+
37
+ if result.returncode != 0 or not result.stdout.strip():
38
+ return {
39
+ "importable": False,
40
+ "error": result.stderr.strip() or "No output from import probe",
41
+ }
42
+
43
+ return json.loads(result.stdout.strip())
44
+
45
+ except subprocess.TimeoutExpired:
46
+ return {"importable": False, "error": "Import probe timed out after 15s"}
47
+ except json.JSONDecodeError:
48
+ return {"importable": False, "error": f"Could not parse probe output: {result.stdout.strip()}"}
49
+ except Exception as e:
50
+ return {"importable": False, "error": str(e)}
51
+
52
+
53
+ def triage_import_failure(package_name: str) -> dict:
54
+ """
55
+ When verify_install fails, attempt to diagnose why.
56
+ Returns a diagnosis dict with cause and suggested fix.
57
+ """
58
+ import_name = _resolve_import_name(package_name)
59
+
60
+ checks = {
61
+ "missing_entirely": _check_missing(package_name),
62
+ "wrong_python": _check_wrong_python(package_name),
63
+ "broken_deps": _check_broken_deps(package_name),
64
+ "c_extension_broken": _check_c_extension(import_name),
65
+ }
66
+
67
+ # Determine the most likely cause
68
+ if checks["missing_entirely"]:
69
+ cause = "Package is not installed at all"
70
+ fix = f"peep install {package_name}"
71
+ elif checks["wrong_python"]:
72
+ cause = "Package was installed for a different Python interpreter"
73
+ fix = f"peep install {package_name} (ensure you're using the right python)"
74
+ elif checks["broken_deps"]:
75
+ cause = f"Missing or broken dependencies: {checks['broken_deps']}"
76
+ fix = f"peep install {package_name} --force"
77
+ elif checks["c_extension_broken"]:
78
+ cause = "C extension failed to load (likely a build or ABI issue)"
79
+ fix = f"peep install {package_name} --force --no-binary"
80
+ else:
81
+ cause = "Unknown import failure"
82
+ fix = f"peep install {package_name} --force"
83
+
84
+ return {
85
+ "package": package_name,
86
+ "cause": cause,
87
+ "suggested_fix": fix,
88
+ "checks": checks,
89
+ }
90
+
91
+
92
+ def _check_missing(package_name: str) -> bool:
93
+ """Check if the package shows up in pip list at all."""
94
+ result = subprocess.run(
95
+ [sys.executable, "-m", "pip", "show", package_name],
96
+ capture_output=True, text=True
97
+ )
98
+ return result.returncode != 0
99
+
100
+
101
+ def _check_wrong_python(package_name: str) -> bool:
102
+ """
103
+ Check if the package is installed but under a different Python.
104
+ Detects the common case of pip vs pip3 confusion.
105
+ """
106
+ result = subprocess.run(
107
+ [sys.executable, "-m", "pip", "show", package_name],
108
+ capture_output=True, text=True
109
+ )
110
+ if result.returncode != 0:
111
+ return False
112
+
113
+ # Check if the install location is within the current sys.prefix
114
+ for line in result.stdout.splitlines():
115
+ if line.startswith("Location:"):
116
+ location = line.split(":", 1)[1].strip()
117
+ return sys.prefix.lower() not in location.lower()
118
+
119
+ return False
120
+
121
+
122
+ def _check_broken_deps(package_name: str) -> list[str] | None:
123
+ """Use pip check to find broken dependencies."""
124
+ result = subprocess.run(
125
+ [sys.executable, "-m", "pip", "check"],
126
+ capture_output=True, text=True
127
+ )
128
+ if result.returncode == 0:
129
+ return None
130
+
131
+ # Filter output to lines mentioning this package
132
+ broken = [
133
+ line for line in result.stdout.splitlines()
134
+ if package_name.lower() in line.lower()
135
+ ]
136
+ return broken if broken else None
137
+
138
+
139
+ def _check_c_extension(import_name: str) -> bool:
140
+ """Check if the failure is specifically from a C extension load error."""
141
+ probe = f"""
142
+ try:
143
+ __import__("{import_name}")
144
+ except ImportError as e:
145
+ print("c_ext" if "cannot import" in str(e).lower() or ".so" in str(e) else "other")
146
+ except Exception:
147
+ print("other")
148
+ else:
149
+ print("ok")
150
+ """
151
+ result = subprocess.run(
152
+ [sys.executable, "-c", probe],
153
+ capture_output=True, text=True,
154
+ timeout=10,
155
+ )
156
+ return result.stdout.strip() == "c_ext"
157
+
158
+
159
+ # Map of PyPI package names that differ from their import names
160
+ _IMPORT_NAME_MAP = {
161
+ "pillow": "PIL",
162
+ "opencv-python": "cv2",
163
+ "opencv-python-headless": "cv2",
164
+ "scikit-learn": "sklearn",
165
+ "scikit-image": "skimage",
166
+ "beautifulsoup4": "bs4",
167
+ "pyyaml": "yaml",
168
+ "python-dateutil": "dateutil",
169
+ "python-dotenv": "dotenv",
170
+ "pyzmq": "zmq",
171
+ "pygments": "pygments",
172
+ "typing-extensions": "typing_extensions",
173
+ "attrs": "attr",
174
+ "moviepy": "moviepy.editor",
175
+ }
176
+
177
+
178
+ def _resolve_import_name(package_name: str) -> str:
179
+ """Resolve PyPI package name to its actual Python import name."""
180
+ normalized = package_name.lower().strip()
181
+ return _IMPORT_NAME_MAP.get(normalized, normalized.replace("-", "_"))
@@ -0,0 +1,168 @@
1
+ import sys
2
+ import platform
3
+ import sysconfig
4
+ import requests
5
+ from packaging.version import Version
6
+ from packaging.specifiers import SpecifierSet
7
+
8
+
9
+ def get_current_environment() -> dict:
10
+ """Snapshot of the current Python + platform environment."""
11
+ return {
12
+ "python_version": Version(platform.python_version()),
13
+ "platform_system": platform.system().lower(), # 'linux', 'windows', 'darwin'
14
+ "platform_machine": platform.machine().lower(), # 'x86_64', 'arm64', etc.
15
+ "platform_tags": _get_platform_tags(),
16
+ }
17
+
18
+
19
+ def _get_platform_tags() -> list[str]:
20
+ """Get the list of compatible wheel platform tags for this system."""
21
+ try:
22
+ from pip._internal.utils.compatibility_tags import get_supported
23
+ return [str(tag) for tag in get_supported()]
24
+ except ImportError:
25
+ # Fallback: construct basic tags manually
26
+ py = f"cp{sys.version_info.major}{sys.version_info.minor}"
27
+ arch = sysconfig.get_platform().replace("-", "_").replace(".", "_")
28
+ return [f"{py}-{py}-{arch}", f"py3-none-any", f"py{sys.version_info.major}-none-any"]
29
+
30
+
31
+ def fetch_package_metadata(package_name: str, version: str = None) -> dict | None:
32
+ """Fetch package metadata from PyPI JSON API."""
33
+ url = f"https://pypi.org/pypi/{package_name}/json"
34
+ if version:
35
+ url = f"https://pypi.org/pypi/{package_name}/{version}/json"
36
+
37
+ try:
38
+ resp = requests.get(url, timeout=10)
39
+ resp.raise_for_status()
40
+ return resp.json()
41
+ except requests.RequestException:
42
+ return None
43
+
44
+
45
+ def check_python_compatibility(package_name: str, version: str = None) -> dict:
46
+ """
47
+ Check if a package is compatible with the current Python version.
48
+ Returns a result dict with 'compatible', 'requires_python', and optionally 'suggested_version'.
49
+ """
50
+ env = get_current_environment()
51
+ metadata = fetch_package_metadata(package_name, version)
52
+
53
+ if metadata is None:
54
+ return {"compatible": None, "error": f"Could not fetch metadata for '{package_name}'"}
55
+
56
+ info = metadata["info"]
57
+ requires_python = info.get("requires_python")
58
+
59
+ if not requires_python:
60
+ return {"compatible": True, "requires_python": None}
61
+
62
+ spec = SpecifierSet(requires_python)
63
+ compatible = env["python_version"] in spec
64
+
65
+ result = {
66
+ "compatible": compatible,
67
+ "requires_python": requires_python,
68
+ "current_python": str(env["python_version"]),
69
+ }
70
+
71
+ if not compatible:
72
+ # Try to find the latest version that does support this Python
73
+ suggested = _find_compatible_version(metadata["releases"], spec, env["python_version"])
74
+ result["suggested_version"] = suggested
75
+
76
+ return result
77
+
78
+
79
+ def _find_compatible_version(releases: dict, broken_spec: SpecifierSet, current_python: Version) -> str | None:
80
+ """
81
+ Walk all available releases and return the latest one compatible
82
+ with the current Python version.
83
+ """
84
+ candidates = []
85
+
86
+ for ver_str, files in releases.items():
87
+ if not files:
88
+ continue
89
+ try:
90
+ v = Version(ver_str)
91
+ except Exception:
92
+ continue
93
+
94
+ # Check each release file for its requires_python
95
+ for f in files:
96
+ rp = f.get("requires_python")
97
+ if rp is None:
98
+ candidates.append(v)
99
+ break
100
+ try:
101
+ if current_python in SpecifierSet(rp):
102
+ candidates.append(v)
103
+ break
104
+ except Exception:
105
+ continue
106
+
107
+ if not candidates:
108
+ return None
109
+
110
+ return str(max(candidates))
111
+
112
+
113
+ def check_platform_compatibility(package_name: str, version: str = None) -> dict:
114
+ """
115
+ Check if any wheel for this package supports the current platform.
116
+ Falls back to sdist detection if no compatible wheel is found.
117
+ """
118
+ env = get_current_environment()
119
+ metadata = fetch_package_metadata(package_name, version)
120
+
121
+ if metadata is None:
122
+ return {"compatible": None, "error": f"Could not fetch metadata for '{package_name}'"}
123
+
124
+ target_version = version or metadata["info"]["version"]
125
+ release_files = metadata["releases"].get(target_version, [])
126
+
127
+ has_sdist = False
128
+ has_compatible_wheel = False
129
+ has_any_wheel = False
130
+
131
+ for f in release_files:
132
+ if f["packagetype"] == "sdist":
133
+ has_sdist = True
134
+ elif f["packagetype"] == "bdist_wheel":
135
+ has_any_wheel = True
136
+ filename = f["filename"]
137
+ # Check platform tag compatibility
138
+ if _wheel_is_compatible(filename, env["platform_tags"]):
139
+ has_compatible_wheel = True
140
+
141
+ return {
142
+ "has_compatible_wheel": has_compatible_wheel,
143
+ "has_sdist": has_sdist,
144
+ "has_any_wheel": has_any_wheel,
145
+ "needs_compilation": has_sdist and not has_compatible_wheel,
146
+ "platform": env["platform_system"],
147
+ "machine": env["platform_machine"],
148
+ }
149
+
150
+
151
+ def _wheel_is_compatible(filename: str, supported_tags: list[str]) -> bool:
152
+ """Check if a wheel filename's tags match any of the supported platform tags."""
153
+ # Wheel filename format: {name}-{version}(-{build})?-{python}-{abi}-{platform}.whl
154
+ try:
155
+ base = filename.replace(".whl", "")
156
+ parts = base.split("-")
157
+ if len(parts) < 5:
158
+ return False
159
+ py_tag, abi_tag, plat_tag = parts[-3], parts[-2], parts[-1]
160
+ wheel_tag = f"{py_tag}-{abi_tag}-{plat_tag}"
161
+
162
+ # Pure Python wheels are always compatible
163
+ if "none-any" in wheel_tag:
164
+ return True
165
+
166
+ return wheel_tag in supported_tags
167
+ except Exception:
168
+ return False
@@ -0,0 +1,123 @@
1
+ import subprocess
2
+ import sys
3
+ from .environment import check_python_compatibility, check_platform_compatibility
4
+ from .resolver import resolve_conflicts
5
+ from .build import check_and_fix_build_deps
6
+ from .diagnostics import verify_install
7
+
8
+
9
+ def install(package: str, version: str = None, verbose: bool = False) -> dict:
10
+ """
11
+ Full peep install pipeline for a single package.
12
+ Runs all checks, auto-fixes what it can, then delegates to pip.
13
+ Returns a full report of what happened.
14
+ """
15
+ report = {
16
+ "package": package,
17
+ "version": version,
18
+ "steps": {},
19
+ "success": False,
20
+ "final_version": None,
21
+ }
22
+
23
+ target = f"{package}=={version}" if version else package
24
+ resolved_version = version # May be updated by compatibility checks
25
+
26
+ # ── Step 1: Python version compatibility ─────────────────────────────────
27
+ py_check = check_python_compatibility(package, version)
28
+ report["steps"]["python_compatibility"] = py_check
29
+
30
+ if py_check.get("compatible") is False:
31
+ suggested = py_check.get("suggested_version")
32
+ if suggested:
33
+ resolved_version = suggested
34
+ target = f"{package}=={suggested}"
35
+ report["steps"]["python_compatibility"]["action"] = f"Switched to {package}=={suggested}"
36
+ else:
37
+ report["error"] = f"{package} has no version compatible with Python {py_check['current_python']}"
38
+ return report
39
+
40
+ # ── Step 2: Platform compatibility ───────────────────────────────────────
41
+ plat_check = check_platform_compatibility(package, resolved_version)
42
+ report["steps"]["platform_compatibility"] = plat_check
43
+
44
+ needs_compilation = plat_check.get("needs_compilation", False)
45
+
46
+ if not plat_check.get("has_compatible_wheel") and not plat_check.get("has_sdist"):
47
+ report["error"] = f"No compatible distribution found for {package} on {plat_check.get('platform')}/{plat_check.get('machine')}"
48
+ return report
49
+
50
+ # ── Step 3: Dependency conflict resolution ────────────────────────────────
51
+ conflict_check = resolve_conflicts(package, resolved_version)
52
+ report["steps"]["dependency_resolution"] = conflict_check
53
+
54
+ if not conflict_check["clean"]:
55
+ report["error"] = f"Unresolvable dependency conflicts for {package}: {conflict_check['conflicts']}"
56
+ return report
57
+
58
+ # Silently pre-install or upgrade conflicting deps before the main package
59
+ for dep in conflict_check["to_install"] + conflict_check["to_upgrade"]:
60
+ dep_target = f"{dep['package']}=={dep.get('version') or dep.get('to')}" if dep.get("version") or dep.get("to") else dep["package"]
61
+ _run_pip(["install", dep_target], verbose=verbose)
62
+
63
+ # ── Step 4: Build dependency check + fix ─────────────────────────────────
64
+ build_check = check_and_fix_build_deps(package, needs_compilation)
65
+ report["steps"]["build_deps"] = build_check
66
+
67
+ if build_check.get("failed"):
68
+ hints = [f["hint"] for f in build_check["failed"] if f.get("hint")]
69
+ report["error"] = (
70
+ f"Could not auto-install build dependencies: "
71
+ f"{[f['dep'] for f in build_check['failed']]}. "
72
+ f"Manual steps: {hints}"
73
+ )
74
+ return report
75
+
76
+ # ── Step 5: Run pip ───────────────────────────────────────────────────────
77
+ pip_result = _run_pip(["install", target], verbose=verbose)
78
+ report["steps"]["pip"] = pip_result
79
+
80
+ if pip_result["returncode"] != 0:
81
+ # One retry: let pip attempt its own conflict resolution
82
+ retry_result = _run_pip(["install", target, "--upgrade"], verbose=verbose)
83
+ report["steps"]["pip_retry"] = retry_result
84
+
85
+ if retry_result["returncode"] != 0:
86
+ report["error"] = f"pip failed:\n{retry_result['stderr']}"
87
+ return report
88
+
89
+ # ── Step 6: Post-install verification ─────────────────────────────────────
90
+ verify = verify_install(package)
91
+ report["steps"]["verification"] = verify
92
+
93
+ if not verify["importable"]:
94
+ report["error"] = f"Package installed but failed to import: {verify.get('error')}"
95
+ return report
96
+
97
+ report["success"] = True
98
+ report["final_version"] = verify.get("version") or resolved_version
99
+ return report
100
+
101
+
102
+ def install_many(packages: list[str], verbose: bool = False) -> list[dict]:
103
+ """Install multiple packages, collecting a report for each."""
104
+ return [install(pkg, verbose=verbose) for pkg in packages]
105
+
106
+
107
+ def _run_pip(args: list[str], verbose: bool = False) -> dict:
108
+ """Shell out to pip with the given args. Returns returncode, stdout, stderr."""
109
+ cmd = [sys.executable, "-m", "pip"] + args
110
+
111
+ if not verbose:
112
+ args_with_quiet = cmd + ["-q"]
113
+ else:
114
+ args_with_quiet = cmd
115
+
116
+ result = subprocess.run(args_with_quiet, capture_output=True, text=True)
117
+
118
+ return {
119
+ "command": " ".join(args_with_quiet),
120
+ "returncode": result.returncode,
121
+ "stdout": result.stdout.strip(),
122
+ "stderr": result.stderr.strip(),
123
+ }
@@ -0,0 +1,131 @@
1
+ import subprocess
2
+ import sys
3
+ from packaging.version import Version
4
+ from packaging.specifiers import SpecifierSet
5
+ from packaging.requirements import Requirement
6
+ from .environment import fetch_package_metadata
7
+
8
+
9
+ def get_installed_packages() -> dict[str, Version]:
10
+ """Return a dict of {package_name: installed_version} for the current environment."""
11
+ try:
12
+ result = subprocess.run(
13
+ [sys.executable, "-m", "pip", "list", "--format=freeze"],
14
+ capture_output=True, text=True
15
+ )
16
+ packages = {}
17
+ for line in result.stdout.splitlines():
18
+ if "==" in line:
19
+ name, ver = line.split("==", 1)
20
+ try:
21
+ packages[name.lower().strip()] = Version(ver.strip())
22
+ except Exception:
23
+ continue
24
+ return packages
25
+ except Exception:
26
+ return {}
27
+
28
+
29
+ def fetch_dependencies(package_name: str, version: str = None) -> list[Requirement]:
30
+ """Fetch the dependency list for a package from PyPI metadata."""
31
+ metadata = fetch_package_metadata(package_name, version)
32
+ if not metadata:
33
+ return []
34
+
35
+ requires_dist = metadata["info"].get("requires_dist") or []
36
+ deps = []
37
+
38
+ for dep_str in requires_dist:
39
+ try:
40
+ req = Requirement(dep_str)
41
+ # Skip extras/optional dependencies (e.g. dev, test, docs)
42
+ if not req.marker or req.marker.evaluate({"extra": ""}):
43
+ deps.append(req)
44
+ except Exception:
45
+ continue
46
+
47
+ return deps
48
+
49
+
50
+ def get_all_versions(package_name: str) -> list[Version]:
51
+ """Fetch all available versions of a package from PyPI."""
52
+ metadata = fetch_package_metadata(package_name)
53
+ if not metadata:
54
+ return []
55
+
56
+ versions = []
57
+ for ver_str in metadata["releases"].keys():
58
+ try:
59
+ versions.append(Version(ver_str))
60
+ except Exception:
61
+ continue
62
+
63
+ return sorted(versions, reverse=True)
64
+
65
+
66
+ def resolve_conflicts(package_name: str, requested_version: str = None) -> dict:
67
+ """
68
+ Check the full dependency tree of a package against the current environment.
69
+ Returns a resolution plan: what to install, what to upgrade, what conflicts exist.
70
+ """
71
+ installed = get_installed_packages()
72
+ deps = fetch_dependencies(package_name, requested_version)
73
+
74
+ to_install = []
75
+ to_upgrade = []
76
+ conflicts = []
77
+
78
+ for req in deps:
79
+ name = req.name.lower()
80
+ specifier = req.specifier # e.g. SpecifierSet(">=1.0,<2.0")
81
+
82
+ if name not in installed:
83
+ # Not installed at all — find best version to install
84
+ best = _best_version(name, specifier)
85
+ to_install.append({"package": req.name, "version": str(best) if best else None, "reason": "not installed"})
86
+
87
+ else:
88
+ current = installed[name]
89
+ if current in specifier:
90
+ # Already installed and compatible — nothing to do
91
+ continue
92
+ else:
93
+ # Installed but wrong version
94
+ best = _best_version(name, specifier)
95
+ if best:
96
+ to_upgrade.append({
97
+ "package": req.name,
98
+ "from": str(current),
99
+ "to": str(best),
100
+ "required_by": package_name,
101
+ "specifier": str(specifier),
102
+ })
103
+ else:
104
+ conflicts.append({
105
+ "package": req.name,
106
+ "installed": str(current),
107
+ "required": str(specifier),
108
+ "required_by": package_name,
109
+ "resolvable": False,
110
+ })
111
+
112
+ return {
113
+ "package": package_name,
114
+ "version": requested_version,
115
+ "to_install": to_install,
116
+ "to_upgrade": to_upgrade,
117
+ "conflicts": conflicts,
118
+ "clean": len(conflicts) == 0,
119
+ }
120
+
121
+
122
+ def _best_version(package_name: str, specifier: SpecifierSet) -> Version | None:
123
+ """Find the latest version of a package that satisfies the given specifier."""
124
+ versions = get_all_versions(package_name)
125
+ for v in versions:
126
+ # Skip pre-releases unless specifier explicitly allows them
127
+ if v.is_prerelease:
128
+ continue
129
+ if v in specifier:
130
+ return v
131
+ return None
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: peep-install
3
+ Version: 0.1.0
4
+ Summary: A smarter pip that auto-resolves version conflicts, build errors, and platform issues
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: requests>=2.28.0
7
+ Requires-Dist: packaging>=23.0
@@ -0,0 +1,14 @@
1
+ pyproject.toml
2
+ peep/__init__.py
3
+ peep/build.py
4
+ peep/cli.py
5
+ peep/diagnostics.py
6
+ peep/environment.py
7
+ peep/installer.py
8
+ peep/resolver.py
9
+ peep_install.egg-info/PKG-INFO
10
+ peep_install.egg-info/SOURCES.txt
11
+ peep_install.egg-info/dependency_links.txt
12
+ peep_install.egg-info/entry_points.txt
13
+ peep_install.egg-info/requires.txt
14
+ peep_install.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ peep = peep.cli:main
@@ -0,0 +1,2 @@
1
+ requests>=2.28.0
2
+ packaging>=23.0
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "peep-install"
7
+ version = "0.1.0"
8
+ description = "A smarter pip that auto-resolves version conflicts, build errors, and platform issues"
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "requests>=2.28.0",
12
+ "packaging>=23.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ peep = "peep.cli:main"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["."]
20
+ include = ["peep*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+