Source code for unidep._setuptools_integration

#!/usr/bin/env python3
"""unidep - Unified Conda and Pip requirements management.

This module provides setuptools integration for unidep.
"""

from __future__ import annotations

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

from unidep._conflicts import resolve_conflicts
from unidep._dependencies_parsing import parse_local_dependencies, parse_requirements
from unidep.utils import (
    UnsupportedPlatformError,
    build_pep508_environment_marker,
    identify_current_platform,
    parse_folder_or_filename,
    warn,
)

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 (
        CondaPip,
        Platform,
        Spec,
    )

    if sys.version_info >= (3, 8):
        from typing import Literal
    else:
        from typing_extensions import Literal


[docs] def filter_python_dependencies( resolved: dict[str, dict[Platform | None, dict[CondaPip, Spec]]], ) -> list[str]: """Filter out conda dependencies and return only pip dependencies. Examples -------- >>> requirements = parse_requirements("requirements.yaml") >>> resolved = resolve_conflicts( ... requirements.requirements, requirements.platforms ... ) >>> python_deps = filter_python_dependencies(resolved) """ pip_deps = [] for platform_data in resolved.values(): to_process: dict[Platform | None, Spec] = {} # platform -> Spec for _platform, sources in platform_data.items(): pip_spec = sources.get("pip") if pip_spec: to_process[_platform] = pip_spec if not to_process: continue # Check if all Spec objects are identical first_spec = next(iter(to_process.values())) if all(spec == first_spec for spec in to_process.values()): # Build a single combined environment marker dep_str = first_spec.name_with_pin(is_pip=True) if _platform is not None: selector = build_pep508_environment_marker(list(to_process.keys())) # type: ignore[arg-type] dep_str = f"{dep_str}; {selector}" pip_deps.append(dep_str) continue for _platform, pip_spec in to_process.items(): dep_str = pip_spec.name_with_pin(is_pip=True) if _platform is not None: selector = build_pep508_environment_marker([_platform]) dep_str = f"{dep_str}; {selector}" pip_deps.append(dep_str) return sorted(pip_deps)
class Dependencies(NamedTuple): dependencies: list[str] extras: dict[str, list[str]]
[docs] def get_python_dependencies( filename: str | Path | Literal["requirements.yaml", "pyproject.toml"] = "requirements.yaml", # noqa: PYI051 *, verbose: bool = False, ignore_pins: list[str] | None = None, overwrite_pins: list[str] | None = None, skip_dependencies: list[str] | None = None, platforms: list[Platform] | None = None, raises_if_missing: bool = True, include_local_dependencies: bool = False, ) -> Dependencies: """Extract Python (pip) requirements from a `requirements.yaml` or `pyproject.toml` file.""" # noqa: E501 try: p = parse_folder_or_filename(filename) except FileNotFoundError: if raises_if_missing: raise return Dependencies(dependencies=[], extras={}) requirements = parse_requirements( p.path, ignore_pins=ignore_pins, overwrite_pins=overwrite_pins, skip_dependencies=skip_dependencies, verbose=verbose, extras="*", ) if not platforms: platforms = list(requirements.platforms) resolved = resolve_conflicts(requirements.requirements, platforms) dependencies = filter_python_dependencies(resolved) # TODO[Bas]: This currently doesn't correctly handle # noqa: TD004, TD003, FIX002 # conflicts between sections in the extras and the main dependencies. extras = { section: filter_python_dependencies(resolve_conflicts(reqs, platforms)) for section, reqs in requirements.optional_dependencies.items() } if include_local_dependencies: local_dependencies = parse_local_dependencies( p.path_with_extras, check_pip_installable=True, verbose=verbose, raise_if_missing=False, # skip if local dep is not found # We don't need to warn about skipping the dependencies of # the local dependencies. That is only relevant for the # `unidep install` commands. warn_non_managed=False, ) for paths in local_dependencies.values(): for path in paths: name = _package_name_from_path(path) # TODO: Consider doing this properly using pathname2url # noqa: FIX002 # https://github.com/basnijholt/unidep/pull/214#issuecomment-2568663364 uri = path.as_posix().replace(" ", "%20") dependencies.append(f"{name} @ file://{uri}") 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()] except UnsupportedPlatformError: warn( "Could not identify the current platform." " This may result in selecting all platforms." " Please report this issue at" " https://github.com/basnijholt/unidep/issues", ) # We don't know the current platform, so we can't filter out. # This will result in selecting all platforms. But this is better # than failing. platforms = None skip_local_dependencies = bool(os.getenv("UNIDEP_SKIP_LOCAL_DEPS")) verbose = bool(os.getenv("UNIDEP_VERBOSE")) return get_python_dependencies( requirements_file, platforms=platforms, raises_if_missing=False, verbose=verbose, include_local_dependencies=not skip_local_dependencies, ) def _setuptools_finalizer(dist: Distribution) -> None: # pragma: no cover """Entry point called by setuptools to get the dependencies for a project.""" # PEP 517 says that "All hooks are run with working directory set to the # root of the source tree". project_root = Path.cwd() try: requirements_file = parse_folder_or_filename(project_root).path except FileNotFoundError: return if requirements_file.exists() and dist.install_requires: # type: ignore[attr-defined] msg = ( "You have a `requirements.yaml` file in your project root or" " configured unidep in `pyproject.toml` with `[tool.unidep]`," " but you are also using setuptools' `install_requires`." " Remove the `install_requires` line from `setup.py`." ) raise RuntimeError(msg) deps = _deps(requirements_file) dist.install_requires = deps.dependencies # type: ignore[attr-defined] if deps.extras: dist.extras_require = deps.extras # type: ignore[attr-defined]