Skip to content

Commit

Permalink
Guess package name, re-implements #180 (#181)
Browse files Browse the repository at this point in the history
From #180:

This change is because sometimes a
```
local_dependencies:
  - my_package
```
is not actually installed as `my_package` but perhaps as `foo.my_package` or something else altogether.
  • Loading branch information
basnijholt authored Jul 3, 2024
1 parent 784b318 commit 5396a7f
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 4 deletions.
86 changes: 86 additions & 0 deletions tests/test_setuptools_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Tests for setuptools integration."""

import textwrap
from pathlib import Path

import pytest

from unidep._setuptools_integration import (
_package_name_from_path,
_package_name_from_pyproject_toml,
_package_name_from_setup_cfg,
_package_name_from_setup_py,
)

REPO_ROOT = Path(__file__).parent.parent


def test_package_name_from_path() -> None:
example = REPO_ROOT / "example"
# Could not find the package name, so it uses the folder name
assert _package_name_from_path(example) == "example"
# The following should read from the setup.py or pyproject.toml file
assert _package_name_from_path(example / "hatch_project") == "hatch_project"
assert (
_package_name_from_pyproject_toml(example / "hatch_project" / "pyproject.toml")
== "hatch_project"
)
assert _package_name_from_path(example / "hatch2_project") == "hatch2_project"
assert (
_package_name_from_pyproject_toml(example / "hatch2_project" / "pyproject.toml")
== "hatch2_project"
)
assert (
_package_name_from_path(example / "pyproject_toml_project")
== "pyproject_toml_project"
)
assert (
_package_name_from_pyproject_toml(
example / "pyproject_toml_project" / "pyproject.toml",
)
== "pyproject_toml_project"
)
assert _package_name_from_path(example / "setup_py_project") == "setup_py_project"
assert (
_package_name_from_setup_py(example / "setup_py_project" / "setup.py")
== "setup_py_project"
)
assert (
_package_name_from_path(example / "setuptools_project") == "setuptools_project"
)
assert (
_package_name_from_pyproject_toml(
example / "setuptools_project" / "pyproject.toml",
)
== "setuptools_project"
)


def test_package_name_from_cfg(tmp_path: Path) -> None:
setup_cfg = tmp_path / "setup.cfg"
setup_cfg.write_text(
textwrap.dedent(
"""\
[metadata]
name = setup_cfg_project
""",
),
)
assert _package_name_from_path(tmp_path) == "setup_cfg_project"
assert _package_name_from_setup_cfg(setup_cfg) == "setup_cfg_project"
missing = tmp_path / "missing" / "setup.cfg"
assert not missing.exists()
with pytest.raises(KeyError):
_package_name_from_setup_cfg(missing)

setup_cfg2 = tmp_path / "setup.cfg"
setup_cfg2.write_text(
textwrap.dedent(
"""\
[metadata]
yolo = missing
""",
),
)
with pytest.raises(KeyError):
_package_name_from_setup_cfg(setup_cfg2)
2 changes: 1 addition & 1 deletion tests/test_unidep.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ def test_pip_install_local_dependencies(tmp_path: Path) -> None:
deps = get_python_dependencies(p, include_local_dependencies=True)
assert deps.dependencies == [
"foo",
local_package.as_posix(),
f"local_package @ file://{local_package.as_posix()}",
]


Expand Down
91 changes: 88 additions & 3 deletions unidep/_setuptools_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

from __future__ import annotations

import ast
import configparser
import contextlib
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple

Expand All @@ -20,9 +24,17 @@
warn,
)

if TYPE_CHECKING:
import sys
try: # pragma: no cover
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
HAS_TOML = True
except ImportError: # pragma: no cover
HAS_TOML = False


if TYPE_CHECKING:
from setuptools import Distribution

from unidep.platform_definitions import (
Expand Down Expand Up @@ -138,11 +150,84 @@ def get_python_dependencies(
)
for paths in local_dependencies.values():
for path in paths:
dependencies.append(path.as_posix())
name = _package_name_from_path(path)
dependencies.append(f"{name} @ file://{path.as_posix()}")

return Dependencies(dependencies=dependencies, extras=extras)


def _package_name_from_setup_cfg(file_path: Path) -> str:
config = configparser.ConfigParser()
config.read(file_path)
name = config.get("metadata", "name", fallback=None)
if name is None:
msg = "Could not find the package name in the setup.cfg file."
raise KeyError(msg)
return name


def _package_name_from_setup_py(file_path: Path) -> str:
with file_path.open() as f:
file_content = f.read()

tree = ast.parse(file_content)

class SetupVisitor(ast.NodeVisitor):
def __init__(self) -> None:
self.package_name = None

def visit_Call(self, node: ast.Call) -> None: # noqa: N802
if isinstance(node.func, ast.Name) and node.func.id == "setup":
for keyword in node.keywords:
if keyword.arg == "name":
self.package_name = keyword.value.value # type: ignore[attr-defined]

visitor = SetupVisitor()
visitor.visit(tree)
if visitor.package_name is None:
msg = "Could not find the package name in the setup.py file."
raise KeyError(msg)
assert isinstance(visitor.package_name, str)
return visitor.package_name


def _package_name_from_pyproject_toml(file_path: Path) -> str:
if not HAS_TOML: # pragma: no cover
msg = "toml is required to parse pyproject.toml files."
raise ImportError(msg)
with file_path.open("rb") as f:
data = tomllib.load(f)
with contextlib.suppress(KeyError):
# PEP 621: setuptools, flit, hatch, pdm
return data["project"]["name"]
with contextlib.suppress(KeyError):
# poetry doesn't follow any standard
return data["tool"]["poetry"]["name"]
msg = f"Could not find the package name in the pyproject.toml file: {data}."
raise KeyError(msg)


def _package_name_from_path(path: Path) -> str:
"""Get the package name from a path."""
pyproject_toml = path / "pyproject.toml"
if pyproject_toml.exists():
with contextlib.suppress(Exception):
return _package_name_from_pyproject_toml(pyproject_toml)

setup_cfg = path / "setup.cfg"
if setup_cfg.exists():
with contextlib.suppress(Exception):
return _package_name_from_setup_cfg(setup_cfg)

setup_py = path / "setup.py"
if setup_py.exists():
with contextlib.suppress(Exception):
return _package_name_from_setup_py(setup_py)

# Best guess for the package name is folder name.
return path.name


def _deps(requirements_file: Path) -> Dependencies: # pragma: no cover
try:
platforms = [identify_current_platform()]
Expand Down

0 comments on commit 5396a7f

Please sign in to comment.