pyfirstaid 0.2.0__tar.gz → 0.2.1__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.
Files changed (33) hide show
  1. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/CHANGELOG.md +11 -0
  2. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/PKG-INFO +12 -2
  3. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/README.md +11 -1
  4. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/__init__.py +1 -1
  5. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/compiled.py +2 -0
  6. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/cli.py +56 -1
  7. pyfirstaid-0.2.1/src/pyfirstaid/delegate.py +85 -0
  8. pyfirstaid-0.2.1/tests/test_delegate.py +50 -0
  9. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/.github/workflows/ci.yml +0 -0
  10. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/.github/workflows/publish.yml +0 -0
  11. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/.gitignore +0 -0
  12. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/LICENSE +0 -0
  13. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/pyproject.toml +0 -0
  14. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/scripts/build_pyz.py +0 -0
  15. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/__main__.py +0 -0
  16. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/__init__.py +0 -0
  17. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/broken_installs.py +0 -0
  18. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/dependencies.py +0 -0
  19. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/encoding.py +0 -0
  20. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/leaks.py +0 -0
  21. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/path_check.py +0 -0
  22. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/permissions.py +0 -0
  23. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/pip_mismatch.py +0 -0
  24. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/python_version.py +0 -0
  25. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/shadowing.py +0 -0
  26. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/ssl_check.py +0 -0
  27. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/checks/venv.py +0 -0
  28. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/model.py +0 -0
  29. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/report.py +0 -0
  30. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/src/pyfirstaid/util.py +0 -0
  31. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/tests/test_checks.py +0 -0
  32. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/tests/test_cli.py +0 -0
  33. {pyfirstaid-0.2.0 → pyfirstaid-0.2.1}/tests/test_more_checks.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - **Fix: correct results when installed with pipx or `uv tool`.** These tools put pyfirstaid in
6
+ its own private environment, so it used to diagnose *that* environment and report false
7
+ problems (e.g. "pip installs into a DIFFERENT Python"). It now checks the Python you get
8
+ when you type `python3`, and says so in a short note.
9
+ - **New `--python PATH` option** to check any interpreter, e.g. `--python python3.12` or
10
+ `--python .venv/bin/python`. The target doesn't need pyfirstaid installed.
11
+ - Files in the current folder (e.g. `random.py`) can no longer break pyfirstaid itself.
12
+ - Clearer message when no compiled packages are installed.
13
+
3
14
  ## 0.2.0
4
15
 
5
16
  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.0
3
+ Version: 0.2.1
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.1.** 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.1.** 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
 
@@ -1,3 +1,3 @@
1
1
  """pyfirstaid: first aid for broken Python environments."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -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
 
@@ -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)
@@ -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
File without changes
File without changes
File without changes
File without changes