Skip to content

Commit

Permalink
pip: refactor getting metadata and unit tests
Browse files Browse the repository at this point in the history
- consolidate metadata extraction logic from files
  (pyproject.toml, setup.py, and setup.cfg) into a single function
- consolidate extraction logic from the origin remote to a single function
- clean up too many logging statements during execution
- drastically simplify unit tests and speed up overall time
- preserve the same coverage

Signed-off-by: Michal Šoltis <[email protected]>
  • Loading branch information
slimreaper35 committed Oct 9, 2024
1 parent 9508fbb commit 414e227
Show file tree
Hide file tree
Showing 2 changed files with 124 additions and 150 deletions.
104 changes: 51 additions & 53 deletions cachi2/core/package_managers/pip.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import ast
import asyncio
import configparser
Expand Down Expand Up @@ -273,72 +272,71 @@ def _generate_purl_dependency(package: dict[str, Any]) -> str:
return purl.to_string()


def _get_pip_metadata(package_dir: RootedPath) -> tuple[str, Optional[str]]:
"""
Attempt to get the name and version of a Pip package.
def _infer_package_name_from_origin_url(package_dir: RootedPath) -> str:
try:
repo_id = get_repo_id(package_dir.root)
except UnsupportedFeature:
raise PackageRejected(
reason="Unable to infer package name from origin URL",
solution=(
"Provide valid metadata in the package files or ensure"
"the git repository has an 'origin' remote with a valid URL."
),
docs=PIP_METADATA_DOC,
)

First, try to parse the setup.py script (if present) and extract name and version
from keyword arguments to the setuptools.setup() call. If either name or version
could not be resolved and there is a setup.cfg file, try to fill in the missing
values from metadata.name and metadata.version in the .cfg file.
repo_name = Path(repo_id.parsed_origin_url.path.removesuffix(".git")).name
subpath = package_dir.subpath_from_root
resolved_name = Path(repo_name).joinpath(subpath)
return canonicalize_name(str(resolved_name).replace("/", "-")).strip("-.")

:param package_dir: Path to the root directory of a Pip package
:return: Tuple of strings (name, version)
"""
name = None
version = None

def _extract_metadata_from_files(package_dir: RootedPath) -> tuple[Optional[str], Optional[str]]:
pyproject_toml = PyProjectTOML(package_dir)
setup_py = SetupPY(package_dir)
setup_cfg = SetupCFG(package_dir)

# pyproject.toml
if pyproject_toml.exists():
log.info("Extracting metadata from pyproject.toml")
log.debug("Checking pyproject.toml for metadata")
if pyproject_toml.check_dynamic_version():
log.warning("Parsing dynamic metadata from pyproject.toml is not supported")
log.warning("Dynamic version parsing from pyproject.toml is not supported")

name = pyproject_toml.get_name()
version = pyproject_toml.get_version()
name, version = pyproject_toml.get_name(), pyproject_toml.get_version()
if name and version:
return name, version

if None in (name, version) and setup_py.exists():
log.info("Filling in missing metadata from setup.py")
name = name or setup_py.get_name()
version = version or setup_py.get_version()
# setup.py
if setup_py.exists():
log.debug("Checking setup.py for metadata")
name, version = setup_py.get_name(), setup_py.get_version()
if name and version:
return name, version

if None in (name, version) and setup_cfg.exists():
log.info("Filling in missing metadata from setup.cfg")
name = name or setup_cfg.get_name()
version = version or setup_cfg.get_version()
# setup.cfg
if setup_cfg.exists():
log.debug("Checking setup.cfg for metadata")
return setup_cfg.get_name(), setup_cfg.get_version()

if not name:
log.info("Processing metadata from git repository")
try:
repo_path = get_repo_id(package_dir.root).parsed_origin_url.path.removesuffix(".git")
repo_name = Path(repo_path).name
package_subpath = package_dir.subpath_from_root

resolved_path = Path(repo_name).joinpath(package_subpath)
normalized_path = canonicalize_name(str(resolved_path).replace("/", "-"))
name = normalized_path.strip("-.")
except UnsupportedFeature:
raise PackageRejected(
reason="Could not take name from the repository origin url",
solution=(
"Please specify package metadata in a way that Cachi2 understands"
" (see the docs)\n"
"or make sure that the directory Cachi2 is processing is a git"
" repository with\n"
"an 'origin' remote in which case Cachi2 will infer the package name from"
" the remote url."
),
docs=PIP_METADATA_DOC,
)
return None, None

log.info("Resolved package name: %r", name)
if version:
log.info("Resolved package version: %r", version)
else:
log.warning("Could not resolve package version")

