pyfirstaid 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,46 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest, macos-latest, windows-latest]
15
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ allow-prereleases: true
22
+ - name: Install
23
+ run: python -m pip install -e ".[dev]"
24
+ - name: Lint
25
+ run: ruff check src tests scripts
26
+ - name: Test
27
+ run: python -m pytest -q
28
+ - name: Run pyfirstaid on the CI machine (report only)
29
+ shell: bash
30
+ run: python -m pyfirstaid || true
31
+
32
+ pyz:
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - uses: actions/setup-python@v5
37
+ with:
38
+ python-version: "3.12"
39
+ - name: Build single-file pyfirstaid.pyz
40
+ run: python scripts/build_pyz.py
41
+ - name: Smoke test
42
+ run: python dist/pyfirstaid.pyz --offline || true
43
+ - uses: actions/upload-artifact@v4
44
+ with:
45
+ name: pyfirstaid.pyz
46
+ path: dist/pyfirstaid.pyz
@@ -0,0 +1,45 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+ inputs:
8
+ tag:
9
+ description: "Release tag to attach pyfirstaid.pyz to"
10
+ required: true
11
+ default: "v0.1.0"
12
+
13
+ jobs:
14
+ publish:
15
+ runs-on: ubuntu-latest
16
+ environment: pypi
17
+ permissions:
18
+ id-token: write
19
+ contents: write
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.12"
25
+
26
+ - name: Build package (wheel + sdist)
27
+ run: |
28
+ python -m pip install build
29
+ python -m build
30
+
31
+ - name: Build single-file pyfirstaid.pyz (kept OUT of dist/)
32
+ run: |
33
+ python scripts/build_pyz.py
34
+ mkdir -p pyz-out
35
+ mv dist/pyfirstaid.pyz pyz-out/
36
+
37
+ - name: Publish to PyPI
38
+ uses: pypa/gh-action-pypi-publish@release/v1
39
+ with:
40
+ packages-dir: dist/
41
+
42
+ - name: Attach pyfirstaid.pyz to the GitHub release
43
+ env:
44
+ GH_TOKEN: ${{ github.token }}
45
+ run: gh release upload "${{ github.event.release.tag_name || inputs.tag }}" pyz-out/pyfirstaid.pyz --clobber
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First version, with 3 checks:
6
+
7
+ - **venv**: is a virtual environment active, is an unused `.venv` sitting in the folder,
8
+ is a *different* venv activated in your shell, and does the system Python block pip (PEP 668)?
9
+ - **pip-mismatch**: does the `pip` command install into a different Python than the one you run?
10
+ - **ssl**: can Python reach pypi.org over HTTPS, and are certificate settings
11
+ (`SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `PIP_CERT`, macOS certificates) valid?
12
+
13
+ Options: `--json`, `--share`, `--offline`, `--strict`, `--only`, `--skip`, `--list`.
14
+ Single-file build: `python scripts/build_pyz.py` creates `dist/pyfirstaid.pyz`.
@@ -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.
@@ -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,151 @@
1
+ # pyfirstaid 🩹
2
+ [![CI](https://github.com/sai-sakalam/pyfirstaid/actions/workflows/ci.yml/badge.svg)](https://github.com/sai-sakalam/pyfirstaid/actions/workflows/ci.yml)
3
+
4
+ **First aid for broken Python environments.** One command tells you *what's broken* and *exactly how to fix it*.
5
+
6
+ > **Status: v0.1, early.** 3 checks work today, and more are on the roadmap.
7
+ > Feedback is very welcome in [Issues](https://github.com/sai-sakalam/pyfirstaid/issues). *Which problems do you hit most?*
8
+
9
+ ---
10
+
11
+ ## Why
12
+
13
+ "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.
14
+
15
+ The errors are cryptic, the fixes are scattered across Stack Overflow, and there's no single command that checks it all.
16
+
17
+ 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.
18
+
19
+ ## Example
20
+
21
+ A real run: the virtual environment's Python is running, but the `pip` command on PATH belongs to the system Python:
22
+
23
+ ```console
24
+ $ python -m pyfirstaid
25
+
26
+ pyfirstaid 0.1.0: checking your Python environment
27
+ Python 3.13.1 (~/project/.venv/bin/python)
28
+
29
+ ✔ Virtual environment active: ~/project/.venv
30
+ ✘ `pip` installs into a DIFFERENT Python
31
+ pip -> /usr/lib/python3/dist-packages/pip (python 3.13)
32
+ python -> ~/project/.venv/bin/python (python 3.13)
33
+ fix: Use `python -m pip install <package>` instead of `pip install`. If a virtual environment should be active, activate it first.
34
+ ✔ HTTPS to pypi.org works (OpenSSL 3.0.13 30 Jan 2024)
35
+
36
+ 1 problem(s), 0 warning(s).
37
+ ```
38
+
39
+ ## Try it
40
+
41
+ pyfirstaid is not on PyPI yet. To run it from source:
42
+
43
+ ```bash
44
+ git clone https://github.com/sai-sakalam/pyfirstaid
45
+ cd pyfirstaid
46
+ python -m pip install .
47
+ python -m pyfirstaid
48
+ ```
49
+
50
+ Or build the **single file**, which needs no install and works even when pip is broken:
51
+
52
+ ```bash
53
+ python scripts/build_pyz.py
54
+ python dist/pyfirstaid.pyz
55
+ ```
56
+
57
+ ### Options
58
+
59
+ | Option | What it does |
60
+ |---|---|
61
+ | `--share` | Hides your username and home folder, so the report is safe to paste into a bug report |
62
+ | `--json` | Machine-readable output for CI and scripts |
63
+ | `--offline` | Skips checks that need the internet |
64
+ | `--strict` | Exits with code 1 on warnings too (useful in CI) |
65
+ | `--only venv,ssl` / `--skip ssl` | Runs only some checks, or skips some |
66
+ | `--list` | Lists all checks |
67
+
68
+ Exit codes: `0` means no problems, `1` means problems were found, `2` means pyfirstaid itself failed.
69
+
70
+ ## Common situations
71
+
72
+ **"I installed a package, but `import` says it doesn't exist."**
73
+
74
+ python -m pyfirstaid --only pip-mismatch,venv
75
+
76
+ Usually `pip` installed into a different Python, or your virtual environment isn't active. pyfirstaid tells you which, and how to fix it.
77
+
78
+ **"pip install fails with SSL: CERTIFICATE_VERIFY_FAILED at work."**
79
+
80
+ python -m pyfirstaid --only ssl
81
+
82
+ This checks whether a company proxy is intercepting HTTPS and whether your certificate settings point at real files.
83
+
84
+ **"I'm reporting a bug and the maintainer asked for my environment details."**
85
+
86
+ python -m pyfirstaid --share
87
+
88
+ Paste the output into the issue. Your username and home folder are hidden.
89
+
90
+ **"I want CI to fail if the environment is broken."**
91
+
92
+ python -m pyfirstaid --offline --strict --json > env-report.json
93
+
94
+ The exit code is `1` if there are problems, and the JSON report can be saved as a build artifact.
95
+
96
+ **"pip itself is broken, so I can't install anything."**
97
+
98
+ Download `pyfirstaid.pyz` from the [latest release](https://github.com/sai-sakalam/pyfirstaid/releases) and run:
99
+
100
+ python pyfirstaid.pyz
101
+
102
+ ## Checks
103
+
104
+ | Status | Check | What it catches |
105
+ |---|---|---|
106
+ | ✅ 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) |
107
+ | ✅ v0.1 | `pip-mismatch` | `pip` installs into a different Python than the one you run, pip missing, a broken `pip` command |
108
+ | ✅ v0.1 | `ssl` | Certificate failures reaching PyPI (corporate proxies), certificate variables pointing at missing files, macOS certificates not installed, Python built without SSL |
109
+ | 🔜 planned | `compiled` | Compiled packages built for a different Python version |
110
+ | 🔜 planned | `broken-installs` | Duplicate or half-removed packages |
111
+ | 🔜 planned | `dependencies` | Dependency conflicts (`pip check`) |
112
+ | 🔜 planned | `path` | Several Pythons on PATH hiding each other |
113
+ | 🔜 planned | `leaks` | `PYTHONPATH` or user-site packages leaking into a venv |
114
+ | 🔜 planned | `permissions` | No write access to site-packages |
115
+ | 🔜 planned | `encoding` | Locale and encoding problems |
116
+
117
+ ## How is this different?
118
+
119
+ | Tool | What it does | Gap pyfirstaid fills |
120
+ |---|---|---|
121
+ | `pip check` | Finds dependency conflicts | Only covers one kind of problem |
122
+ | `conda doctor` | Health checks for conda environments | Doesn't cover pip, venv or uv |
123
+ | pymedic | Lists environment info (versions, packages) | Reports, but doesn't diagnose or suggest fixes |
124
+ | pyenv-doctor | Early-stage environment checks | Single release so far |
125
+ | env-repair | Repairs conda and pip environments | Changes your environment; pyfirstaid only diagnoses, safely |
126
+
127
+ **pyfirstaid's focus:** diagnose the problem, explain it in plain English, and give a copy-paste fix.
128
+
129
+ ## Design principles
130
+
131
+ - **Works when pip is broken.** It runs as a single file (`python pyfirstaid.pyz`). A PyPI release is coming.
132
+ - **Zero dependencies.** Standard library only, Python 3.9+.
133
+ - **Every problem comes with a fix command,** not just a description.
134
+ - **Conservative.** It's better to miss an edge case than to raise a false alarm.
135
+ - **Never crashes.** A check that fails internally is reported, and the rest still run.
136
+ - **Diagnose only.** It never changes your environment.
137
+
138
+ ## Scope
139
+
140
+ **In:** pip, venv and uv on Windows, macOS and Linux.
141
+ **Out (for now):** conda (use `conda doctor`), Poetry and pyenv specifics, automatic repair.
142
+
143
+ ## Contributing
144
+
145
+ - 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.
146
+ - Development: `python -m pip install -e ".[dev]"`, then `python -m pytest` and `ruff check src tests`.
147
+ - A new check is one file in `src/pyfirstaid/checks/` that returns a list of `Finding`s, registered in `checks/__init__.py`.
148
+
149
+ ## License
150
+
151
+ MIT
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pyfirstaid"
7
+ dynamic = ["version"]
8
+ description = "First aid for broken Python environments: finds what's wrong and tells you how to fix it."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "Sai Gavaskar Sakalam" }]
14
+ keywords = ["pip", "virtualenv", "venv", "diagnostics", "troubleshooting", "ssl", "environment"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Software Development :: Debuggers",
22
+ "Topic :: System :: Installation/Setup",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=7", "ruff>=0.5"]
28
+
29
+ [project.scripts]
30
+ pyfirstaid = "pyfirstaid.cli:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/sai-sakalam/pyfirstaid"
34
+ Issues = "https://github.com/sai-sakalam/pyfirstaid/issues"
35
+
36
+ [tool.hatch.version]
37
+ path = "src/pyfirstaid/__init__.py"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/pyfirstaid"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ pythonpath = ["src"]
45
+
46
+ [tool.ruff]
47
+ line-length = 100
48
+ target-version = "py39"
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "W", "I", "B"]
@@ -0,0 +1,28 @@
1
+ """Build dist/pyfirstaid.pyz: a single file that runs with `python pyfirstaid.pyz`.
2
+
3
+ It needs no installation, so it works even when pip is broken.
4
+ Usage: python scripts/build_pyz.py
5
+ """
6
+
7
+ import shutil
8
+ import tempfile
9
+ import zipapp
10
+ from pathlib import Path
11
+
12
+ ROOT = Path(__file__).resolve().parent.parent
13
+
14
+
15
+ def main() -> None:
16
+ dist = ROOT / "dist"
17
+ dist.mkdir(exist_ok=True)
18
+ target = dist / "pyfirstaid.pyz"
19
+ with tempfile.TemporaryDirectory() as tmp:
20
+ shutil.copytree(ROOT / "src" / "pyfirstaid", Path(tmp) / "pyfirstaid",
21
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
22
+ zipapp.create_archive(tmp, target, interpreter="/usr/bin/env python3",
23
+ main="pyfirstaid.cli:_run")
24
+ print("built", target)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,3 @@
1
+ """pyfirstaid: first aid for broken Python environments."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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())
@@ -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
@@ -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
@@ -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,80 @@
1
+ import os
2
+ import ssl
3
+ import sys
4
+ import urllib.error
5
+
6
+ from pyfirstaid.checks import pip_mismatch, ssl_check, venv
7
+ from pyfirstaid.model import Options, Status
8
+
9
+ # --- pip-mismatch -----------------------------------------------------------
10
+
11
+ def test_parse_pip_version_posix():
12
+ out = "pip 24.0 from /usr/lib/python3/dist-packages/pip (python 3.13)"
13
+ assert pip_mismatch.parse_pip_version(out) == (
14
+ "24.0", "/usr/lib/python3/dist-packages/pip", "3.13")
15
+
16
+
17
+ def test_parse_pip_version_windows_path_with_spaces():
18
+ out = r"pip 25.1 from C:\Program Files\Python313\Lib\site-packages\pip (python 3.13)"
19
+ assert pip_mismatch.parse_pip_version(out)[1].endswith(r"site-packages\pip")
20
+
21
+
22
+ def test_parse_pip_version_garbage():
23
+ assert pip_mismatch.parse_pip_version("Traceback (most recent call last):") is None
24
+
25
+
26
+ def test_pip_belongs_here(tmp_path):
27
+ prefix = str(tmp_path / "venv")
28
+ loc = os.path.join(prefix, "lib", "site-packages", "pip")
29
+ assert pip_mismatch.pip_belongs_here(loc, "3.13", "3.13", [prefix])
30
+ assert not pip_mismatch.pip_belongs_here(loc, "3.12", "3.13", [prefix])
31
+ assert not pip_mismatch.pip_belongs_here("/usr/lib/pip", "3.13", "3.13", [prefix])
32
+
33
+
34
+ def test_pip_check_runs_without_crashing():
35
+ findings = pip_mismatch.run_check(Options(offline=True))
36
+ assert findings and all(f.check == "pip-mismatch" for f in findings)
37
+
38
+
39
+ # --- venv -------------------------------------------------------------------
40
+
41
+ def test_find_unused_venv(tmp_path):
42
+ assert venv.find_unused_venv(str(tmp_path)) is None
43
+ (tmp_path / ".venv").mkdir()
44
+ (tmp_path / ".venv" / "pyvenv.cfg").write_text("home = /usr/bin\n")
45
+ assert venv.find_unused_venv(str(tmp_path)).endswith(".venv")
46
+
47
+
48
+ def test_activate_command_mentions_path():
49
+ assert ".venv" in venv.activate_command(".venv")
50
+
51
+
52
+ def test_wrong_venv_activated(monkeypatch, tmp_path):
53
+ monkeypatch.setenv("VIRTUAL_ENV", str(tmp_path / "some-other-venv"))
54
+ findings = venv.run_check(Options(cwd=str(tmp_path)))
55
+ assert any("DIFFERENT virtual environment" in f.title for f in findings)
56
+
57
+
58
+ # --- ssl --------------------------------------------------------------------
59
+
60
+ def test_bad_cert_env_vars(tmp_path):
61
+ good = tmp_path / "ca.pem"
62
+ good.write_text("x")
63
+ env = {"SSL_CERT_FILE": str(tmp_path / "missing.pem"), "REQUESTS_CA_BUNDLE": str(good)}
64
+ assert ssl_check.bad_cert_env_vars(env) == ["SSL_CERT_FILE"]
65
+
66
+
67
+ def test_classify_error():
68
+ cert = urllib.error.URLError(ssl.SSLCertVerificationError("CERTIFICATE_VERIFY_FAILED"))
69
+ assert ssl_check.classify_error(cert) == "cert"
70
+ assert ssl_check.classify_error(urllib.error.URLError(ssl.SSLError("bad"))) == "ssl"
71
+ assert ssl_check.classify_error(urllib.error.URLError(OSError("no route"))) == "network"
72
+
73
+
74
+ def test_ssl_offline_skips_network(monkeypatch):
75
+ for name in ssl_check.CERT_ENV_VARS:
76
+ monkeypatch.delenv(name, raising=False)
77
+ findings = ssl_check.run_check(Options(offline=True))
78
+ assert any(f.status == Status.SKIP for f in findings)
79
+ if sys.platform != "darwin":
80
+ assert all(f.status != Status.ERROR for f in findings)
@@ -0,0 +1,56 @@
1
+ import json
2
+ import os
3
+
4
+ from pyfirstaid import cli, report
5
+ from pyfirstaid.model import Check, Finding, Options, Status
6
+
7
+
8
+ def test_list(capsys):
9
+ assert cli.main(["--list"]) == 0
10
+ out = capsys.readouterr().out
11
+ assert "pip-mismatch" in out and "venv" in out and "ssl" in out
12
+
13
+
14
+ def test_json_output_is_valid(capsys):
15
+ code = cli.main(["--offline", "--json"])
16
+ data = json.loads(capsys.readouterr().out)
17
+ assert code in (0, 1)
18
+ assert data["tool"] == "pyfirstaid"
19
+ assert {"ok", "info", "warn", "error", "skip"} <= set(data["summary"])
20
+ assert all({"check", "status", "title", "detail", "fix"} <= set(f) for f in data["findings"])
21
+
22
+
23
+ def test_only_and_skip():
24
+ assert [c.id for c in cli.select_checks("venv", None)] == ["venv"]
25
+ assert "ssl" not in [c.id for c in cli.select_checks(None, "ssl")]
26
+
27
+
28
+ def test_crashing_check_is_reported_not_raised():
29
+ def boom(opts):
30
+ raise RuntimeError("kaboom")
31
+
32
+ findings = cli.run_checks([Check("boom", "Boom", boom)], Options())
33
+ assert findings[0].status == Status.SKIP and "kaboom" in findings[0].detail
34
+
35
+
36
+ def test_exit_codes(monkeypatch):
37
+ def fake(statuses):
38
+ return lambda checks, opts: [Finding("x", s, "t") for s in statuses]
39
+
40
+ monkeypatch.setattr(cli, "run_checks", fake([Status.OK]))
41
+ assert cli.main(["--json"]) == 0
42
+ monkeypatch.setattr(cli, "run_checks", fake([Status.WARN]))
43
+ assert cli.main(["--json"]) == 0
44
+ assert cli.main(["--json", "--strict"]) == 1
45
+ monkeypatch.setattr(cli, "run_checks", fake([Status.ERROR]))
46
+ assert cli.main(["--json"]) == 1
47
+
48
+
49
+ def test_redact_hides_home():
50
+ home = os.path.expanduser("~")
51
+ assert home not in report.redact("path is %s/project" % home)
52
+
53
+
54
+ def test_text_report_shows_fix():
55
+ text = report.render_text([Finding("x", Status.ERROR, "Broken", "why", "do this")])
56
+ assert "Broken" in text and "fix: do this" in text and "1 problem(s)" in text