pyfirstaid 0.3.0__tar.gz → 0.4.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.
Files changed (42) hide show
  1. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/.github/workflows/ci.yml +8 -0
  2. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/.github/workflows/publish.yml +1 -1
  3. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/CHANGELOG.md +22 -2
  4. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/PKG-INFO +30 -7
  5. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/README.md +28 -5
  6. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/pyproject.toml +2 -1
  7. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/__init__.py +1 -1
  8. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/__init__.py +8 -0
  9. pyfirstaid-0.4.0/src/pyfirstaid/checks/jupyter.py +183 -0
  10. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/pip_mismatch.py +8 -1
  11. pyfirstaid-0.4.0/src/pyfirstaid/checks/poetry.py +153 -0
  12. pyfirstaid-0.4.0/src/pyfirstaid/checks/pyenv.py +125 -0
  13. pyfirstaid-0.4.0/src/pyfirstaid/checks/uv.py +100 -0
  14. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/report.py +15 -1
  15. pyfirstaid-0.4.0/src/pyfirstaid/util.py +162 -0
  16. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/tests/test_cli.py +16 -0
  17. pyfirstaid-0.4.0/tests/test_jupyter.py +115 -0
  18. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/tests/test_more_checks.py +1 -1
  19. pyfirstaid-0.4.0/tests/test_project_tools.py +290 -0
  20. pyfirstaid-0.3.0/src/pyfirstaid/util.py +0 -81
  21. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/.gitignore +0 -0
  22. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/LICENSE +0 -0
  23. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/scripts/build_pyz.py +0 -0
  24. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/__main__.py +0 -0
  25. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/broken_installs.py +0 -0
  26. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/compiled.py +0 -0
  27. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/conda.py +0 -0
  28. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/dependencies.py +0 -0
  29. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/encoding.py +0 -0
  30. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/leaks.py +0 -0
  31. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/path_check.py +0 -0
  32. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/permissions.py +0 -0
  33. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/python_version.py +0 -0
  34. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/shadowing.py +0 -0
  35. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/ssl_check.py +0 -0
  36. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/checks/venv.py +0 -0
  37. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/cli.py +0 -0
  38. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/delegate.py +0 -0
  39. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/src/pyfirstaid/model.py +0 -0
  40. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/tests/test_checks.py +0 -0
  41. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/tests/test_conda.py +0 -0
  42. {pyfirstaid-0.3.0 → pyfirstaid-0.4.0}/tests/test_delegate.py +0 -0
@@ -13,6 +13,14 @@ jobs:
13
13
  matrix:
14
14
  os: [ubuntu-latest, macos-latest, windows-latest]
15
15
  python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
16
+ # Python 3.9 is end-of-life and not built for ubuntu-latest (26.04),
17
+ # so test it on Ubuntu 24.04 instead.
18
+ exclude:
19
+ - os: ubuntu-latest
20
+ python-version: "3.9"
21
+ include:
22
+ - os: ubuntu-24.04
23
+ python-version: "3.9"
16
24
  steps:
17
25
  - uses: actions/checkout@v4
18
26
  - uses: actions/setup-python@v5
@@ -8,7 +8,7 @@ on:
8
8
  tag:
9
9
  description: "Release tag to attach pyfirstaid.pyz to"
10
10
  required: true
11
- default: "v0.1.0"
11
+ default: "v0.4.0"
12
12
 
13
13
  jobs:
14
14
  publish:
@@ -1,6 +1,26 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.4.0
4
+
5
+ Four new checks for the tools people use to manage Pythons and projects. Like the conda
6
+ check, they only read files on disk and run no uv, Poetry, pyenv or Jupyter commands.
7
+
8
+ - **New `jupyter` check**: Jupyter kernels whose Python no longer exists (deleted venv,
9
+ upgraded Python), an environment that has ipykernel but is not registered as a kernel,
10
+ and several kernels with the same name in the kernel picker.
11
+ - **New `uv` check**: a uv project whose `.venv` was never created, is broken (its base
12
+ Python is gone), or is not the Python running. Also respects `UV_PROJECT_ENVIRONMENT`.
13
+ - **New `poetry` check**: finds the project's Poetry environment (in-project `.venv` or
14
+ Poetry's cache folder), and warns when it is broken or not the Python running.
15
+ - **New `pyenv` check**: a `.python-version` file (or `PYENV_VERSION`) asking for a different
16
+ Python than the one running, a pinned version pyenv hasn't installed, and pyenv not set
17
+ up in the shell. uv reads `.python-version` too, so this also helps uv users.
18
+ - `pip-mismatch`: in an environment made by uv (which has no pip by design), it now
19
+ suggests `uv pip install` instead of reporting pip as missing.
20
+ - Report: checks for tools you don't use are shown on one line ("Not used here: ...").
21
+ Multi-line fixes are indented properly.
22
+
23
+ ## 0.3.0
4
24
 
