From b404572ab0b8a72f2168a75820f919389a4bc7a7 Mon Sep 17 00:00:00 2001 From: bretello Date: Mon, 26 May 2025 13:58:24 +0200 Subject: [PATCH 1/3] setup.py: add custom `editable_wheel` command to hijack pdbpp in editable installs Editable installs (`pip install -e .`) were broken because `pdbpp_hijack_pdb.pth` was not installed, resulting in `pdbpp` not hijacking `pdb` since the `.pth` file inserts `_pdbpp_path_hack` in sys.path. This adds a custom `editable_wheel` class `editable_install_with_pth_file` that uses a custom `EditableWheel` strategy that takes care of inserting `_pdbpp_path_hack` in the path, allowing `pdbpp` editable installs to correctly hijack `pdb`. --- pdbpp_hijack_pdb.pth | 2 ++ setup.py | 76 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/pdbpp_hijack_pdb.pth b/pdbpp_hijack_pdb.pth index ecd46fc7..ad121aa7 100644 --- a/pdbpp_hijack_pdb.pth +++ b/pdbpp_hijack_pdb.pth @@ -2,6 +2,8 @@ # Doc: https://docs.python.org/3/library/site.html # Code: https://github.com/blueyed/cpython/blob/1b293b600/Lib/site.py#L148-L187 # +# See `setup.py` for a similar setup for editable installs. +# # Add our "_pdbpp_path_hack" to the beginning of `sys.path` if: # 1. the environment variable PDBPP_HIJACK_PDB is trueish (as an int), defaults to 1. # i.e. PDBPP_HIJACK_PDB=0 can be used to disable it. diff --git a/setup.py b/setup.py index c42dea7c..47ba8dfd 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,20 @@ -import os.path +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING from setuptools import setup from setuptools.command.build_py import build_py +from setuptools.command.editable_wheel import _encode_pth, _StaticPth, editable_wheel -readme_path = os.path.join(os.path.dirname(__file__), "README.rst") -changelog_path = os.path.join(os.path.dirname(__file__), "CHANGELOG") +if TYPE_CHECKING: + from setuptools.command.editable_wheel import ( + Distribution, + EditableStrategy, + WheelFile, + ) + +readme_path = Path(__file__).parent / "README.rst" +changelog_path = Path(__file__).parent / "CHANGELOG" with open(readme_path, encoding="utf-8") as fh: readme = fh.read() @@ -14,6 +24,58 @@ long_description = readme + "\n\n" + changelog +class CustomEditablePth(_StaticPth): + """Inserts a custom module (path_hack_name) as the first element of sys.path""" + + def __init__( + self, path_hack_name: str, dist: Distribution, name: str, src_dir: Path + ) -> None: + super().__init__(dist, name, [src_dir]) + self.path_hack = src_dir / path_hack_name + + def __call__( + self, wheel: WheelFile, files: list[str], mapping: Mapping[str, str] + ) -> None: + assert all([p.resolve().exists() for p in self.path_entries]), ( + "module path does not exist" + ) + + path_hack = f"import sys; sys.path.insert(0, \"{self.path_hack.resolve()}\") if int(os.environ.get('PDBPP_HIJACK_PDB', 1)) else None" + contents = _encode_pth( + "\n".join( + [ + path_hack, + self.path_entries[0].resolve().as_posix(), + ] + ), + ) + + wheel.writestr(f"__editable__.{self.name}.pth", contents) + + +class editable_install_with_pth_file(editable_wheel): + """custom editable_wheel install so that `pip install -e .` also hijacks pdb with pdbpp""" + + _path_hack_name: str = "_pdbpp_path_hack" # must match name of module in `./src/` + + def _select_strategy( + self, + name: str, + tag: str, + build_lib: str | Path, + ) -> EditableStrategy: + # note: this requires src-layout + src_dir = self.package_dir.get("", ".") + + src_dir = Path(self.project_dir, src_dir) + return CustomEditablePth( + self._path_hack_name, + self.distribution, + name, + src_dir, + ) + + class build_py_with_pth_file(build_py): """Include the .pth file for this project, in the generated wheel.""" @@ -24,13 +86,17 @@ def run(self): self.copy_file( self.pth_file, - os.path.join(self.build_lib, self.pth_file), + Path(self.build_lib, self.pth_file), preserve_mode=0, ) + print(f"build_py_with_pth_file: include {self.pth_file} in wheel") setup( - cmdclass={"build_py": build_py_with_pth_file}, + cmdclass={ + "editable_wheel": editable_install_with_pth_file, + "build_py": build_py_with_pth_file, + }, platforms=[ "unix", "linux", From 93e10e3e9476d3a11af12de67203eddda629c579 Mon Sep 17 00:00:00 2001 From: bretello Date: Mon, 23 Feb 2026 13:35:39 +0100 Subject: [PATCH 2/3] tests: remove skip_with_missing_pth file: editable installs should always work --- testing/conftest.py | 6 ------ testing/test_integration.py | 3 --- testing/test_pdb.py | 5 ----- 3 files changed, 14 deletions(-) diff --git a/testing/conftest.py b/testing/conftest.py index 6fd8be70..e4f2a19d 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -212,9 +212,3 @@ def import_mock(name, *args): yield m return cm - - -def skip_with_missing_pth_file(): - pth = os.path.join(sysconfig.get_path("purelib"), "pdbpp_hijack_pdb.pth") - if not os.path.exists(pth): - pytest.skip(f"Missing pth file ({pth}), editable install?") diff --git a/testing/test_integration.py b/testing/test_integration.py index 65b51506..20bdea7b 100644 --- a/testing/test_integration.py +++ b/testing/test_integration.py @@ -6,8 +6,6 @@ import pexpect import pytest -from .conftest import skip_with_missing_pth_file - def test_integration(pytester, readline_param): with (pytester.path / "test_file.py").open("w") as fh: @@ -60,7 +58,6 @@ def test_ipython(testdir): - `up` used to crash due to conflicting `hidden_frames` attribute/method. """ pytest.importorskip("IPython") - skip_with_missing_pth_file() child = testdir.spawn( f"{sys.executable} -m IPython --colors=nocolor --simple-prompt", diff --git a/testing/test_pdb.py b/testing/test_pdb.py index a54a368f..dce6a2b2 100644 --- a/testing/test_pdb.py +++ b/testing/test_pdb.py @@ -23,8 +23,6 @@ import pdbpp from pdbpp import DefaultConfig, Pdb, StringIO -from .conftest import skip_with_missing_pth_file - pygments_major, pygments_minor, _ = pygments_version.split(".") @@ -5704,9 +5702,6 @@ def test_python_m_pdb_usage(): @pytest.mark.parametrize("PDBPP_HIJACK_PDB", (1, 0)) def test_python_m_pdb_uses_pdbpp_and_env(PDBPP_HIJACK_PDB, monkeypatch, tmpdir): - if PDBPP_HIJACK_PDB: - skip_with_missing_pth_file() - monkeypatch.setenv("PDBPP_HIJACK_PDB", str(PDBPP_HIJACK_PDB)) f = tmpdir.ensure("test.py") From 59e63baf62263d7acfab4f2f785cc7b937786fbc Mon Sep 17 00:00:00 2001 From: bretello Date: Mon, 23 Feb 2026 14:38:09 +0100 Subject: [PATCH 3/3] tests: add editable install/.pth tests --- testing/test_integration.py | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/testing/test_integration.py b/testing/test_integration.py index 20bdea7b..35dce1c3 100644 --- a/testing/test_integration.py +++ b/testing/test_integration.py @@ -1,6 +1,12 @@ +import glob +import importlib import re +import subprocess import sys +import sysconfig from os import spawnl +from pathlib import Path +from shutil import which from textwrap import dedent import pexpect @@ -72,3 +78,74 @@ def test_ipython(testdir): child.sendeof() child.sendline("y") assert child.wait() == 0 + + +def test_hijacking(): + """make sure that _pdbpp_path_hack is one of the first items in sys.path""" + + spec = importlib.util.find_spec("pdbpp") + if spec is None or spec.origin is None: + return False + + package_path = Path(spec.origin).parent + + if package_path.parts[-1] == "src": + # editable install + path_entry = sys.path[2] + elif package_path.parts[-1] == "site-packages": + # full install + path_entry = sys.path[1] + else: + pytest.fail("Unknown install location/method?") + + stdlib_path = sysconfig.get_path("stdlib") + + path_hack_found = False + for path_entry in sys.path: + if "_pdbpp_path_hack" in path_entry: + path_hack_found = True + + if path_entry != stdlib_path: + continue + + if path_hack_found: + break + + pytest.fail("stdlib path is not hijacked") + + +def test_editable_install_pth(tmp_path): + """Make sure that running `pip install -e .` installs the .pth file for pdb hijacking""" + + has_uv = which("uv") + if has_uv: # uv is faster in creating venvs + venv_command = f"uv venv --python={sys.executable} --seed {tmp_path}" # seed the env with pip + else: + venv_command = f"{sys.executable} -m venv {tmp_path}" + + subprocess.check_call(venv_command.split()) + + python = tmp_path / "bin/python" + + pdbpp_root = Path(__file__).parent.parent + + install_command = f"pip install --no-deps -e {pdbpp_root}" + if has_uv: + install_command = f"uv {install_command} --python={python}" + else: + install_command = f"{python} -m {install_command}" + + subprocess.check_call(install_command.split()) + + pths: list[Path] = list( + glob.glob((tmp_path / "lib/*/site-packages/__editable__.pdbpp*.pth").as_posix()) + ) + + assert len(pths) == 1 + + editable_pth = Path(pths[0]) + pth_contents = editable_pth.read_text() + + pattern = r"sys.path.insert\(0, \".*_pdbpp_path_hack\"\)" + result = re.search(pattern, pth_contents) + assert result is not None