pyfirstaid 0.2.0__tar.gz → 0.2.2__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.
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/CHANGELOG.md +21 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/PKG-INFO +12 -2
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/README.md +11 -1
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/__init__.py +1 -1
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/compiled.py +2 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/dependencies.py +20 -1
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/pip_mismatch.py +5 -2
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/cli.py +56 -1
- pyfirstaid-0.2.2/src/pyfirstaid/delegate.py +85 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/util.py +6 -2
- pyfirstaid-0.2.2/tests/test_delegate.py +50 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/tests/test_more_checks.py +41 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/.github/workflows/ci.yml +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/.github/workflows/publish.yml +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/.gitignore +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/LICENSE +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/pyproject.toml +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/scripts/build_pyz.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/__main__.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/__init__.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/broken_installs.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/encoding.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/leaks.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/path_check.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/permissions.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/python_version.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/shadowing.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/ssl_check.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/checks/venv.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/model.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/src/pyfirstaid/report.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/tests/test_checks.py +0 -0
- {pyfirstaid-0.2.0 → pyfirstaid-0.2.2}/tests/test_cli.py +0 -0
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
Fixes found by testing on a real Mac with Homebrew Python:
|
|
6
|
+
|
|
7
|
+
- **Fix: false "pip installs into a DIFFERENT Python" on Homebrew** (and other setups where
|
|
8
|
+
site-packages lives outside Python's own folder). Paths are now also compared with symlinks
|
|
9
|
+
resolved.
|
|
10
|
+
- **Dependency conflicts inside a system / Homebrew Python** are now reported as information
|
|
11
|
+
with safe advice, instead of a `pip install` fix that the system would refuse (PEP 668).
|
|
12
|
+
|
|
13
|
+
## 0.2.1
|
|
14
|
+
|
|
15
|
+
- **Fix: correct results when installed with pipx or `uv tool`.** These tools put pyfirstaid in
|
|
16
|
+
its own private environment, so it used to diagnose *that* environment and report false
|
|
17
|
+
problems (e.g. "pip installs into a DIFFERENT Python"). It now checks the Python you get
|
|
18
|
+
when you type `python3`, and says so in a short note.
|
|
19
|
+
- **New `--python PATH` option** to check any interpreter, e.g. `--python python3.12` or
|
|
20
|
+
`--python .venv/bin/python`. The target doesn't need pyfirstaid installed.
|
|
21
|
+
- Files in the current folder (e.g. `random.py`) can no longer break pyfirstaid itself.
|
|
22
|
+
- Clearer message when no compiled packages are installed.
|
|
23
|
+
|
|
3
24
|
## 0.2.0
|
|
4
25
|
|
|
5
26
|
Nine new checks, bringing the total to 12:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyfirstaid
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: First aid for broken Python environments: finds what's wrong and tells you how to fix it.
|
|
5
5
|
Project-URL: Homepage, https://github.com/sai-sakalam/pyfirstaid
|
|
6
6
|
Project-URL: Issues, https://github.com/sai-sakalam/pyfirstaid/issues
|
|
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
|
|
27
27
|
**First aid for broken Python environments.** One command tells you *what's broken* and *exactly how to fix it*.
|
|
28
28
|
|
|
29
|
-
> **Status: v0.2.** 12 checks, tested on Windows, macOS and Linux (Python 3.9–3.14).
|
|
29
|
+
> **Status: v0.2.2.** 12 checks, tested on Windows, macOS and Linux (Python 3.9–3.14).
|
|
30
30
|
> Feedback is very welcome in [Issues](https://github.com/sai-sakalam/pyfirstaid/issues). *Which problems do you hit most?*
|
|
31
31
|
|
|
32
32
|
---
|
|
@@ -81,6 +81,15 @@ pip install pyfirstaid # or: pipx install pyfirstaid
|
|
|
81
81
|
python -m pyfirstaid
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
Installed with **pipx** or **`uv tool`**? pyfirstaid then lives in its own private environment, so it
|
|
85
|
+
automatically checks the Python you get when you type `python3`, and tells you so. To check a
|
|
86
|
+
different one, use `--python`:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pyfirstaid --python .venv/bin/python # a project's virtual environment
|
|
90
|
+
pyfirstaid --python python3.12 # any Python on your PATH
|
|
91
|
+
```
|
|
92
|
+
|
|
84
93
|
No install possible (for example, pip itself is broken)? Download `pyfirstaid.pyz` from the
|
|
85
94
|
[latest release](https://github.com/sai-sakalam/pyfirstaid/releases) and run:
|
|
86
95
|
|
|
@@ -98,6 +107,7 @@ python pyfirstaid.pyz
|
|
|
98
107
|
| `--strict` | Exits with code 1 on warnings too (useful in CI) |
|
|
99
108
|
| `--only venv,ssl` / `--skip ssl` | Runs only some checks, or skips some |
|
|
100
109
|
| `--list` | Lists all checks |
|
|
110
|
+
| `--python PATH` | Checks a different Python (path or command, e.g. `python3.12`). It doesn't need pyfirstaid installed |
|
|
101
111
|
|
|
102
112
|
Exit codes: `0` means no problems, `1` means problems were found, `2` means pyfirstaid itself failed.
|
|
103
113
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
**First aid for broken Python environments.** One command tells you *what's broken* and *exactly how to fix it*.
|
|
5
5
|
|
|
6
|
-
> **Status: v0.2.** 12 checks, tested on Windows, macOS and Linux (Python 3.9–3.14).
|
|
6
|
+
> **Status: v0.2.2.** 12 checks, tested on Windows, macOS and Linux (Python 3.9–3.14).
|
|
7
7
|
> Feedback is very welcome in [Issues](https://github.com/sai-sakalam/pyfirstaid/issues). *Which problems do you hit most?*
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -58,6 +58,15 @@ pip install pyfirstaid # or: pipx install pyfirstaid
|
|
|
58
58
|
python -m pyfirstaid
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
Installed with **pipx** or **`uv tool`**? pyfirstaid then lives in its own private environment, so it
|
|
62
|
+
automatically checks the Python you get when you type `python3`, and tells you so. To check a
|
|
63
|
+
different one, use `--python`:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pyfirstaid --python .venv/bin/python # a project's virtual environment
|
|
67
|
+
pyfirstaid --python python3.12 # any Python on your PATH
|
|
68
|
+
```
|
|
69
|
+
|
|
61
70
|
No install possible (for example, pip itself is broken)? Download `pyfirstaid.pyz` from the
|
|
62
71
|
[latest release](https://github.com/sai-sakalam/pyfirstaid/releases) and run:
|
|
63
72
|
|
|
@@ -75,6 +84,7 @@ python pyfirstaid.pyz
|
|
|
75
84
|
| `--strict` | Exits with code 1 on warnings too (useful in CI) |
|
|
76
85
|
| `--only venv,ssl` / `--skip ssl` | Runs only some checks, or skips some |
|
|
77
86
|
| `--list` | Lists all checks |
|
|
87
|
+
| `--python PATH` | Checks a different Python (path or command, e.g. `python3.12`). It doesn't need pyfirstaid installed |
|
|
78
88
|
|
|
79
89
|
Exit codes: `0` means no problems, `1` means problems were found, `2` means pyfirstaid itself failed.
|
|
80
90
|
|
|
@@ -59,6 +59,8 @@ def run_check(opts: Options) -> List[Finding]:
|
|
|
59
59
|
compiled_count += has_ext
|
|
60
60
|
|
|
61
61
|
if not bad:
|
|
62
|
+
if compiled_count == 0:
|
|
63
|
+
return [Finding(CHECK_ID, Status.OK, "No compiled packages installed")]
|
|
62
64
|
return [Finding(CHECK_ID, Status.OK,
|
|
63
65
|
"%d compiled package(s) match this Python" % compiled_count)]
|
|
64
66
|
|
|
@@ -7,7 +7,7 @@ import sys
|
|
|
7
7
|
from typing import List
|
|
8
8
|
|
|
9
9
|
from pyfirstaid.model import Finding, Options, Status
|
|
10
|
-
from pyfirstaid.util import run
|
|
10
|
+
from pyfirstaid.util import in_virtualenv, run
|
|
11
11
|
|
|
12
12
|
CHECK_ID = "dependencies"
|
|
13
13
|
|
|
@@ -25,6 +25,13 @@ def fix_for(line: str) -> str:
|
|
|
25
25
|
return "python -m pip install --upgrade <the package named above>"
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def system_managed() -> bool:
|
|
29
|
+
"""A Python that refuses pip installs (PEP 668) and is not a venv."""
|
|
30
|
+
from pyfirstaid.checks.venv import is_externally_managed
|
|
31
|
+
|
|
32
|
+
return is_externally_managed() and not in_virtualenv()
|
|
33
|
+
|
|
34
|
+
|
|
28
35
|
def run_check(opts: Options) -> List[Finding]:
|
|
29
36
|
code, out, err = run([sys.executable, "-m", "pip", "check"], timeout=max(opts.timeout, 30))
|
|
30
37
|
if code in (124, 126, 127) or "No module named pip" in err:
|
|
@@ -33,6 +40,18 @@ def run_check(opts: Options) -> List[Finding]:
|
|
|
33
40
|
return [Finding(CHECK_ID, Status.OK, "No dependency conflicts (pip check)")]
|
|
34
41
|
|
|
35
42
|
lines = [ln for ln in out.splitlines() if ln.strip()]
|
|
43
|
+
if system_managed():
|
|
44
|
+
# pip install would be refused here (PEP 668), and these packages belong to
|
|
45
|
+
# Homebrew / the OS, so a conflict is informational, not something to fix with pip.
|
|
46
|
+
return [Finding(
|
|
47
|
+
CHECK_ID, Status.INFO,
|
|
48
|
+
"%d dependency conflict(s) inside your system / Homebrew Python (usually harmless)"
|
|
49
|
+
% len(lines),
|
|
50
|
+
detail="\n".join(lines[:8]),
|
|
51
|
+
fix="Don't pip install into this Python. Do your work in a venv "
|
|
52
|
+
"(python -m venv .venv). If it causes real errors, reinstall Python with the "
|
|
53
|
+
"tool that installed it (e.g. brew reinstall python).",
|
|
54
|
+
)]
|
|
36
55
|
findings = [Finding(CHECK_ID, Status.WARN, "Dependency conflict", detail=ln, fix=fix_for(ln))
|
|
37
56
|
for ln in lines[:8]]
|
|
38
57
|
if len(lines) > 8:
|
|
@@ -9,7 +9,7 @@ import sys
|
|
|
9
9
|
from typing import List, Optional, Tuple
|
|
10
10
|
|
|
11
11
|
from pyfirstaid.model import Finding, Options, Status
|
|
12
|
-
from pyfirstaid.util import is_within, run
|
|
12
|
+
from pyfirstaid.util import is_within, run, site_package_dirs
|
|
13
13
|
|
|
14
14
|
CHECK_ID = "pip-mismatch"
|
|
15
15
|
|
|
@@ -34,7 +34,10 @@ def pip_belongs_here(location: str, pip_python: str, running_python: str,
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def _allowed_prefixes() -> List[str]:
|
|
37
|
-
|
|
37
|
+
# Homebrew (and some Linux distros) keep site-packages OUTSIDE sys.prefix,
|
|
38
|
+
# e.g. /opt/homebrew/lib/python3.14/site-packages, so include every
|
|
39
|
+
# site-packages folder this interpreter actually uses.
|
|
40
|
+
prefixes = [sys.prefix] + site_package_dirs()
|
|
38
41
|
if site.ENABLE_USER_SITE:
|
|
39
42
|
user_site = site.getusersitepackages()
|
|
40
43
|
if isinstance(user_site, str):
|
|
@@ -8,7 +8,7 @@ import sys
|
|
|
8
8
|
import traceback
|
|
9
9
|
from typing import List, Optional
|
|
10
10
|
|
|
11
|
-
from pyfirstaid import __version__
|
|
11
|
+
from pyfirstaid import __version__, delegate
|
|
12
12
|
from pyfirstaid.checks import ALL_CHECKS
|
|
13
13
|
from pyfirstaid.model import Check, Finding, Options, Status
|
|
14
14
|
from pyfirstaid.report import render_json, render_text
|
|
@@ -31,6 +31,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
31
31
|
p.add_argument("--skip", metavar="IDS", help="comma-separated check ids to skip")
|
|
32
32
|
p.add_argument("--list", action="store_true", help="list available checks and exit")
|
|
33
33
|
p.add_argument("--no-color", action="store_true", help="disable colored output")
|
|
34
|
+
p.add_argument("--python", metavar="PATH",
|
|
35
|
+
help="check this Python instead (path or command, e.g. python3.12). "
|
|
36
|
+
"Default: the Python running pyfirstaid, or `python3` on PATH "
|
|
37
|
+
"when pyfirstaid is installed with pipx / uv tool")
|
|
34
38
|
p.add_argument("--version", action="version", version="pyfirstaid " + __version__)
|
|
35
39
|
return p
|
|
36
40
|
|
|
@@ -67,7 +71,54 @@ def use_color(no_color: bool) -> bool:
|
|
|
67
71
|
or bool(os.environ.get("WT_SESSION")) # Windows Terminal supports ANSI
|
|
68
72
|
|
|
69
73
|
|
|
74
|
+
def forwarded(argv: List[str]) -> List[str]:
|
|
75
|
+
"""Command-line arguments minus --python, to pass on to the target interpreter."""
|
|
76
|
+
out, skip_next = [], False
|
|
77
|
+
for a in argv:
|
|
78
|
+
if skip_next:
|
|
79
|
+
skip_next = False
|
|
80
|
+
elif a == "--python":
|
|
81
|
+
skip_next = True
|
|
82
|
+
elif not a.startswith("--python="):
|
|
83
|
+
out.append(a)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def maybe_delegate(args: argparse.Namespace, argv: List[str]) -> Optional[int]:
|
|
88
|
+
"""Re-run inside another interpreter when needed. Returns its exit code, or None."""
|
|
89
|
+
if os.environ.get(delegate.DELEGATED_ENV):
|
|
90
|
+
return None
|
|
91
|
+
if args.python:
|
|
92
|
+
target = delegate.resolve(args.python)
|
|
93
|
+
if not target:
|
|
94
|
+
print("pyfirstaid: cannot find Python %r" % args.python, file=sys.stderr)
|
|
95
|
+
return EXIT_INTERNAL
|
|
96
|
+
reason = "--python"
|
|
97
|
+
elif delegate.in_tool_venv(sys.prefix):
|
|
98
|
+
target = delegate.default_target()
|
|
99
|
+
if not target:
|
|
100
|
+
return None
|
|
101
|
+
reason = "tool"
|
|
102
|
+
else:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
info = delegate.probe(target)
|
|
106
|
+
if info is None or not info[0]:
|
|
107
|
+
print("pyfirstaid: %s %s; checking the Python that runs pyfirstaid instead."
|
|
108
|
+
% (target, "does not start" if info is None else "is older than Python 3.9"),
|
|
109
|
+
file=sys.stderr)
|
|
110
|
+
return None
|
|
111
|
+
if delegate.same_environment(info[1]):
|
|
112
|
+
return None
|
|
113
|
+
if reason == "tool" and not args.json:
|
|
114
|
+
print("Note: pyfirstaid is installed in its own environment (pipx / uv tool), so it is "
|
|
115
|
+
"checking the Python you get with `python3`: %s\n"
|
|
116
|
+
" Use --python PATH to check a different one.\n" % target, file=sys.stderr)
|
|
117
|
+
return delegate.run_in(target, forwarded(argv))
|
|
118
|
+
|
|
119
|
+
|
|
70
120
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
121
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
71
122
|
args = build_parser().parse_args(argv)
|
|
72
123
|
|
|
73
124
|
if args.list:
|
|
@@ -75,6 +126,10 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
75
126
|
print("%-16s %s" % (c.id, c.title))
|
|
76
127
|
return EXIT_OK
|
|
77
128
|
|
|
129
|
+
code = maybe_delegate(args, argv)
|
|
130
|
+
if code is not None:
|
|
131
|
+
return code
|
|
132
|
+
|
|
78
133
|
try:
|
|
79
134
|
findings = run_checks(select_checks(args.only, args.skip), Options(offline=args.offline))
|
|
80
135
|
if args.json:
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Run the checks inside *another* Python interpreter.
|
|
2
|
+
|
|
3
|
+
pyfirstaid must examine the Python the user actually works with. When it is
|
|
4
|
+
installed with pipx or `uv tool`, it runs from its own private virtual
|
|
5
|
+
environment, so it re-runs itself inside the target interpreter instead.
|
|
6
|
+
pyfirstaid has no dependencies, so the target does not need it installed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from typing import List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
DELEGATED_ENV = "PYFIRSTAID_DELEGATED"
|
|
18
|
+
|
|
19
|
+
_TOOL_VENV_MARKERS = ("/pipx/venvs/", "/uv/tools/", "/pipx/.cache/")
|
|
20
|
+
|
|
21
|
+
# Runs inside the target interpreter. Drop the current folder from sys.path so a
|
|
22
|
+
# user's random.py or json.py can't break pyfirstaid, then import it from argv[1].
|
|
23
|
+
_BOOTSTRAP = (
|
|
24
|
+
"import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]; "
|
|
25
|
+
"sys.path.insert(0, sys.argv.pop(1)); "
|
|
26
|
+
"from pyfirstaid.cli import main; sys.exit(main(sys.argv[1:]))"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def in_tool_venv(prefix: str) -> bool:
|
|
31
|
+
"""True if `prefix` is a private tool environment made by pipx or `uv tool`."""
|
|
32
|
+
p = prefix.replace("\\", "/").lower() + "/"
|
|
33
|
+
return any(m in p for m in _TOOL_VENV_MARKERS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_target() -> Optional[str]:
|
|
37
|
+
"""The interpreter the user gets when typing `python3` (or `python`)."""
|
|
38
|
+
return shutil.which("python3") or shutil.which("python")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve(python: str) -> Optional[str]:
|
|
42
|
+
"""Accept a path or a command name such as `python3.12`."""
|
|
43
|
+
if os.path.sep in python or (os.altsep and os.altsep in python):
|
|
44
|
+
return python if os.path.exists(python) else None
|
|
45
|
+
return shutil.which(python)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def package_root() -> str:
|
|
49
|
+
"""Folder (or .pyz file) that contains the `pyfirstaid` package."""
|
|
50
|
+
import pyfirstaid
|
|
51
|
+
|
|
52
|
+
return os.path.dirname(os.path.dirname(os.path.abspath(pyfirstaid.__file__)))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def probe(target: str) -> Optional[Tuple[bool, str]]:
|
|
56
|
+
"""Ask `target` for (is Python 3.9+, sys.prefix). None if it does not start."""
|
|
57
|
+
try:
|
|
58
|
+
out = subprocess.run(
|
|
59
|
+
[target, "-c", "import sys; print(sys.version_info >= (3, 9)); print(sys.prefix)"],
|
|
60
|
+
capture_output=True, text=True, timeout=15,
|
|
61
|
+
)
|
|
62
|
+
except (OSError, subprocess.SubprocessError):
|
|
63
|
+
return None
|
|
64
|
+
lines = out.stdout.strip().splitlines()
|
|
65
|
+
if out.returncode != 0 or len(lines) < 2:
|
|
66
|
+
return None
|
|
67
|
+
return lines[0] == "True", lines[1]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def same_environment(target_prefix: str) -> bool:
|
|
71
|
+
"""Venv interpreters are often links to the same binary, so compare sys.prefix."""
|
|
72
|
+
def key(p: str) -> str:
|
|
73
|
+
return os.path.normcase(os.path.realpath(p))
|
|
74
|
+
|
|
75
|
+
return key(target_prefix) == key(sys.prefix)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_command(target: str, forwarded_args: List[str]) -> List[str]:
|
|
79
|
+
return [target, "-c", _BOOTSTRAP, package_root()] + list(forwarded_args)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_in(target: str, forwarded_args: List[str]) -> int:
|
|
83
|
+
env = dict(os.environ)
|
|
84
|
+
env[DELEGATED_ENV] = "1"
|
|
85
|
+
return subprocess.call(build_command(target, forwarded_args), env=env)
|
|
@@ -31,10 +31,14 @@ def run(cmd: List[str], timeout: float = 15.0) -> Tuple[int, str, str]:
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def norm(path: Optional[str]) -> str:
|
|
34
|
-
"""Normalise a path for comparison
|
|
34
|
+
"""Normalise a path for comparison: symlinks resolved, case-folded on Windows.
|
|
35
|
+
|
|
36
|
+
Resolving symlinks matters on macOS/Homebrew, where e.g.
|
|
37
|
+
/opt/homebrew/opt/python@3.14 links into /opt/homebrew/Cellar/...
|
|
38
|
+
"""
|
|
35
39
|
if not path:
|
|
36
40
|
return ""
|
|
37
|
-
return os.path.normcase(os.path.
|
|
41
|
+
return os.path.normcase(os.path.realpath(path))
|
|
38
42
|
|
|
39
43
|
|
|
40
44
|
def is_within(path: str, parent: str) -> bool:
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from pyfirstaid import cli, delegate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_in_tool_venv():
|
|
9
|
+
assert delegate.in_tool_venv(
|
|
10
|
+
"/Users/me/Library/Application Support/pipx/venvs/pyfirstaid")
|
|
11
|
+
assert delegate.in_tool_venv("/home/me/.local/share/pipx/venvs/pyfirstaid")
|
|
12
|
+
assert delegate.in_tool_venv("/home/me/.local/share/uv/tools/pyfirstaid")
|
|
13
|
+
assert delegate.in_tool_venv(r"C:\Users\me\pipx\venvs\pyfirstaid")
|
|
14
|
+
assert not delegate.in_tool_venv("/home/me/project/.venv")
|
|
15
|
+
assert not delegate.in_tool_venv("/opt/homebrew/opt/python@3.14")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_forwarded_drops_python_option():
|
|
19
|
+
assert cli.forwarded(["--share", "--python", "/x/python", "--only", "venv"]) == \
|
|
20
|
+
["--share", "--only", "venv"]
|
|
21
|
+
assert cli.forwarded(["--python=/x/python", "--json"]) == ["--json"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_resolve_command_name_and_path():
|
|
25
|
+
assert delegate.resolve(sys.executable) == sys.executable
|
|
26
|
+
assert delegate.resolve(os.path.join("no", "such", "python")) is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_probe_and_same_environment():
|
|
30
|
+
ok, prefix = delegate.probe(sys.executable)
|
|
31
|
+
assert ok and delegate.same_environment(prefix)
|
|
32
|
+
assert not delegate.same_environment("/somewhere/else")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_unknown_python_is_an_error(capsys):
|
|
36
|
+
assert cli.main(["--python", "/definitely/not/a/python"]) == cli.EXIT_INTERNAL
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_delegated_run_produces_report():
|
|
40
|
+
# Run pyfirstaid "inside" this same interpreter through the bootstrap command,
|
|
41
|
+
# exactly as --python would for another interpreter.
|
|
42
|
+
cmd = delegate.build_command(sys.executable, ["--only", "encoding", "--json"])
|
|
43
|
+
env = dict(os.environ, **{delegate.DELEGATED_ENV: "1"})
|
|
44
|
+
out = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=60)
|
|
45
|
+
assert out.returncode == 0, out.stderr
|
|
46
|
+
assert '"check": "encoding"' in out.stdout
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_probe_missing_python():
|
|
50
|
+
assert delegate.probe("/definitely/not/a/python") is None
|
|
@@ -146,3 +146,44 @@ def test_all_checks_registered():
|
|
|
146
146
|
ids = [c.id for c in ALL_CHECKS]
|
|
147
147
|
assert len(ids) == len(set(ids)) == 12
|
|
148
148
|
assert os.path.basename(__file__) # keeps `os` used
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- v0.2.2 regressions found on a real Mac (Homebrew) ------------------------
|
|
152
|
+
|
|
153
|
+
def test_pip_in_homebrew_site_packages_outside_prefix(tmp_path):
|
|
154
|
+
# Homebrew: sys.prefix is .../Frameworks/Python.framework/Versions/3.14, but
|
|
155
|
+
# site-packages lives in /opt/homebrew/lib/python3.14/site-packages
|
|
156
|
+
from pyfirstaid.checks import pip_mismatch
|
|
157
|
+
|
|
158
|
+
prefix = str(tmp_path / "Frameworks" / "Versions" / "3.14")
|
|
159
|
+
site_dir = str(tmp_path / "lib" / "python3.14" / "site-packages")
|
|
160
|
+
loc = os.path.join(site_dir, "pip")
|
|
161
|
+
assert not pip_mismatch.pip_belongs_here(loc, "3.14", "3.14", [prefix])
|
|
162
|
+
assert pip_mismatch.pip_belongs_here(loc, "3.14", "3.14", [prefix, site_dir])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_norm_resolves_symlinks(tmp_path):
|
|
166
|
+
from pyfirstaid.util import norm
|
|
167
|
+
|
|
168
|
+
real = tmp_path / "Cellar" / "python"
|
|
169
|
+
real.mkdir(parents=True)
|
|
170
|
+
link = tmp_path / "opt-python"
|
|
171
|
+
link.symlink_to(real)
|
|
172
|
+
assert norm(str(link)) == norm(str(real))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_dependency_conflicts_in_system_python_are_info(monkeypatch):
|
|
176
|
+
monkeypatch.setattr(dependencies, "system_managed", lambda: True)
|
|
177
|
+
monkeypatch.setattr(dependencies, "run", lambda cmd, timeout=0: (
|
|
178
|
+
1, "wheel 0.48.0 requires packaging, which is not installed.", ""))
|
|
179
|
+
findings = dependencies.run_check(Options())
|
|
180
|
+
assert len(findings) == 1 and findings[0].status == Status.INFO
|
|
181
|
+
assert "pip install" not in findings[0].fix.split("Don't pip install")[-1].split(".")[0]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_dependency_conflicts_in_venv_are_warnings(monkeypatch):
|
|
185
|
+
monkeypatch.setattr(dependencies, "system_managed", lambda: False)
|
|
186
|
+
monkeypatch.setattr(dependencies, "run", lambda cmd, timeout=0: (
|
|
187
|
+
1, "wheel 0.48.0 requires packaging, which is not installed.", ""))
|
|
188
|
+
findings = dependencies.run_check(Options())
|
|
189
|
+
assert findings[0].status == Status.WARN and 'install "packaging"' in findings[0].fix
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|