5
25
  - **New `conda` check** for problems from mixing conda and pip (something `conda doctor`
6
26
  doesn't look at):
@@ -8,7 +28,7 @@
8
28
  - pip packages installed into the conda *base* environment,
9
29
  - a conda environment that is activated, but isn't the Python actually running.
10
30
  It only reads files on disk, runs no conda or pip commands, and stays quiet when unsure.
11
- - README: up-to-date example output and downloads badge; author link on PyPI.
31
+ - README: up-to-date example output; author link on PyPI.
12
32
 
13
33
  ## 0.2.3
14
34
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyfirstaid
3
- Version: 0.3.0
3
+ Version: 0.4.0
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: Source, https://github.com/sai-sakalam/pyfirstaid
@@ -10,7 +10,7 @@ Project-URL: Author, https://sai-sakalam.github.io/
10
10
  Author: Sai Gavaskar Sakalam
11
11
  License-Expression: MIT
12
12
  License-File: LICENSE
13
- Keywords: diagnostics,environment,pip,ssl,troubleshooting,venv,virtualenv
13
+ Keywords: conda,diagnostics,environment,jupyter,pip,poetry,pyenv,ssl,troubleshooting,uv,venv,virtualenv
14
14
  Classifier: Development Status :: 3 - Alpha
15
15
  Classifier: Environment :: Console
16
16
  Classifier: Intended Audience :: Developers
@@ -33,14 +33,14 @@ Description-Content-Type: text/markdown
33
33
 
34
34
  # pyfirstaid 🩹
35
35
  [![PyPI](https://img.shields.io/pypi/v/pyfirstaid)](https://pypi.org/project/pyfirstaid/)
36
- [![Python versions](https://img.shields.io/pypi/pyversions/pyfirstaid)](https://pypi.org/project/pyfirstaid/)
37
- [![Downloads](https://img.shields.io/pypi/dm/pyfirstaid)](https://pypistats.org/packages/pyfirstaid)
36
+ [![Python versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://pypi.org/project/pyfirstaid/)
38
37
  [![CI](https://github.com/sai-sakalam/pyfirstaid/actions/workflows/ci.yml/badge.svg)](https://github.com/sai-sakalam/pyfirstaid/actions/workflows/ci.yml)
39
38
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/sai-sakalam/pyfirstaid/blob/main/LICENSE)
39
+ [![XERJ Corpus Hub contributor](https://hub.xerj.org/contributors/sai-sakalam/badge.svg)](https://hub.xerj.org/contributors/sai-sakalam)
40
40
 
41
41
  **First aid for broken Python environments.** One command tells you *what's broken* and *exactly how to fix it*.
42
42
 
43
- > **Status: early, actively developed.** 13 checks, tested on Windows, macOS and Linux (Python 3.9–3.14).
43
+ > **Status: early, actively developed.** 17 checks, tested on Windows, macOS and Linux (Python 3.9–3.14).
44
44
  > Feedback is very welcome in [Issues](https://github.com/sai-sakalam/pyfirstaid/issues). *Which problems do you hit most?*
45
45
 
46
46
  ---
@@ -61,7 +61,7 @@ system Python, and a file called `random.py` is hiding the real `random` module:
61
61
  ```console
62
62
  $ python -m pyfirstaid --share
63
63
 
64
- pyfirstaid 0.2.3: checking your Python environment
64
+ pyfirstaid 0.4.0: checking your Python environment
65
65
  Python 3.13.1 (~/project/.venv/bin/python)
66
66
 
67
67
  ✔ Python 3.13.1 (supported until October 2029)
@@ -84,6 +84,7 @@ Python 3.13.1 (~/project/.venv/bin/python)
84
84
  ✔ No dependency conflicts (pip check)
85
85
  ✔ site-packages is writable
86
86
  ✔ Default text encoding is UTF-8
87
+ - Not used here: conda, uv, poetry, pyenv, jupyter
87
88
 
88
89
  1 problem(s), 2 warning(s).
89
90
  ```
@@ -163,6 +164,24 @@ Shows which Python `python` / `python3` actually start, warns about Apple's buil
163
164
 
164
165
  Tells you whether you're installing into a read-only system Python (use a venv or pipx) or into a venv that was created with `sudo`.
165
166
 
167
+ **"I installed a package, but my Jupyter notebook still says `ModuleNotFoundError`."**
168
+
169
+ python -m pyfirstaid --only jupyter,pip-mismatch
170
+
171
+ The notebook runs a *kernel*, and the kernel may use a different Python than your terminal. pyfirstaid finds kernels that point at a deleted or moved Python, and tells you when your environment isn't registered as a kernel at all. Tip: inside a notebook, use `%pip install <package>` (not `!pip install`), so it installs into the kernel's Python.
172
+
173
+ **"It works with `uv run` / `poetry run`, but not when I run `python` myself."**
174
+
175
+ python -m pyfirstaid --only uv,poetry,venv
176
+
177
+ The project's packages live in its own environment. pyfirstaid tells you whether the Python running is that environment, whether it exists yet (`uv sync` / `poetry install`), or whether it broke after a Python upgrade.
178
+
179
+ **"pyenv says one version, but `python --version` shows another."**
180
+
181
+ python -m pyfirstaid --only pyenv,path
182
+
183
+ Compares the Python running with your `.python-version` file (or `PYENV_VERSION`), and tells you if the version isn't installed in pyenv or pyenv isn't set up in your shell.
184
+
166
185
  **"pip check shows conflicts and I don't know what to install."**
167
186
 
168
187
  python -m pyfirstaid --only dependencies
@@ -206,7 +225,11 @@ Download `pyfirstaid.pyz` from the [latest release](https://github.com/sai-sakal
206
225
  | `python-version` | Python past (or close to) end of life, pre-release Pythons, Apple's built-in Command Line Tools Python |
207
226
  | `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) |
208
227
  | `conda` | A package conda installed that pip later replaced, pip packages in the conda *base* environment, a conda environment that is activated but isn't the Python running |
209
- | `pip-mismatch` | `pip` installs into a different Python than the one you run, pip missing, a broken `pip` command |
228
+ | `uv` | A uv project whose environment isn't created yet, is broken (its base Python is gone), or isn't the Python running |
229
+ | `poetry` | A Poetry project whose environment is missing, broken, or isn't the Python running |
230
+ | `pyenv` | `.python-version` / `PYENV_VERSION` asking for a different Python than the one running, a version pyenv hasn't installed, pyenv not set up in your shell |
231
+ | `jupyter` | Kernels pointing to a Python that no longer exists, an environment with ipykernel that isn't registered as a kernel, kernels with the same name |
232
+ | `pip-mismatch` | `pip` installs into a different Python than the one you run, pip missing (with the `uv pip` fix in uv environments), a broken `pip` command |
210
233
  | `path` | Typing `python` / `python3` runs a *different* Python, or the Windows Microsoft Store shortcut |
211
234
  | `leaks` | `PYTHONPATH` / `PYTHONHOME` set, venvs that can see system packages |
212
235
  | `shadowing` | Files like `random.py` or `requests.py` in your folder that hide real modules |
@@ -1,13 +1,13 @@
1
1
  # pyfirstaid 🩹
2
2
  [![PyPI](https://img.shields.io/pypi/v/pyfirstaid)](https://pypi.org/project/pyfirstaid/)
3
- [![Python versions](https://img.shields.io/pypi/pyversions/pyfirstaid)](https://pypi.org/project/pyfirstaid/)
4
- [![Downloads](https://img.shields.io/pypi/dm/pyfirstaid)](https://pypistats.org/packages/pyfirstaid)
3
+ [![Python versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://pypi.org/project/pyfirstaid/)
5
4
  [![CI](https://github.com/sai-sakalam/pyfirstaid/actions/workflows/ci.yml/badge.svg)](https://github.com/sai-sakalam/pyfirstaid/actions/workflows/ci.yml)
6
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/sai-sakalam/pyfirstaid/blob/main/LICENSE)
6
+ [![XERJ Corpus Hub contributor](https://hub.xerj.org/contributors/sai-sakalam/badge.svg)](https://hub.xerj.org/contributors/sai-sakalam)
7
7
 
8
8
  **First aid for broken Python environments.** One command tells you *what's broken* and *exactly how to fix it*.
9
9
 
10
- > **Status: early, actively developed.** 13 checks, tested on Windows, macOS and Linux (Python 3.9–3.14).
10
+ > **Status: early, actively developed.** 17 checks, tested on Windows, macOS and Linux (Python 3.9–3.14).
11
11
  > Feedback is very welcome in [Issues](https://github.com/sai-sakalam/pyfirstaid/issues). *Which problems do you hit most?*
12
12
 
13
13
  ---
@@ -28,7 +28,7 @@ system Python, and a file called `random.py` is hiding the real `random` module:
28
28
  ```console
29
29
  $ python -m pyfirstaid --share
30
30
 
31
- pyfirstaid 0.2.3: checking your Python environment
31
+ pyfirstaid 0.4.0: checking your Python environment
32
32
  Python 3.13.1 (~/project/.venv/bin/python)
33
33
 
34
34
  ✔ Python 3.13.1 (supported until October 2029)
@@ -51,6 +51,7 @@ Python 3.13.1 (~/project/.venv/bin/python)
51
51
  ✔ No dependency conflicts (pip check)
52
52
  ✔ site-packages is writable
53
53
  ✔ Default text encoding is UTF-8
54
+ - Not used here: conda, uv, poetry, pyenv, jupyter
54
55
 
55
56
  1 problem(s), 2 warning(s).
56
57
  ```
@@ -130,6 +131,24 @@ Shows which Python `python` / `python3` actually start, warns about Apple's buil
130
131
 
131
132
  Tells you whether you're installing into a read-only system Python (use a venv or pipx) or into a venv that was created with `sudo`.
132
133
 
134
+ **"I installed a package, but my Jupyter notebook still says `ModuleNotFoundError`."**
135
+
136
+ python -m pyfirstaid --only jupyter,pip-mismatch
137
+
138
+ The notebook runs a *kernel*, and the kernel may use a different Python than your terminal. pyfirstaid finds kernels that point at a deleted or moved Python, and tells you when your environment isn't registered as a kernel at all. Tip: inside a notebook, use `%pip install <package>` (not `!pip install`), so it installs into the kernel's Python.
139
+
140
+ **"It works with `uv run` / `poetry run`, but not when I run `python` myself."**
141
+
142
+ python -m pyfirstaid --only uv,poetry,venv
143
+
144
+ The project's packages live in its own environment. pyfirstaid tells you whether the Python running is that environment, whether it exists yet (`uv sync` / `poetry install`), or whether it broke after a Python upgrade.
145
+
146
+ **"pyenv says one version, but `python --version` shows another."**
147
+
148
+ python -m pyfirstaid --only pyenv,path
149
+
150
+ Compares the Python running with your `.python-version` file (or `PYENV_VERSION`), and tells you if the version isn't installed in pyenv or pyenv isn't set up in your shell.
151
+
133
152
  **"pip check shows conflicts and I don't know what to install."**
134
153
 
135
154
  python -m pyfirstaid --only dependencies
@@ -173,7 +192,11 @@ Download `pyfirstaid.pyz` from the [latest release](https://github.com/sai-sakal
173
192
  | `python-version` | Python past (or close to) end of life, pre-release Pythons, Apple's built-in Command Line Tools Python |
174
193
  | `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) |
175
194
  | `conda` | A package conda installed that pip later replaced, pip packages in the conda *base* environment, a conda environment that is activated but isn't the Python running |
176
- | `pip-mismatch` | `pip` installs into a different Python than the one you run, pip missing, a broken `pip` command |
195
+ | `uv` | A uv project whose environment isn't created yet, is broken (its base Python is gone), or isn't the Python running |
196
+ | `poetry` | A Poetry project whose environment is missing, broken, or isn't the Python running |
197
+ | `pyenv` | `.python-version` / `PYENV_VERSION` asking for a different Python than the one running, a version pyenv hasn't installed, pyenv not set up in your shell |
198
+ | `jupyter` | Kernels pointing to a Python that no longer exists, an environment with ipykernel that isn't registered as a kernel, kernels with the same name |
199
+ | `pip-mismatch` | `pip` installs into a different Python than the one you run, pip missing (with the `uv pip` fix in uv environments), a broken `pip` command |
177
200
  | `path` | Typing `python` / `python3` runs a *different* Python, or the Windows Microsoft Store shortcut |
178
201
  | `leaks` | `PYTHONPATH` / `PYTHONHOME` set, venvs that can see system packages |
179
202
  | `shadowing` | Files like `random.py` or `requests.py` in your folder that hide real modules |
@@ -11,7 +11,8 @@ license = "MIT"
11
11
  license-files = ["LICENSE"]
12
12
  requires-python = ">=3.9"
13
13
  authors = [{ name = "Sai Gavaskar Sakalam" }]
14
- keywords = ["pip", "virtualenv", "venv", "diagnostics", "troubleshooting", "ssl", "environment"]
14
+ keywords = ["pip", "virtualenv", "venv", "diagnostics", "troubleshooting", "ssl", "environment",
15
+ "conda", "uv", "poetry", "pyenv", "jupyter"]
15
16
  classifiers = [
16
17
  "Development Status :: 3 - Alpha",
17
18
  "Environment :: Console",
@@ -1,3 +1,3 @@
1
1
  """pyfirstaid: first aid for broken Python environments."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -10,13 +10,17 @@ from pyfirstaid.checks import (
10
10
  conda,
11
11
  dependencies,
12
12
  encoding,
13
+ jupyter,
13
14
  leaks,
14
15
  path_check,
15
16
  permissions,
16
17
  pip_mismatch,
18
+ poetry,
19
+ pyenv,
17
20
  python_version,
18
21
  shadowing,
19
22
  ssl_check,
23
+ uv,
20
24
  venv,
21
25
  )
22
26
  from pyfirstaid.model import Check
@@ -25,6 +29,10 @@ ALL_CHECKS: List[Check] = [
25
29
  Check(python_version.CHECK_ID, "Python version", python_version.run_check),
26
30
  Check(venv.CHECK_ID, "Virtual environment", venv.run_check),
27
31
  Check(conda.CHECK_ID, "conda / pip mixing", conda.run_check),
32
+ Check(uv.CHECK_ID, "uv project environment", uv.run_check),
33
+ Check(poetry.CHECK_ID, "Poetry project environment", poetry.run_check),
34
+ Check(pyenv.CHECK_ID, "pyenv / .python-version", pyenv.run_check),
35
+ Check(jupyter.CHECK_ID, "Jupyter kernels", jupyter.run_check),
28
36
  Check(pip_mismatch.CHECK_ID, "pip / python match", pip_mismatch.run_check),
29
37
  Check(path_check.CHECK_ID, "python on PATH", path_check.run_check),
30
38
  Check(leaks.CHECK_ID, "PYTHONPATH / PYTHONHOME", leaks.run_check),
@@ -0,0 +1,183 @@
1
+ """Check: Jupyter kernels that point at the wrong (or a missing) Python.
2
+
3
+ The classic notebook problem is "I installed the package, but the notebook can't import
4
+ it": the notebook runs a *kernel*, and each kernel is tied to one Python. This check
5
+ reads the kernel files (kernel.json) on disk. It runs no Jupyter commands.
6
+
7
+ - kernels whose Python no longer exists (deleted or moved venv, upgraded Python),
8
+ - this environment has ipykernel but is not registered as a kernel,
9
+ - several kernels with the same name in the kernel picker.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import json
16
+ import os
17
+ import sys
18
+ from typing import Dict, List, Optional, Tuple
19
+
20
+ from pyfirstaid.model import Finding, Options, Status
21
+ from pyfirstaid.util import in_virtualenv, is_within, norm
22
+
23
+ CHECK_ID = "jupyter"
24
+
25
+ Kernel = Tuple[str, str, List[str], str] # (name, folder, argv, display name)
26
+
27
+
28
+ def jupyter_dirs(prefix: Optional[str] = None) -> List[str]:
29
+ """Folders Jupyter searches for kernels, in Jupyter's own order (user, env, system)."""
30
+ prefix = prefix or sys.prefix
31
+ home = os.path.expanduser("~")
32
+ dirs: List[str] = []
33
+
34
+ data_dir = os.environ.get("JUPYTER_DATA_DIR")
35
+ if data_dir:
36
+ dirs.append(data_dir)
37
+ elif sys.platform == "darwin":
38
+ dirs.append(os.path.join(home, "Library", "Jupyter"))
39
+ elif os.name == "nt":
40
+ appdata = os.environ.get("APPDATA")
41
+ if appdata:
42
+ dirs.append(os.path.join(appdata, "jupyter"))
43
+ else:
44
+ xdg = os.environ.get("XDG_DATA_HOME") or os.path.join(home, ".local", "share")
45
+ dirs.append(os.path.join(xdg, "jupyter"))
46
+
47
+ for extra in (os.environ.get("JUPYTER_PATH") or "").split(os.pathsep):
48
+ if extra:
49
+ dirs.append(extra)
50
+
51
+ dirs.append(os.path.join(prefix, "share", "jupyter"))
52
+ if os.name == "nt":
53
+ programdata = os.environ.get("PROGRAMDATA")
54
+ if programdata:
55
+ dirs.append(os.path.join(programdata, "jupyter"))
56
+ else:
57
+ dirs.extend(["/usr/local/share/jupyter", "/usr/share/jupyter"])
58
+ return [os.path.join(d, "kernels") for d in dirs]
59
+
60
+
61
+ def find_kernels(kernel_dirs: List[str]) -> List[Kernel]:
62
+ """All kernels, first one wins when the same name is in several folders (like Jupyter)."""
63
+ seen: Dict[str, Kernel] = {}
64
+ for base in kernel_dirs:
65
+ try:
66
+ names = sorted(os.listdir(base))
67
+ except OSError:
68
+ continue
69
+ for name in names:
70
+ folder = os.path.join(base, name)
71
+ spec_file = os.path.join(folder, "kernel.json")
72
+ if name.lower() in seen or not os.path.isfile(spec_file):
73
+ continue
74
+ try:
75
+ with open(spec_file, encoding="utf-8") as fh:
76
+ spec = json.load(fh)
77
+ except (OSError, ValueError):
78
+ continue
79
+ argv = spec.get("argv") if isinstance(spec, dict) else None
80
+ if not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv):
81
+ continue
82
+ display = spec.get("display_name")
83
+ if not isinstance(display, str):
84
+ display = name
85
+ seen[name.lower()] = (name, folder, argv, display)
86
+ return list(seen.values())
87
+
88
+
89
+ def missing_python(kernel: Kernel) -> bool:
90
+ """True when the kernel starts a Python by full path and that file is gone.
91
+
92
+ Kernels that use a bare `python` are fine: Jupyter runs them with its own Python.
93
+ """
94
+ exe = kernel[2][0]
95
+ return os.path.isabs(exe) and not os.path.exists(exe)
96
+
97
+
98
+ def env_name(prefix: str) -> str:
99
+ """A readable kernel name for an environment: `.venv` folders are named after the project."""
100
+ base = os.path.basename(os.path.normpath(prefix))
101
+ if base.lower() in (".venv", "venv", "env", ".env"):
102
+ base = os.path.basename(os.path.dirname(os.path.normpath(prefix))) or base
103
+ return "".join(c if c.isalnum() or c in "-_." else "-" for c in base).strip("-") or "myenv"
104
+
105
+
106
+ def is_registered(kernels: List[Kernel], prefix: str, executable: str) -> bool:
107
+ for _, _, argv, _ in kernels:
108
+ exe = argv[0]
109
+ if os.path.isabs(exe) and (norm(exe) == norm(executable) or is_within(exe, prefix)):
110
+ return True
111
+ return False
112
+
113
+
114
+ def run_check(opts: Options, kernel_dirs: Optional[List[str]] = None,
115
+ prefix: Optional[str] = None, executable: Optional[str] = None,
116
+ has_ipykernel: Optional[bool] = None, is_env: Optional[bool] = None,
117
+ has_jupyter: Optional[bool] = None) -> List[Finding]:
118
+ prefix = prefix or sys.prefix
119
+ executable = executable or sys.executable
120
+ if has_ipykernel is None:
121
+ has_ipykernel = importlib.util.find_spec("ipykernel") is not None
122
+ if has_jupyter is None:
123
+ has_jupyter = any(importlib.util.find_spec(m) is not None
124
+ for m in ("jupyter_server", "notebook", "jupyterlab"))
125
+ if is_env is None:
126
+ is_env = in_virtualenv() or os.path.isdir(os.path.join(prefix, "conda-meta"))
127
+ kernels = find_kernels(kernel_dirs if kernel_dirs is not None else jupyter_dirs(prefix))
128
+
129
+ if not kernels and not has_ipykernel:
130
+ return [Finding(CHECK_ID, Status.SKIP, "Jupyter not found")]
131
+
132
+ findings: List[Finding] = []
133
+
134
+ broken = [k for k in kernels if missing_python(k)]
135
+ if broken:
136
+ findings.append(Finding(
137
+ CHECK_ID, Status.WARN,
138
+ "%d Jupyter kernel(s) point to a Python that no longer exists" % len(broken),
139
+ detail="These kernels fail to start (often after deleting a venv or upgrading "
140
+ "Python):\n" + "\n".join(
141
+ "%s -> %s" % (k[0], k[2][0]) for k in broken[:8]),
142
+ fix="Remove each one: jupyter kernelspec remove %s\n"
143
+ "Then register the environment again from inside it: "
144
+ "python -m ipykernel install --user --name <name>" % broken[0][0],
145
+ ))
146
+
147
+ # Jupyter installed in this same environment already uses this Python.
148
+ if has_ipykernel and is_env and not has_jupyter and not is_registered(
149
+ kernels, prefix, executable):
150
+ name = env_name(prefix)
151
+ findings.append(Finding(
152
+ CHECK_ID, Status.INFO,
153
+ "This environment has ipykernel, but no Jupyter kernel uses it",
154
+ detail="Notebooks can only use this Python (and its packages) through a kernel. "
155
+ "Otherwise `import` in a notebook won't see what you installed here.",
156
+ fix='python -m ipykernel install --user --name %s --display-name "Python (%s)"'
157
+ % (name, name),
158
+ ))
159
+
160
+ by_display: Dict[str, List[str]] = {}
161
+ shown: Dict[str, str] = {}
162
+ for name, _, _, display in kernels:
163
+ key = display.strip().lower()
164
+ by_display.setdefault(key, []).append(name)
165
+ shown.setdefault(key, display.strip())
166
+ dupes = {shown[d]: n for d, n in by_display.items() if len(n) > 1}
167
+ if dupes:
168
+ findings.append(Finding(
169
+ CHECK_ID, Status.INFO,
170
+ "Several Jupyter kernels have the same name in the kernel picker",
171
+ detail="\n".join("%s: %s" % (d, ", ".join(n)) for d, n in sorted(dupes.items())),
172
+ fix="List them with `jupyter kernelspec list`, then remove old ones "
173
+ "(`jupyter kernelspec remove <name>`) or re-install with a clear "
174
+ '--display-name "Python (project)".',
175
+ ))
176
+
177
+ if not findings:
178
+ findings.append(Finding(
179
+ CHECK_ID, Status.OK,
180
+ "%d Jupyter kernel(s) found, all point to a working Python" % len(kernels)
181
+ if kernels else "ipykernel installed, no kernel problems",
182
+ ))
183
+ return findings
@@ -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, site_package_dirs
12
+ from pyfirstaid.util import is_within, read_pyvenv_cfg, run, site_package_dirs
13
13
 
14
14
  CHECK_ID = "pip-mismatch"
15
15
 
@@ -50,6 +50,13 @@ def run_check(opts: Options) -> List[Finding]:
50
50
 
51
51
  code, out, err = run([sys.executable, "-m", "pip", "--version"], opts.timeout)
52
52
  if code != 0:
53
+ if "uv" in read_pyvenv_cfg(sys.prefix):
54
+ return [Finding(
55
+ CHECK_ID, Status.INFO,
56
+ "No pip in this environment (normal: it was made by uv)",
57
+ detail="A plain `pip install` would use some other Python's pip.",
58
+ fix="uv pip install <package> (in a uv project: uv add <package>)",
59
+ )]
53
60
  return [Finding(
54
61
  CHECK_ID, Status.ERROR,
55
62
  "pip is not installed for this Python",
@@ -0,0 +1,153 @@
1
+ """Check: Poetry projects that run the wrong environment (or a broken one).
2
+
3
+ Poetry keeps each project's environment either in the project (`.venv`) or in its own
4
+ cache folder, named `<project>-<hash>-py3.X`. This check finds those folders on disk and
5
+ compares them with the Python that is running. It runs no Poetry commands.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import os
13
+ import re
14
+ import sys
15
+ from typing import List, Optional
16
+
17
+ from pyfirstaid.checks.uv import display_path
18
+ from pyfirstaid.model import Finding, Options, Status
19
+ from pyfirstaid.util import (
20
+ broken_venv_base,
21
+ find_upwards,
22
+ in_virtualenv,
23
+ is_within,
24
+ norm,
25
+ read_pyproject,
26
+ )
27
+
28
+ CHECK_ID = "poetry"
29
+
30
+
31
+ def poetry_project(cwd: str) -> Optional[str]:
32
+ """The Poetry project folder for `cwd` (has poetry.lock or [tool.poetry]), if any."""
33
+ lock = find_upwards(cwd, "poetry.lock")
34
+ if lock:
35
+ return os.path.dirname(lock)
36
+ if "poetry" in (read_pyproject(cwd).get("tool") or {}):
37
+ return cwd
38
+ return None
39
+
40
+
41
+ def project_name(root: str) -> Optional[str]:
42
+ data = read_pyproject(root)
43
+ for table in ((data.get("tool") or {}).get("poetry") or {}, data.get("project") or {}):
44
+ name = table.get("name") if isinstance(table, dict) else None
45
+ if isinstance(name, str) and name:
46
+ return name
47
+ return None
48
+
49
+
50
+ def venvs_dir() -> str:
51
+ """Where Poetry keeps environments that are not inside the project."""
52
+ custom = os.environ.get("POETRY_VIRTUALENVS_PATH")
53
+ if custom:
54
+ return custom
55
+ cache = os.environ.get("POETRY_CACHE_DIR")
56
+ if not cache:
57
+ home = os.path.expanduser("~")
58
+ if sys.platform == "darwin":
59
+ cache = os.path.join(home, "Library", "Caches", "pypoetry")
60
+ elif os.name == "nt":
61
+ local = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
62
+ cache = os.path.join(local, "pypoetry", "Cache")
63
+ else:
64
+ xdg = os.environ.get("XDG_CACHE_HOME") or os.path.join(home, ".cache")
65
+ cache = os.path.join(xdg, "pypoetry")
66
+ return os.path.join(cache, "virtualenvs")
67
+
68
+
69
+ def sanitized(name: str) -> str:
70
+ return re.sub(r'[ $`!*@"\\\r\n\t]', "_", name.lower())[:42]
71
+
72
+
73
+ def env_name(name: str, root: str) -> str:
74
+ """Poetry's environment name: <name>-<first 8 chars of a hash of the project path>."""
75
+ digest = hashlib.sha256(os.path.normcase(os.path.realpath(root)).encode()).digest()
76
+ return "%s-%s" % (sanitized(name), base64.urlsafe_b64encode(digest).decode()[:8])
77
+
78
+
79
+ def project_envs(root: str, name: Optional[str], cache_dir: str) -> List[str]:
80
+ """This project's environments: an in-project .venv, plus matching cache folders."""
81
+ envs: List[str] = []
82
+ local = os.path.join(root, ".venv")
83
+ if os.path.isfile(os.path.join(local, "pyvenv.cfg")):
84
+ envs.append(local)
85
+ if name:
86
+ start = env_name(name, root) + "-py"
87
+ try:
88
+ entries = sorted(os.listdir(cache_dir))
89
+ except OSError:
90
+ entries = []
91
+ envs.extend(os.path.join(cache_dir, e) for e in entries if e.startswith(start))
92
+ return envs
93
+
94
+
95
+ def run_check(opts: Options, prefix: Optional[str] = None, in_venv: Optional[bool] = None,
96
+ cache_dir: Optional[str] = None) -> List[Finding]:
97
+ prefix = prefix or sys.prefix
98
+ in_venv = in_virtualenv() if in_venv is None else in_venv
99
+ cache_dir = cache_dir or venvs_dir()
100
+ cwd = opts.cwd or os.getcwd()
101
+
102
+ root = poetry_project(cwd)
103
+ if not root:
104
+ return [Finding(CHECK_ID, Status.SKIP, "Not a Poetry project")]
105
+
106
+ name = project_name(root)
107
+ envs = project_envs(root, name, cache_dir)
108
+ findings: List[Finding] = []
109
+
110
+ working = []
111
+ for env in envs:
112
+ gone = broken_venv_base(env)
113
+ if not gone:
114
+ working.append(env)
115
+ continue
116
+ if norm(os.path.dirname(env)) == norm(root):
117
+ fix = "Delete the .venv folder, then run `poetry install`."
118
+ else:
119
+ version = env.rsplit("-py", 1)[-1]
120
+ fix = "poetry env remove python%s then: poetry install" % version
121
+ findings.append(Finding(
122
+ CHECK_ID, Status.WARN,
123
+ "A Poetry environment for this project is broken: its base Python is gone",
124
+ detail="%s was built from %s, which no longer exists (often after upgrading "
125
+ "Python)." % (display_path(env, cwd), gone),
126
+ fix=fix,
127
+ ))
128
+
129
+ # The running env counts as the project's even if the path hash changed (moved project).
130
+ running_is_project = any(norm(prefix) == norm(e) for e in working) or (
131
+ name is not None and is_within(prefix, cache_dir)
132
+ and os.path.basename(os.path.normpath(prefix)).startswith(sanitized(name) + "-"))
133
+
134
+ if running_is_project:
135
+ findings.append(Finding(CHECK_ID, Status.OK, "Running the Poetry project's environment"))
136
+ elif not envs:
137
+ findings.append(Finding(
138
+ CHECK_ID, Status.INFO,
139
+ "Poetry project, but no environment for it was found",
140
+ detail="project: %s" % root,
141
+ fix="poetry install",
142
+ ))
143
+ elif working:
144
+ findings.append(Finding(
145
+ CHECK_ID, Status.WARN if in_venv else Status.INFO,
146
+ "This is a Poetry project, but a DIFFERENT environment is running" if in_venv
147
+ else "This is a Poetry project, but its environment is not the Python running",
148
+ detail="project env: %s\nrunning: %s" % (working[0], prefix),
149
+ fix="Run commands through Poetry: `poetry run python ...`, "
150
+ "or run `poetry env activate` and then the command it prints. "
151
+ "To check it: poetry run python -m pyfirstaid",
152
+ ))
153
+ return findings