pyfirstaid 0.1.0__py3-none-any.whl

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.
pyfirstaid/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """pyfirstaid: first aid for broken Python environments."""
2
+
3
+ __version__ = "0.1.0"
pyfirstaid/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from pyfirstaid.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,14 @@
1
+ """Registry of all checks. Add new checks to ALL_CHECKS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from pyfirstaid.checks import pip_mismatch, ssl_check, venv
8
+ from pyfirstaid.model import Check
9
+
10
+ ALL_CHECKS: List[Check] = [
11
+ Check(venv.CHECK_ID, "Virtual environment", venv.run_check),
12
+ Check(pip_mismatch.CHECK_ID, "pip / python match", pip_mismatch.run_check),
13
+ Check(ssl_check.CHECK_ID, "SSL / HTTPS to PyPI", ssl_check.run_check),
14
+ ]
@@ -0,0 +1,86 @@
1
+ """Check: does the `pip` command install into the Python you are running?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import site
8
+ import sys
9
+ from typing import List, Optional, Tuple
10
+
11
+ from pyfirstaid.model import Finding, Options, Status
12
+ from pyfirstaid.util import is_within, run
13
+
14
+ CHECK_ID = "pip-mismatch"
15
+
16
+ _PIP_RE = re.compile(r"^pip (?P<ver>\S+) from (?P<loc>.+?) \(python (?P<py>\d+\.\d+)\)\s*$")
17
+
18
+
19
+ def parse_pip_version(output: str) -> Optional[Tuple[str, str, str]]:
20
+ """Parse `pip --version` output into (pip_version, location, python_version)."""
21
+ for line in output.splitlines():
22
+ m = _PIP_RE.match(line.strip())
23
+ if m:
24
+ return m.group("ver"), m.group("loc"), m.group("py")
25
+ return None
26
+
27
+
28
+ def pip_belongs_here(location: str, pip_python: str, running_python: str,
29
+ prefixes: List[str]) -> bool:
30
+ """True if a pip install at `location` serves the running interpreter."""
31
+ if pip_python != running_python:
32
+ return False
33
+ return any(is_within(location, p) for p in prefixes if p)
34
+
35
+
36
+ def _allowed_prefixes() -> List[str]:
37
+ prefixes = [sys.prefix]
38
+ if site.ENABLE_USER_SITE:
39
+ user_site = site.getusersitepackages()
40
+ if isinstance(user_site, str):
41
+ prefixes.append(user_site)
42
+ return prefixes
43
+
44
+
45
+ def run_check(opts: Options) -> List[Finding]:
46
+ running = "%d.%d" % sys.version_info[:2]
47
+
48
+ code, out, err = run([sys.executable, "-m", "pip", "--version"], opts.timeout)
49
+ if code != 0:
50
+ return [Finding(
51
+ CHECK_ID, Status.ERROR,
52
+ "pip is not installed for this Python",
53
+ detail=(err or out)[-300:],
54
+ fix="python -m ensurepip --upgrade",
55
+ )]
56
+
57
+ pip_cmd = shutil.which("pip") or shutil.which("pip3")
58
+ if not pip_cmd:
59
+ return [Finding(
60
+ CHECK_ID, Status.INFO,
61
+ "No `pip` command on PATH (python -m pip works)",
62
+ fix="Use `python -m pip install <package>`. It always targets the right Python.",
63
+ )]
64
+
65
+ code, out, err = run([pip_cmd, "--version"], opts.timeout)
66
+ parsed = parse_pip_version(out) if code == 0 else None
67
+ if not parsed:
68
+ return [Finding(
69
+ CHECK_ID, Status.WARN,
70
+ "The `pip` command on PATH is broken",
71
+ detail="%s\n%s" % (pip_cmd, (err or out)[-300:]),
72
+ fix="python -m pip install --force-reinstall pip",
73
+ )]
74
+
75
+ _, location, pip_python = parsed
76
+ if pip_belongs_here(location, pip_python, running, _allowed_prefixes()):
77
+ return [Finding(CHECK_ID, Status.OK, "`pip` installs into this Python")]
78
+
79
+ return [Finding(
80
+ CHECK_ID, Status.ERROR,
81
+ "`pip` installs into a DIFFERENT Python",
82
+ detail="pip -> %s (python %s)\npython -> %s (python %s)" % (
83
+ location, pip_python, sys.executable, running),
84
+ fix="Use `python -m pip install <package>` instead of `pip install`. "
85
+ "If a virtual environment should be active, activate it first.",
86
+ )]
@@ -0,0 +1,123 @@
1
+ """Check: can this Python make verified HTTPS connections to PyPI?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from typing import List
8
+
9
+ from pyfirstaid.model import Finding, Options, Status
10
+
11
+ CHECK_ID = "ssl"
12
+
13
+ CERT_ENV_VARS = ("SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE",
14
+ "CURL_CA_BUNDLE", "PIP_CERT")
15
+ PROXY_ENV_VARS = ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy")
16
+ TEST_URL = "https://pypi.org/simple/pip/"
17
+
18
+ CORPORATE_FIX = (
19
+ "If you are behind a company proxy or VPN, it is probably inspecting HTTPS traffic. "
20
+ "Ask IT for the company root certificate (.pem), then run: "
21
+ "python -m pip config set global.cert /path/to/company-ca.pem "
22
+ "(also: set SSL_CERT_FILE=/path/to/company-ca.pem). "
23
+ "Upgrading pip can also help: python -m pip install --upgrade pip"
24
+ )
25
+
26
+
27
+ def bad_cert_env_vars(environ=os.environ) -> List[str]:
28
+ """Return env vars that point at certificate files/dirs that do not exist."""
29
+ return [name for name in CERT_ENV_VARS
30
+ if environ.get(name) and not os.path.exists(environ[name])]
31
+
32
+
33
+ def classify_error(exc: BaseException) -> str:
34
+ """Map a connection exception to 'cert', 'ssl' or 'network'."""
35
+ import ssl
36
+
37
+ reason = getattr(exc, "reason", exc)
38
+ if (isinstance(reason, ssl.SSLCertVerificationError)
39
+ or "CERTIFICATE_VERIFY_FAILED" in str(reason)):
40
+ return "cert"
41
+ if isinstance(reason, ssl.SSLError):
42
+ return "ssl"
43
+ return "network"
44
+
45
+
46
+ def run_check(opts: Options) -> List[Finding]:
47
+ try:
48
+ import ssl
49
+ except ImportError:
50
+ return [Finding(
51
+ CHECK_ID, Status.ERROR, "This Python was built WITHOUT SSL support",
52
+ detail="`import ssl` failed, so pip cannot download anything.",
53
+ fix="Reinstall Python from python.org or your package manager "
54
+ "(if you compiled it yourself, install the OpenSSL dev package first).",
55
+ )]
56
+
57
+ findings: List[Finding] = []
58
+ for name in bad_cert_env_vars():
59
+ findings.append(Finding(
60
+ CHECK_ID, Status.ERROR, "%s points to a file that does not exist" % name,
61
+ detail="%s=%s" % (name, os.environ[name]),
62
+ fix="Fix the path, or remove the variable (unset %s)." % name,
63
+ ))
64
+
65
+ paths = ssl.get_default_verify_paths()
66
+ no_ca = not ((paths.cafile and os.path.exists(paths.cafile))
67
+ or (paths.capath and os.path.isdir(paths.capath) and os.listdir(paths.capath)))
68
+ if sys.platform == "darwin" and no_ca and not os.environ.get("SSL_CERT_FILE"):
69
+ findings.append(Finding(
70
+ CHECK_ID, Status.WARN, "No CA certificates configured for this macOS Python",
71
+ detail="python.org installers on macOS need a one-time certificate install.",
72
+ fix='Run: open "/Applications/Python %d.%d/Install Certificates.command"'
73
+ % sys.version_info[:2],
74
+ ))
75
+
76
+ if opts.offline:
77
+ findings.append(Finding(CHECK_ID, Status.SKIP,
78
+ "Skipped connection test to pypi.org (--offline)"))
79
+ return findings
80
+
81
+ import urllib.error
82
+ import urllib.request
83
+
84
+ try:
85
+ req = urllib.request.Request(TEST_URL, method="HEAD",
86
+ headers={"User-Agent": "pyfirstaid"})
87
+ with urllib.request.urlopen(req, timeout=opts.timeout,
88
+ context=ssl.create_default_context()):
89
+ pass
90
+ findings.append(Finding(CHECK_ID, Status.OK,
91
+ "HTTPS to pypi.org works (%s)" % ssl.OPENSSL_VERSION))
92
+ except urllib.error.HTTPError as exc:
93
+ # The TLS handshake and certificate check succeeded; the server (or a
94
+ # proxy) answered with an HTTP error code.
95
+ findings.append(Finding(
96
+ CHECK_ID, Status.INFO,
97
+ "SSL certificates OK, but pypi.org answered HTTP %s" % exc.code,
98
+ detail="A proxy or firewall may be blocking PyPI." if exc.code in (403, 407) else "",
99
+ fix="If pip installs fail, check your proxy settings or ask IT to allow pypi.org "
100
+ "and files.pythonhosted.org.",
101
+ ))
102
+ except Exception as exc: # noqa: BLE001 - we classify every failure
103
+ kind = classify_error(exc)
104
+ if kind == "cert":
105
+ findings.append(Finding(
106
+ CHECK_ID, Status.ERROR, "SSL certificate verification FAILED for pypi.org",
107
+ detail=str(exc)[:300], fix=CORPORATE_FIX,
108
+ ))
109
+ elif kind == "ssl":
110
+ findings.append(Finding(
111
+ CHECK_ID, Status.ERROR, "SSL error connecting to pypi.org",
112
+ detail=str(exc)[:300], fix=CORPORATE_FIX,
113
+ ))
114
+ else:
115
+ proxies = [v for v in PROXY_ENV_VARS if os.environ.get(v)]
116
+ findings.append(Finding(
117
+ CHECK_ID, Status.WARN, "Could not reach pypi.org",
118
+ detail=str(exc)[:300] + (
119
+ "\nProxy variables set: %s" % ", ".join(proxies) if proxies else ""),
120
+ fix="Check your internet connection, VPN or proxy settings. "
121
+ "Use --offline to skip this test.",
122
+ ))
123
+ return findings
@@ -0,0 +1,82 @@
1
+ """Check: is a virtual environment active, and is it the right one?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import sysconfig
8
+ from typing import List, Optional
9
+
10
+ from pyfirstaid.model import Finding, Options, Status
11
+ from pyfirstaid.util import in_virtualenv, norm
12
+
13
+ CHECK_ID = "venv"
14
+
15
+ CANDIDATE_DIRS = (".venv", "venv", "env", ".env")
16
+
17
+
18
+ def find_unused_venv(cwd: str) -> Optional[str]:
19
+ """Return the path of a virtual environment folder in `cwd`, if any."""
20
+ for name in CANDIDATE_DIRS:
21
+ path = os.path.join(cwd, name)
22
+ if os.path.isfile(os.path.join(path, "pyvenv.cfg")):
23
+ return path
24
+ return None
25
+
26
+
27
+ def activate_command(venv_path: str) -> str:
28
+ if os.name == "nt":
29
+ return r"%s\Scripts\activate" % venv_path
30
+ return "source %s/bin/activate" % venv_path
31
+
32
+
33
+ def is_externally_managed() -> bool:
34
+ stdlib = sysconfig.get_paths().get("stdlib", "")
35
+ return bool(stdlib) and os.path.isfile(os.path.join(stdlib, "EXTERNALLY-MANAGED"))
36
+
37
+
38
+ def run_check(opts: Options) -> List[Finding]:
39
+ findings: List[Finding] = []
40
+ cwd = opts.cwd or os.getcwd()
41
+ activated = os.environ.get("VIRTUAL_ENV")
42
+ conda = os.environ.get("CONDA_PREFIX")
43
+
44
+ if in_virtualenv():
45
+ findings.append(Finding(CHECK_ID, Status.OK,
46
+ "Virtual environment active: %s" % sys.prefix))
47
+ elif conda and norm(conda) == norm(sys.prefix):
48
+ findings.append(Finding(
49
+ CHECK_ID, Status.INFO, "Running in a conda environment: %s" % sys.prefix,
50
+ fix="For conda-specific checks, also run `conda doctor`.",
51
+ ))
52
+ else:
53
+ unused = find_unused_venv(cwd)
54
+ if unused:
55
+ findings.append(Finding(
56
+ CHECK_ID, Status.WARN,
57
+ "Found a virtual environment that is NOT being used",
58
+ detail="%s exists, but this Python is %s" % (unused, sys.executable),
59
+ fix=activate_command(os.path.relpath(unused, cwd)),
60
+ ))
61
+ elif is_externally_managed():
62
+ findings.append(Finding(
63
+ CHECK_ID, Status.WARN,
64
+ "No virtual environment, and this system Python blocks pip installs",
65
+ detail="Your OS marks this Python as externally managed (PEP 668), "
66
+ "so `pip install` will fail with 'externally-managed-environment'.",
67
+ fix="python -m venv .venv && " + activate_command(".venv"),
68
+ ))
69
+ else:
70
+ findings.append(Finding(
71
+ CHECK_ID, Status.INFO, "No virtual environment active",
72
+ fix="Recommended: python -m venv .venv && " + activate_command(".venv"),
73
+ ))
74
+
75
+ if activated and norm(activated) != norm(sys.prefix):
76
+ findings.append(Finding(
77
+ CHECK_ID, Status.WARN,
78
+ "Your shell activated a DIFFERENT virtual environment",
79
+ detail="activated: %s\nrunning: %s" % (activated, sys.prefix),
80
+ fix="Run `deactivate`, then activate the environment you meant to use.",
81
+ ))
82
+ return findings
pyfirstaid/cli.py ADDED
@@ -0,0 +1,97 @@
1
+ """Command-line entry point: `python -m pyfirstaid` or `pyfirstaid`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import traceback
9
+ from typing import List, Optional
10
+
11
+ from pyfirstaid import __version__
12
+ from pyfirstaid.checks import ALL_CHECKS
13
+ from pyfirstaid.model import Check, Finding, Options, Status
14
+ from pyfirstaid.report import render_json, render_text
15
+
16
+ EXIT_OK, EXIT_PROBLEMS, EXIT_INTERNAL = 0, 1, 2
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ p = argparse.ArgumentParser(
21
+ prog="pyfirstaid",
22
+ description="First aid for broken Python environments: "
23
+ "finds what's wrong and tells you how to fix it.",
24
+ )
25
+ p.add_argument("--json", action="store_true", help="output machine-readable JSON")
26
+ p.add_argument("--share", action="store_true",
27
+ help="hide your username and home folder so the report is safe to share")
28
+ p.add_argument("--offline", action="store_true", help="skip checks that need the internet")
29
+ p.add_argument("--strict", action="store_true", help="exit with code 1 on warnings too")
30
+ p.add_argument("--only", metavar="IDS", help="comma-separated check ids to run")
31
+ p.add_argument("--skip", metavar="IDS", help="comma-separated check ids to skip")
32
+ p.add_argument("--list", action="store_true", help="list available checks and exit")
33
+ p.add_argument("--no-color", action="store_true", help="disable colored output")
34
+ p.add_argument("--version", action="version", version="pyfirstaid " + __version__)
35
+ return p
36
+
37
+
38
+ def select_checks(only: Optional[str], skip: Optional[str]) -> List[Check]:
39
+ checks = list(ALL_CHECKS)
40
+ if only:
41
+ wanted = {s.strip() for s in only.split(",") if s.strip()}
42
+ checks = [c for c in checks if c.id in wanted]
43
+ if skip:
44
+ unwanted = {s.strip() for s in skip.split(",") if s.strip()}
45
+ checks = [c for c in checks if c.id not in unwanted]
46
+ return checks
47
+
48
+
49
+ def run_checks(checks: List[Check], opts: Options) -> List[Finding]:
50
+ findings: List[Finding] = []
51
+ for check in checks:
52
+ try:
53
+ findings.extend(check.run(opts))
54
+ except Exception as exc: # noqa: BLE001 - a broken check must never crash the tool
55
+ findings.append(Finding(
56
+ check.id, Status.SKIP, "%s: check could not run" % check.title,
57
+ detail="%s: %s" % (type(exc).__name__, exc),
58
+ fix="Please report this at https://github.com/sai-sakalam/pyfirstaid/issues",
59
+ ))
60
+ return findings
61
+
62
+
63
+ def use_color(no_color: bool) -> bool:
64
+ if no_color or os.environ.get("NO_COLOR"):
65
+ return False
66
+ return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() and os.name != "nt" \
67
+ or bool(os.environ.get("WT_SESSION")) # Windows Terminal supports ANSI
68
+
69
+
70
+ def main(argv: Optional[List[str]] = None) -> int:
71
+ args = build_parser().parse_args(argv)
72
+
73
+ if args.list:
74
+ for c in ALL_CHECKS:
75
+ print("%-14s %s" % (c.id, c.title))
76
+ return EXIT_OK
77
+
78
+ try:
79
+ findings = run_checks(select_checks(args.only, args.skip), Options(offline=args.offline))
80
+ if args.json:
81
+ print(render_json(findings, share=args.share))
82
+ else:
83
+ print(render_text(findings, color=use_color(args.no_color), share=args.share))
84
+ except Exception: # noqa: BLE001
85
+ traceback.print_exc()
86
+ return EXIT_INTERNAL
87
+
88
+ if any(f.status == Status.ERROR for f in findings):
89
+ return EXIT_PROBLEMS
90
+ if args.strict and any(f.status == Status.WARN for f in findings):
91
+ return EXIT_PROBLEMS
92
+ return EXIT_OK
93
+
94
+
95
+ def _run() -> None:
96
+ """Entry point for the single-file pyfirstaid.pyz."""
97
+ sys.exit(main())
pyfirstaid/model.py ADDED
@@ -0,0 +1,43 @@
1
+ """Data model shared by all checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from enum import Enum
7
+ from typing import Callable, List, Optional
8
+
9
+
10
+ class Status(str, Enum):
11
+ OK = "ok"
12
+ INFO = "info"
13
+ WARN = "warn"
14
+ ERROR = "error"
15
+ SKIP = "skip"
16
+
17
+
18
+ @dataclass
19
+ class Finding:
20
+ check: str
21
+ status: Status
22
+ title: str
23
+ detail: str = ""
24
+ fix: str = ""
25
+
26
+ def to_dict(self) -> dict:
27
+ d = asdict(self)
28
+ d["status"] = self.status.value
29
+ return d
30
+
31
+
32
+ @dataclass
33
+ class Check:
34
+ id: str
35
+ title: str
36
+ run: Callable[["Options"], List[Finding]]
37
+
38
+
39
+ @dataclass
40
+ class Options:
41
+ offline: bool = False
42
+ timeout: float = 8.0
43
+ cwd: Optional[str] = None
pyfirstaid/report.py ADDED
@@ -0,0 +1,107 @@
1
+ """Render findings as text or JSON, with optional privacy redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import json
7
+ import os
8
+ import platform
9
+ import sys
10
+ from typing import Dict, List
11
+
12
+ from pyfirstaid import __version__
13
+ from pyfirstaid.model import Finding, Status
14
+
15
+ SYMBOLS = {
16
+ Status.OK: ("✔", "[ok]"),
17
+ Status.INFO: ("i", "[i]"),
18
+ Status.WARN: ("!", "[!]"),
19
+ Status.ERROR: ("✘", "[x]"),
20
+ Status.SKIP: ("-", "[-]"),
21
+ }
22
+ COLORS = {Status.OK: "32", Status.INFO: "36", Status.WARN: "33",
23
+ Status.ERROR: "31", Status.SKIP: "90"}
24
+
25
+
26
+ def redact(text: str) -> str:
27
+ """Hide the home directory and username so reports are safe to share."""
28
+ if not text:
29
+ return text
30
+ home = os.path.expanduser("~")
31
+ if home and home not in ("~", "/"):
32
+ text = text.replace(home, "~")
33
+ try:
34
+ user = getpass.getuser()
35
+ except Exception: # noqa: BLE001 - getuser can fail in odd environments
36
+ user = ""
37
+ if user and len(user) > 2:
38
+ text = text.replace(user, "<user>")
39
+ return text
40
+
41
+
42
+ def environment_summary() -> Dict[str, str]:
43
+ return {
44
+ "python": platform.python_version(),
45
+ "implementation": platform.python_implementation(),
46
+ "executable": sys.executable,
47
+ "prefix": sys.prefix,
48
+ "platform": platform.platform(),
49
+ }
50
+
51
+
52
+ def _can_encode(s: str) -> bool:
53
+ enc = getattr(sys.stdout, "encoding", None) or "ascii"
54
+ try:
55
+ s.encode(enc)
56
+ return True
57
+ except (UnicodeEncodeError, LookupError):
58
+ return False
59
+
60
+
61
+ def summarize(findings: List[Finding]) -> Dict[str, int]:
62
+ return {s.value: sum(1 for f in findings if f.status == s) for s in Status}
63
+
64
+
65
+ def render_json(findings: List[Finding], share: bool = False) -> str:
66
+ data = {
67
+ "tool": "pyfirstaid",
68
+ "version": __version__,
69
+ "environment": environment_summary(),
70
+ "summary": summarize(findings),
71
+ "findings": [f.to_dict() for f in findings],
72
+ }
73
+ text = json.dumps(data, indent=2, ensure_ascii=False)
74
+ return redact(text) if share else text
75
+
76
+
77
+ def render_text(findings: List[Finding], color: bool = False, share: bool = False) -> str:
78
+ unicode_ok = _can_encode("✔✘")
79
+
80
+ def paint(status: Status, s: str) -> str:
81
+ return "\033[%sm%s\033[0m" % (COLORS[status], s) if color else s
82
+
83
+ env = environment_summary()
84
+ lines = [
85
+ "pyfirstaid %s: checking your Python environment" % __version__,
86
+ "Python %s (%s)" % (env["python"], env["executable"]),
87
+ "",
88
+ ]
89
+ for f in findings:
90
+ sym = SYMBOLS[f.status][0 if unicode_ok else 1]
91
+ lines.append("%s %s" % (paint(f.status, sym), f.title))
92
+ for d in filter(None, f.detail.splitlines()):
93
+ lines.append(" " + d)
94
+ if f.fix:
95
+ lines.append(" fix: " + f.fix)
96
+
97
+ counts = summarize(findings)
98
+ lines.append("")
99
+ if counts["error"] == 0 and counts["warn"] == 0:
100
+ lines.append(paint(Status.OK, "No problems found."))
101
+ else:
102
+ lines.append("%d problem(s), %d warning(s)." % (counts["error"], counts["warn"]))
103
+ if not share:
104
+ lines.append("Tip: run with --share for a privacy-safe report "
105
+ "you can paste into a bug report.")
106
+ text = "\n".join(lines)
107
+ return redact(text) if share else text
pyfirstaid/util.py ADDED
@@ -0,0 +1,51 @@
1
+ """Small helpers used by checks. Standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from typing import List, Optional, Tuple
9
+
10
+
11
+ def run(cmd: List[str], timeout: float = 15.0) -> Tuple[int, str, str]:
12
+ """Run a command and return (returncode, stdout, stderr).
13
+
14
+ Never raises: a missing executable or a timeout returns code 127 / 124.
15
+ """
16
+ try:
17
+ proc = subprocess.run(
18
+ cmd,
19
+ capture_output=True,
20
+ text=True,
21
+ timeout=timeout,
22
+ env={**os.environ, "PIP_DISABLE_PIP_VERSION_CHECK": "1"},
23
+ )
24
+ return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
25
+ except FileNotFoundError:
26
+ return 127, "", "executable not found"
27
+ except subprocess.TimeoutExpired:
28
+ return 124, "", "timed out after %ss" % timeout
29
+ except OSError as exc:
30
+ return 126, "", str(exc)
31
+
32
+
33
+ def norm(path: Optional[str]) -> str:
34
+ """Normalise a path for comparison (absolute, resolved case on Windows)."""
35
+ if not path:
36
+ return ""
37
+ return os.path.normcase(os.path.abspath(path))
38
+
39
+
40
+ def is_within(path: str, parent: str) -> bool:
41
+ path, parent = norm(path), norm(parent)
42
+ if not path or not parent:
43
+ return False
44
+ try:
45
+ return os.path.commonpath([path, parent]) == parent
46
+ except ValueError: # different drives on Windows
47
+ return False
48
+
49
+
50
+ def in_virtualenv() -> bool:
51
+ return sys.prefix != getattr(sys, "base_prefix", sys.prefix)
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyfirstaid
3
+ Version: 0.1.0
4
+ Summary: First aid for broken Python environments: finds what's wrong and tells you how to fix it.
5
+ Project-URL: Homepage, https://github.com/sai-sakalam/pyfirstaid
6
+ Project-URL: Issues, https://github.com/sai-sakalam/pyfirstaid/issues
7
+ Author: Sai Gavaskar Sakalam
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: diagnostics,environment,pip,ssl,troubleshooting,venv,virtualenv
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Debuggers
17
+ Classifier: Topic :: System :: Installation/Setup
18
+ Requires-Python: >=3.9
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7; extra == 'dev'
21
+ Requires-Dist: ruff>=0.5; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pyfirstaid 🩹
25
+ [![CI](https://github.com/sai-sakalam/pyfirstaid/actions/workflows/ci.yml/badge.svg)](https://github.com/sai-sakalam/pyfirstaid/actions/workflows/ci.yml)
26
+
27
+ **First aid for broken Python environments.** One command tells you *what's broken* and *exactly how to fix it*.
28
+
29
+ > **Status: v0.1, early.** 3 checks work today, and more are on the roadmap.
30
+ > Feedback is very welcome in [Issues](https://github.com/sai-sakalam/pyfirstaid/issues). *Which problems do you hit most?*
31
+
32
+ ---
33
+
34
+ ## Why
35
+
36
+ "It works on my machine" usually comes down to a broken environment: `pip` installing into a different Python than you run, SSL errors behind a corporate proxy, or a virtual environment that isn't active.
37
+
38
+ The errors are cryptic, the fixes are scattered across Stack Overflow, and there's no single command that checks it all.
39
+
40
+ This was discussed on the Python forum: [Standard Library Health Check Module](https://discuss.python.org/t/standard-library-health-check-module/105153). The advice there was to start it as a package on PyPI.
41
+
42
+ ## Example
43
+
44
+ A real run: the virtual environment's Python is running, but the `pip` command on PATH belongs to the system Python:
45
+
46
+ ```console
47
+ $ python -m pyfirstaid
48
+
49
+ pyfirstaid 0.1.0: checking your Python environment
50
+ Python 3.13.1 (~/project/.venv/bin/python)
51
+
52
+ ✔ Virtual environment active: ~/project/.venv
53
+ ✘ `pip` installs into a DIFFERENT Python
54
+ pip -> /usr/lib/python3/dist-packages/pip (python 3.13)
55
+ python -> ~/project/.venv/bin/python (python 3.13)
56
+ fix: Use `python -m pip install <package>` instead of `pip install`. If a virtual environment should be active, activate it first.
57
+ ✔ HTTPS to pypi.org works (OpenSSL 3.0.13 30 Jan 2024)
58
+
59
+ 1 problem(s), 0 warning(s).
60
+ ```
61
+
62
+ ## Try it
63
+
64
+ pyfirstaid is not on PyPI yet. To run it from source:
65
+
66
+ ```bash
67
+ git clone https://github.com/sai-sakalam/pyfirstaid
68
+ cd pyfirstaid
69
+ python -m pip install .
70
+ python -m pyfirstaid
71
+ ```
72
+
73
+ Or build the **single file**, which needs no install and works even when pip is broken:
74
+
75
+ ```bash
76
+ python scripts/build_pyz.py
77
+ python dist/pyfirstaid.pyz
78
+ ```
79
+
80
+ ### Options
81
+
82
+ | Option | What it does |
83
+ |---|---|
84
+ | `--share` | Hides your username and home folder, so the report is safe to paste into a bug report |
85
+ | `--json` | Machine-readable output for CI and scripts |
86
+ | `--offline` | Skips checks that need the internet |
87
+ | `--strict` | Exits with code 1 on warnings too (useful in CI) |
88
+ | `--only venv,ssl` / `--skip ssl` | Runs only some checks, or skips some |
89
+ | `--list` | Lists all checks |
90
+
91
+ Exit codes: `0` means no problems, `1` means problems were found, `2` means pyfirstaid itself failed.
92
+
93
+ ## Common situations
94
+
95
+ **"I installed a package, but `import` says it doesn't exist."**
96
+
97
+ python -m pyfirstaid --only pip-mismatch,venv
98
+
99
+ Usually `pip` installed into a different Python, or your virtual environment isn't active. pyfirstaid tells you which, and how to fix it.
100
+
101
+ **"pip install fails with SSL: CERTIFICATE_VERIFY_FAILED at work."**
102
+
103
+ python -m pyfirstaid --only ssl
104
+
105
+ This checks whether a company proxy is intercepting HTTPS and whether your certificate settings point at real files.
106
+
107
+ **"I'm reporting a bug and the maintainer asked for my environment details."**
108
+
109
+ python -m pyfirstaid --share
110
+
111
+ Paste the output into the issue. Your username and home folder are hidden.
112
+
113
+ **"I want CI to fail if the environment is broken."**
114
+
115
+ python -m pyfirstaid --offline --strict --json > env-report.json
116
+
117
+ The exit code is `1` if there are problems, and the JSON report can be saved as a build artifact.
118
+
119
+ **"pip itself is broken, so I can't install anything."**
120
+
121
+ Download `pyfirstaid.pyz` from the [latest release](https://github.com/sai-sakalam/pyfirstaid/releases) and run:
122
+
123
+ python pyfirstaid.pyz
124
+
125
+ ## Checks
126
+
127
+ | Status | Check | What it catches |
128
+ |---|---|---|
129
+ | ✅ v0.1 | `venv` | No venv active, an unused `.venv` in the folder, a *different* venv activated in your shell, a system Python that blocks pip (PEP 668) |
130
+ | ✅ v0.1 | `pip-mismatch` | `pip` installs into a different Python than the one you run, pip missing, a broken `pip` command |
131
+ | ✅ v0.1 | `ssl` | Certificate failures reaching PyPI (corporate proxies), certificate variables pointing at missing files, macOS certificates not installed, Python built without SSL |
132
+ | 🔜 planned | `compiled` | Compiled packages built for a different Python version |
133
+ | 🔜 planned | `broken-installs` | Duplicate or half-removed packages |
134
+ | 🔜 planned | `dependencies` | Dependency conflicts (`pip check`) |
135
+ | 🔜 planned | `path` | Several Pythons on PATH hiding each other |
136
+ | 🔜 planned | `leaks` | `PYTHONPATH` or user-site packages leaking into a venv |
137
+ | 🔜 planned | `permissions` | No write access to site-packages |
138
+ | 🔜 planned | `encoding` | Locale and encoding problems |
139
+
140
+ ## How is this different?
141
+
142
+ | Tool | What it does | Gap pyfirstaid fills |
143
+ |---|---|---|
144
+ | `pip check` | Finds dependency conflicts | Only covers one kind of problem |
145
+ | `conda doctor` | Health checks for conda environments | Doesn't cover pip, venv or uv |
146
+ | pymedic | Lists environment info (versions, packages) | Reports, but doesn't diagnose or suggest fixes |
147
+ | pyenv-doctor | Early-stage environment checks | Single release so far |
148
+ | env-repair | Repairs conda and pip environments | Changes your environment; pyfirstaid only diagnoses, safely |
149
+
150
+ **pyfirstaid's focus:** diagnose the problem, explain it in plain English, and give a copy-paste fix.
151
+
152
+ ## Design principles
153
+
154
+ - **Works when pip is broken.** It runs as a single file (`python pyfirstaid.pyz`). A PyPI release is coming.
155
+ - **Zero dependencies.** Standard library only, Python 3.9+.
156
+ - **Every problem comes with a fix command,** not just a description.
157
+ - **Conservative.** It's better to miss an edge case than to raise a false alarm.
158
+ - **Never crashes.** A check that fails internally is reported, and the rest still run.
159
+ - **Diagnose only.** It never changes your environment.
160
+
161
+ ## Scope
162
+
163
+ **In:** pip, venv and uv on Windows, macOS and Linux.
164
+ **Out (for now):** conda (use `conda doctor`), Poetry and pyenv specifics, automatic repair.
165
+
166
+ ## Contributing
167
+
168
+ - Hit an environment error? [Open an issue](https://github.com/sai-sakalam/pyfirstaid/issues) with the error message, the cause and the fix. Real cases decide which checks come next.
169
+ - Development: `python -m pip install -e ".[dev]"`, then `python -m pytest` and `ruff check src tests`.
170
+ - A new check is one file in `src/pyfirstaid/checks/` that returns a list of `Finding`s, registered in `checks/__init__.py`.
171
+
172
+ ## License
173
+
174
+ MIT
@@ -0,0 +1,15 @@
1
+ pyfirstaid/__init__.py,sha256=8Y2fnosuKOVOU_-gD9yw781V2Jww11ZQlpxmC31yoJU,83
2
+ pyfirstaid/__main__.py,sha256=qus5zBx3CArE8PQGMmumBuNo76lSIqgwniIr9h8HPkM,62
3
+ pyfirstaid/cli.py,sha256=7rtUymkg1s-o34g2xIXk37ySlaOBDG4Ckw-lLANPpTI,3733
4
+ pyfirstaid/model.py,sha256=W73tNH6Kp89rHNpyuk7Lnt494DuI5o0AE3ZR2_cSrXA,737
5
+ pyfirstaid/report.py,sha256=9HskRv-2zW0Yk2aXnO5t4fJWe9Plwsp3-rgE1XbBpvM,3323
6
+ pyfirstaid/util.py,sha256=bBfLbgBBTXZQPsg1rqr7f9BtOpEokdNd92EMxjbt5lA,1514
7
+ pyfirstaid/checks/__init__.py,sha256=Ugh2KTgcCK29vVIHtnsfB0PikqP0WO01oSwxdTB7SLU,468
8
+ pyfirstaid/checks/pip_mismatch.py,sha256=wuhrBFHjwXl592l3JA_GPEmyCdTtjYjIFcjFKi9OQhg,3017
9
+ pyfirstaid/checks/ssl_check.py,sha256=QBol38duu6s5hS9oQWC-wyS_HWD-XRTaIoir9oNSlWw,5203
10
+ pyfirstaid/checks/venv.py,sha256=3abpih7p-sG4r-fwdD38ud2cMp6BKMB5YoSZHU7b_cQ,3086
11
+ pyfirstaid-0.1.0.dist-info/METADATA,sha256=s8CYHoO91mq2ny1Twq0yD4jzTwTuFS8FsDVhBhggHQE,7468
12
+ pyfirstaid-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
13
+ pyfirstaid-0.1.0.dist-info/entry_points.txt,sha256=BFlXV8dGrE0mvq2ezVoW5KnEVrqnTpyJf5Ips_Cw1So,51
14
+ pyfirstaid-0.1.0.dist-info/licenses/LICENSE,sha256=9LK52_tXa2Xsaj9UGfwYN1i8ncapHRcy8_O4kO_N56U,1077
15
+ pyfirstaid-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyfirstaid = pyfirstaid.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sai Gavaskar Sakalam
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.