#!/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 ruamel.yaml import YAML
from unidep._conflicts import resolve_conflicts
from unidep._dependencies_parsing import (
    _load,
    get_local_dependencies,
    parse_requirements,
)
from unidep.utils import (
    UnsupportedPlatformError,
    build_pep508_environment_marker,
    identify_current_platform,
    is_pip_installable,
    parse_folder_or_filename,
    split_path_and_extras,
    warn,
)
if sys.version_info >= (3, 11):
    import tomllib
else:  # pragma: no cover
    import tomli as tomllib
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()
    }
    # Always process local dependencies to handle PyPI alternatives
    yaml = YAML(typ="rt")
    data = _load(p.path, yaml)
    # Process each local dependency
    for local_dep_obj in get_local_dependencies(data):
        local_path, extras_list = split_path_and_extras(local_dep_obj.local)
        abs_local = (p.path.parent / local_path).resolve()
        # If include_local_dependencies is False (UNIDEP_SKIP_LOCAL_DEPS=1),
        # always use PyPI alternative if available, skip otherwise
        if not include_local_dependencies:
            if local_dep_obj.pypi:
                dependencies.append(local_dep_obj.pypi)
            continue
        # Original behavior when include_local_dependencies is True
        # Handle wheel and zip files
        if abs_local.suffix in (".whl", ".zip"):
            if abs_local.exists():
                # Local wheel exists - use it
                uri = abs_local.as_posix().replace(" ", "%20")
                dependencies.append(f"{abs_local.name} @ file://{uri}")
            elif local_dep_obj.pypi:
                # Wheel doesn't exist - use PyPI alternative
                dependencies.append(local_dep_obj.pypi)
            continue
        # Check if local path exists
        if abs_local.exists() and is_pip_installable(abs_local):
            # Local development - use file:// URL
            name = _package_name_from_path(abs_local)
            # TODO: Consider doing this properly using pathname2url  # noqa: TD003, FIX002, E501
            # github.com/basnijholt/unidep/pull/214#issuecomment-2568663364
            uri = abs_local.as_posix().replace(" ", "%20")
            dep_str = f"{name} @ file://{uri}"
            if extras_list:
                dep_str = f"{name}[{','.join(extras_list)}] @ file://{uri}"
            dependencies.append(dep_str)
        elif local_dep_obj.pypi:
            # Built wheel - local path doesn't exist, use PyPI alternative
            dependencies.append(local_dep_obj.pypi)
        # else: path doesn't exist and no PyPI alternative - skip
    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:
    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]