diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c7cd320..c692e94 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,19 @@ repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 - hooks: - - id: trailing-whitespace - args: [--markdown-linebreak-ext=md] - - id: end-of-file-fixer - - id: check-yaml - - repo: https://github.com/psf/black - rev: 22.1.0 - hooks: - - id: black - - repo: https://github.com/pycqa/isort - rev: 5.10.1 - hooks: - - id: isort - - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 - hooks: - - id: flake8 - args: [--max-line-length=88] +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + # ----- Python formatting ----- +- repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.1.13 + hooks: + # Run ruff linter. + - id: ruff + args: + - --quiet + - --fix + # Run ruff formatter. + - id: ruff-format diff --git a/pydeps2env/__init__.py b/pydeps2env/__init__.py new file mode 100644 index 0000000..74db84b --- /dev/null +++ b/pydeps2env/__init__.py @@ -0,0 +1,5 @@ +"""pydeps2env: helps to generate conda environment files from python package dependencies.""" + +from .environment import Environment + +__all__ = ["Environment"] diff --git a/pydeps2env/environment.py b/pydeps2env/environment.py new file mode 100644 index 0000000..f752aad --- /dev/null +++ b/pydeps2env/environment.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass, field +from packaging.requirements import Requirement +from pathlib import Path +from collections import defaultdict +import tomli as tomllib +import yaml +import warnings + + +def add_requirement( + req: Requirement | str, + requirements: dict[str, Requirement], + mode: str = "combine", +): + """Add a requirement to existing requirement specification (in place).""" + + if not isinstance(req, Requirement): + req = Requirement(req) + + if req.name not in requirements: + requirements[req.name] = req + elif mode == "combine": + requirements[req.name].specifier &= req.specifier + elif mode == "replace": + requirements[req.name] = req + else: + raise ValueError(f"Unknown `mode` for add_requirement: {mode}") + + +def combine_requirements( + req1: dict[str, Requirement], req2: dict[str, Requirement] +) -> dict[str, Requirement]: + """Combine multiple requirement listings.""" + req1 = req1.copy() + for r in req2.values(): + add_requirement(r, req1, mode="combine") + + return req1 + + +@dataclass +class Environment: + filename: str | Path + channels: list[str] = field(default_factory=lambda: ["conda-forge"]) + pip_packages: set[str] = field(default_factory=set) # install via pip + requirements: dict[str, Requirement] = field(default_factory=dict, init=False) + build_system: dict[str, Requirement] = field(default_factory=dict, init=False) + + def __post_init__(self): + if Path(self.filename).suffix == ".toml": + self.load_pyproject() + + def load_pyproject(self): + with open(self.filename, "rb") as fh: + cp = defaultdict(dict, tomllib.load(fh)) + + if python := cp["project"].get("requires-python"): + add_requirement("python" + python, self.requirements) + + for dep in cp.get("project").get("dependencies"): + add_requirement(dep, self.requirements) + + for dep in cp.get("build-system").get("requires"): + add_requirement(dep, self.build_system) + + def _get_dependencies(self, include_build_system: bool = True): + """Get the default conda environment entries.""" + + reqs = self.requirements.copy() + if include_build_system: + reqs = combine_requirements(reqs, self.build_system) + + _python = reqs.pop("python", None) + + deps = [str(r) for r in reqs.values() if r.name not in self.pip_packages] + deps.sort(key=str.lower) + if _python: + deps = [str(_python)] + deps + + pip = [str(r) for r in reqs.values() if r.name in self.pip_packages] + pip.sort(key=str.lower) + + return deps, pip + + def export( + self, + outfile: str | Path = "environment.yaml", + include_build_system: bool = True, + ): + deps, pip = self._get_dependencies(include_build_system=include_build_system) + + conda_env = {"channels": self.channels, "dependencies": deps.copy()} + if pip: + conda_env["dependencies"] += ["pip", {"pip": pip}] + + if outfile is None: + return conda_env + + p = Path(outfile) + if p.suffix in [".txt"]: + deps += pip + deps.sort(key=str.lower) + with open(p, "w") as outfile: + outfile.writelines("\n".join(deps)) + else: + if p.suffix not in [".yaml", ".yml"]: + warnings.warn( + f"Unknown environment format `{p.suffix}`, generating conda yaml output." + ) + with open(p, "w") as outfile: + yaml.dump(conda_env, outfile, default_flow_style=False)