def _get_pip_metadata(package_dir: RootedPath) -> tuple[str, Optional[str]]:
"""
Attempt to retrieve the name and version of a pip package.
The function attempts to extract metadata in the following order:
1. pyproject.toml
2. setup.py
3. setup.cfg
If both name and version cannot be resolved from these sources,
it tries to infer the name from origin remote URL of the git repository.
"""
name, version = _extract_metadata_from_files(package_dir)

if not name:
name = _infer_package_name_from_origin_url(package_dir)

return name, version

Expand Down
170 changes: 73 additions & 97 deletions tests/unit/package_managers/test_pip.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from copy import deepcopy
from pathlib import Path
Expand All @@ -10,6 +9,7 @@
import pypi_simple
import pytest
from _pytest.logging import LogCaptureFixture
from git.repo import Repo

from cachi2.core.checksum import ChecksumInfo
from cachi2.core.errors import (
Expand All @@ -24,13 +24,8 @@
from cachi2.core.models.sbom import Component, Property
from cachi2.core.package_managers import pip
from cachi2.core.rooted_path import PathOutsideRoot, RootedPath
from cachi2.core.scm import RepoID
from tests.common_utils import GIT_REF, Symlink, write_file_tree

THIS_MODULE_DIR = Path(__file__).resolve().parent
PKG_DIR = RootedPath("/foo/package_dir")
PKG_DIR_SUBPATH = PKG_DIR.join_within_root("subpath")
MOCK_REPO_ID = RepoID("https://github.com/foolish/bar.git", "abcdef1234")
CUSTOM_PYPI_ENDPOINT = "https://my-pypi.org/simple/"


Expand Down Expand Up @@ -58,116 +53,97 @@ def make_dpi(
)


@pytest.mark.parametrize("toml_exists", [True, False])
@pytest.mark.parametrize("toml_name", ["name_in_pyproject_toml", None])
@pytest.mark.parametrize("toml_version", ["version_in_pyproject_toml", None])
@pytest.mark.parametrize("py_exists", [True, False])
@pytest.mark.parametrize("py_name", ["name_in_setup_py", None])
@pytest.mark.parametrize("py_version", ["version_in_setup_py", None])
@pytest.mark.parametrize("cfg_exists", [True, False])
@pytest.mark.parametrize("cfg_name", ["name_in_setup_cfg", None])
@pytest.mark.parametrize("cfg_version", ["version_in_setup_cfg", None])
@pytest.mark.parametrize("repo_name_with_subpath", ["bar-subpath", None])
@mock.patch("cachi2.core.package_managers.pip.SetupCFG")
@mock.patch("cachi2.core.package_managers.pip.SetupPY")
@mock.patch("cachi2.core.package_managers.pip.PyProjectTOML")
@mock.patch("cachi2.core.package_managers.pip.get_repo_id")
def test_get_pip_metadata(
mock_get_repo_id: mock.Mock,
def test_get_pip_metadata_from_pyproject_toml(
mock_pyproject_toml: mock.Mock,
mock_setup_py: mock.Mock,
mock_setup_cfg: mock.Mock,
toml_exists: bool,
toml_name: Optional[str],
toml_version: Optional[str],
py_exists: bool,
py_name: Optional[str],
py_version: Optional[str],
cfg_exists: bool,
cfg_name: Optional[str],
cfg_version: Optional[str],
repo_name_with_subpath: Optional[str],
caplog: pytest.LogCaptureFixture,
rooted_tmp_path: RootedPath,
caplog: LogCaptureFixture,
) -> None:
"""
Test get_pip_metadata() function.
More thorough tests of pyproject.toml, setup.py and setup.cfg handling are in their respective classes.
"""
if not toml_exists:
toml_name = None
toml_version = None
if not py_exists:
py_name = None
py_version = None
if not cfg_exists:
cfg_name = None
cfg_version = None

pyproject_toml = mock_pyproject_toml.return_value
pyproject_toml.exists.return_value = toml_exists
pyproject_toml.get_name.return_value = toml_name
pyproject_toml.get_version.return_value = toml_version
pyproject_toml.exists.return_value = True
pyproject_toml.check_dynamic_version.return_value = True
pyproject_toml.get_name.return_value = "foo"
pyproject_toml.get_version.return_value = "0.1.0"

setup_py = mock_setup_py.return_value
setup_py.exists.return_value = py_exists
setup_py.get_name.return_value = py_name
setup_py.get_version.return_value = py_version
name, version = pip._get_pip_metadata(rooted_tmp_path)
assert "Checking pyproject.toml for metadata" in caplog.text
assert "Dynamic version parsing from pyproject.toml is not supported" in caplog.text

setup_cfg = mock_setup_cfg.return_value
setup_cfg.exists.return_value = cfg_exists
setup_cfg.get_name.return_value = cfg_name
setup_cfg.get_version.return_value = cfg_version
assert name == "foo"
assert version == "0.1.0"

mock_get_repo_id.return_value = MOCK_REPO_ID

expect_name = toml_name or py_name or cfg_name or repo_name_with_subpath
expect_version = toml_version or py_version or cfg_version
@mock.patch("cachi2.core.package_managers.pip.SetupPY")
def test_get_pip_metadata_from_setup_py(
mock_setup_py: mock.Mock,
rooted_tmp_path: RootedPath,
caplog: LogCaptureFixture,
) -> None:
setup_py = mock_setup_py.return_value
setup_py.exists.return_value = True
setup_py.get_name.return_value = "bar"
setup_py.get_version.return_value = "0.2.0"

if expect_name:
name, version = pip._get_pip_metadata(PKG_DIR_SUBPATH)
name, version = pip._get_pip_metadata(rooted_tmp_path)
assert "Checking setup.py for metadata" in caplog.text

assert name == expect_name
assert version == expect_version
else:
mock_get_repo_id.side_effect = UnsupportedFeature(
"Cachi2 cannot process repositories that don't have an 'origin' remote"
)
with pytest.raises(PackageRejected) as exc_info:
pip._get_pip_metadata(PKG_DIR_SUBPATH)
assert str(exc_info.value) == "Could not take name from the repository origin url"
return
assert name == "bar"
assert version == "0.2.0"

assert pyproject_toml.get_name.called == toml_exists
assert pyproject_toml.get_version.called == toml_exists

find_name_in_setup_py = toml_name is None and py_exists
find_version_in_setup_py = toml_version is None and py_exists
find_name_in_setup_cfg = toml_name is None and py_name is None and cfg_exists
find_version_in_setup_cfg = toml_version is None and py_version is None and cfg_exists
@mock.patch("cachi2.core.package_managers.pip.SetupCFG")
def test_get_pip_metadata_from_setup_cfg(
mock_setup_cfg: mock.Mock,
rooted_tmp_path: RootedPath,
caplog: LogCaptureFixture,
) -> None:
setup_cfg = mock_setup_cfg.return_value
setup_cfg.exists.return_value = True
setup_cfg.get_name.return_value = "baz"
setup_cfg.get_version.return_value = "0.3.0"

name, version = pip._get_pip_metadata(rooted_tmp_path)
assert "Checking setup.cfg for metadata" in caplog.text

assert setup_py.get_name.called == find_name_in_setup_py
assert setup_py.get_version.called == find_version_in_setup_py
assert name == "baz"
assert version == "0.3.0"

assert setup_cfg.get_name.called == find_name_in_setup_cfg
assert setup_cfg.get_version.called == find_version_in_setup_cfg

if toml_exists:
assert "Extracting metadata from pyproject.toml" in caplog.text
@pytest.mark.parametrize(
"origin_exists",
[True, False],
)
@mock.patch("cachi2.core.package_managers.pip.PyProjectTOML")
@mock.patch("cachi2.core.package_managers.pip.SetupPY")
@mock.patch("cachi2.core.package_managers.pip.SetupCFG")
def test_get_pip_metadata_from_remote_origin(
mock_setup_cfg: mock.Mock,
mock_setup_py: mock.Mock,
mock_pyproject_toml: mock.Mock,
origin_exists: bool,
rooted_tmp_path_repo: RootedPath,
) -> None:
pyproject_toml = mock_pyproject_toml.return_value
pyproject_toml.exists.return_value = False

setup_py = mock_setup_py.return_value
setup_py.exists.return_value = False

if find_name_in_setup_py or find_version_in_setup_py:
assert "Filling in missing metadata from setup.py" in caplog.text
setup_cfg = mock_setup_cfg.return_value
setup_cfg.exists.return_value = False

if find_name_in_setup_cfg or find_version_in_setup_cfg:
assert "Filling in missing metadata from setup.cfg" in caplog.text
if origin_exists:
repo = Repo(rooted_tmp_path_repo)
repo.create_remote("origin", "[email protected]:user/repo.git")

if not (toml_exists or py_exists or cfg_exists):
assert "Processing metadata from git repository" in caplog.text
name, version = pip._get_pip_metadata(rooted_tmp_path_repo)
assert name == "repo"
assert version is None
else:
with pytest.raises(PackageRejected) as exc_info:
pip._get_pip_metadata(rooted_tmp_path_repo)

if expect_name:
assert f"Resolved package name: '{expect_name}'" in caplog.text
if expect_version:
assert f"Resolved package version: '{expect_version}'" in caplog.text
assert str(exc_info.value) == "Unable to infer package name from origin URL"


class TestPyprojectTOML:
Expand Down

0 comments on commit 414e227

Please sign in to comment.