diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2473200..1d1d99f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,9 +7,9 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.12.4 hooks: - - id: ruff + - id: ruff-check args: [--fix] - - id: ruff + - id: ruff-check args: [--preview, --select=CPY] - id: ruff-format - repo: https://github.com/tox-dev/pyproject-fmt diff --git a/src/session_info2/__init__.py b/src/session_info2/__init__.py index 34daebc..52b0e5b 100644 --- a/src/session_info2/__init__.py +++ b/src/session_info2/__init__.py @@ -9,11 +9,12 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from functools import cached_property -from importlib.metadata import packages_distributions, version +from importlib.metadata import version from types import MappingProxyType, ModuleType from typing import TYPE_CHECKING, Any, Literal, TypeAlias from . import _pu +from ._dists import packages_distributions from ._repr import repr_mimebundle as _repr_mimebundle from ._ttl_cache import ttl_cache from ._widget import widget as _widget diff --git a/src/session_info2/_dists.py b/src/session_info2/_dists.py new file mode 100644 index 0000000..6fed46b --- /dev/null +++ b/src/session_info2/_dists.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: MPL-2.0 +from __future__ import annotations + +import re +from importlib.metadata import Distribution, distributions +from importlib.metadata import packages_distributions as pkgs_dists +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Generator, Mapping + + +def packages_distributions() -> Mapping[str, list[str]]: + """Return a mapping of top-level packages to their distributions. + + Unlike :func:`importlib.metadata.packages_distributions`, + this includes editable packages. + """ + pds = dict(pkgs_dists()) + for dist in distributions(): + for pkg_name in _top_level_editable(dist): + if "." not in pkg_name: # apparently that’s what makes an importable name + pds.setdefault(pkg_name, []).append(dist.name) + return pds + + +def _top_level_editable(dist: Distribution) -> Generator[str, None, None]: + """Find top-level packages in an editable distribution.""" + for pth_file in dist.files or (): + if len(pth_file.parts) != 1 or pth_file.suffix != ".pth": + continue + for line in pth_file.read_text().splitlines(): + if re.match(r"import\s", line): + continue # https://docs.python.org/3/library/site.html + for p in Path(line).iterdir(): + yield from _find_top_level(p) + + +def _find_top_level(root: Path) -> Generator[str, None, None]: + if root.suffix == ".py" and "." not in root.stem and root.is_file(): + yield root.stem + return + if "." in root.name or not root.is_dir(): + return + if (root / "__init__.py").is_file(): + yield root.name + return + for p in root.iterdir(): + for pkg in _find_top_level(p): + yield f"{root.name}.{pkg}" diff --git a/tests/test_utils.py b/tests/test_utils.py index 80b2981..7c7e6e9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,9 +3,17 @@ from __future__ import annotations +from importlib.metadata import PathDistribution +from pathlib import Path +from typing import TYPE_CHECKING + import pytest from session_info2 import _mods +from session_info2._dists import _top_level_editable + +if TYPE_CHECKING: + from pathlib import Path @pytest.mark.parametrize( @@ -18,3 +26,16 @@ ) def test_mods(mod_name: str, expected: list[str]) -> None: assert list(_mods(mod_name)) == expected + + +def test_top_level_editable(tmp_path: Path, libdir_test: Path) -> None: + (tmp_path / "fake_editable.pth").write_text(str(libdir_test)) + (meta_path := (tmp_path / "fake_editable-0.1.dist-info")).mkdir() + (meta_path / "RECORD").write_text(f"fake_editable.pth,,\n{meta_path}/RECORD,,") + + assert set(_top_level_editable(PathDistribution(meta_path))) == { + "basic", + "dep", + "mis_match", + "namespace.package", + }