diff --git a/.gitignore b/.gitignore index bd4d3380b4efc83d01763bc201bfe58990d290e5..e7974642e543357ddb6160e73a1a191e2ca194f7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ _citools/ _work/ .mypy_cache/ .pytest_cache/ +.pixi/ +pixi.lock +pixi.toml diff --git a/src/idiap_devtools/scripts/cli.py b/src/idiap_devtools/scripts/cli.py index 55c17e9eb3997c398f260fea5836607f26f65eba..77fb9e4f451f27496bf1f5a7856a388e19b0caf3 100644 --- a/src/idiap_devtools/scripts/cli.py +++ b/src/idiap_devtools/scripts/cli.py @@ -8,6 +8,7 @@ from ..click import AliasedGroup from .env import env from .fullenv import fullenv from .gitlab import gitlab +from .pixi import pixi from .update_pins import update_pins @@ -23,4 +24,5 @@ def cli(): cli.add_command(env) cli.add_command(fullenv) cli.add_command(gitlab) +cli.add_command(pixi) cli.add_command(update_pins) diff --git a/src/idiap_devtools/scripts/pixi.py b/src/idiap_devtools/scripts/pixi.py new file mode 100644 index 0000000000000000000000000000000000000000..f114f391339d21bdb823aa2c6632a1ab002b3bfd --- /dev/null +++ b/src/idiap_devtools/scripts/pixi.py @@ -0,0 +1,243 @@ +# Copyright © 2022 Idiap Research Institute <contact@idiap.ch> +# +# SPDX-License-Identifier: BSD-3-Clause + +import pathlib +import sys + +import click + +from ..click import PreserveIndentCommand, validate_profile, verbosity_option +from ..logging import setup + +logger = setup(__name__.split(".", 1)[0]) + + +@click.command( + cls=PreserveIndentCommand, + epilog=""" +Examples: + + 1. Creates a pixi configuration file for a project you just checked out: + + .. code:: sh + + $ devtool pixi -vv . + $ pixi run python + ... + >>> + + 2. Creates a pixi configuration file for a project you checked out at + directory my-project: + + .. code:: sh + + $ devtool pixi -vv my-project + $ cd my-project + $ pixi run python + ... + >>> + + .. tip:: + + You may hand-edit the output file ``pixi.toml`` to adjust for details, add + conda or Python packages you'd like to complement your work environment. + An example would be adding debuggers such as ``pdbpp`` to the installation + plan. + +""", +) +@click.argument( + "project-dir", + nargs=1, + required=True, + type=click.Path(path_type=pathlib.Path), +) +@click.option( + "-P", + "--profile", + default="default", + show_default=True, + callback=validate_profile, + help="Directory containing the development profile (and a file named " + "profile.toml), or the name of a configuration key pointing to the " + "development profile to use", +) +@click.option( + "-p", + "--python", + default=("%d.%d" % sys.version_info[:2]), + show_default=True, + help="Version of python to build the environment for", +) +@click.option( + "-i/-I", + "--ignore-template/--no-ignore-template", + default=False, + show_default=True, + help="If set, then ignores any project-based templates found " + "on ``conda/pixi.toml.in``", +) +@click.option( + "-o", + "--output", + default="pixi.toml", + show_default=True, + help="The name of the environment plan file", + type=click.Path(path_type=pathlib.Path), +) +@verbosity_option(logger=logger) +def pixi( + project_dir, + profile, + python, + ignore_template, + output, + **_, +) -> None: + """Create a pixi recipe for a project.""" + + import shutil + + import packaging.requirements + + from ..profile import Profile + + the_profile = Profile(profile) + + def _make_requirement_dict( + requirements: list[str], version: dict[str, str] + ) -> dict[str, str]: + retval: dict[str, str] = {} + for k in requirements: + pr = packaging.requirements.Requirement(k) + if pr.name in version: + retval[pr.name] = version[pr.name] + if pr.specifier: + if pr.name in retval: + retval[pr.name] = ",".join( + (retval[pr.name], str(pr.specifier)) + ) + else: + retval[pr.name] = str(pr.specifier) + retval.setdefault(pr.name, "*") + return retval + + version = the_profile.conda_constraints(python) + assert version is not None + + # loads the pyproject.toml file (easy) + pyproject = project_dir / "pyproject.toml" + if pyproject.exists(): + import tomli + + pyproject = tomli.load(pyproject.open("rb")) + + # build output TOML pixi file + config = {} + config["project"] = dict( + name=pyproject["project"]["name"], + authors=[ + f"{k['name']} <{k['email']}>" + for k in pyproject["project"].get("authors", []) + + pyproject["project"].get("maintainers", []) + ], + description=pyproject["project"]["description"], + license=pyproject["project"]["license"]["text"], + readme="README.md", + homepage=pyproject["project"]["urls"]["homepage"], + repository=pyproject["project"]["urls"]["repository"], + documentation=pyproject["project"]["urls"]["documentation"], + ) + + conda_config = the_profile.conda_config( + python=python, public=True, stable=True + ) + config["project"]["channels"] = conda_config.channels + config["project"]["platforms"] = ["linux-64", "osx-arm64"] + + config["dependencies"] = {"python": python + ".*"} + config["dependencies"].update( + _make_requirement_dict( + pyproject.get("project", {}).get("dependencies", []), version + ) + ) + + cmds = [] + + # setup standardized build procedure + config.setdefault("feature", {}).setdefault("build", {})["dependencies"] = ( + _make_requirement_dict( + pyproject.get("build-system", {}).get("requires", []), version + ) + ) + # add pip so that the build works + config["feature"]["build"]["dependencies"].update( + _make_requirement_dict(["pip"], version) + ) + config["feature"]["build"]["tasks"] = dict( + build="pip install --no-build-isolation --no-dependencies --editable ." + ) + config.setdefault("environments", {}).setdefault("default", []).insert( + 0, "build" + ) + cmds.append("To install, run: `pixi run build`") + + # adds optional features + for feature, deps in ( + pyproject.get("project", {}).get("optional-dependencies", {}).items() + ): + config.setdefault("feature", {}).setdefault(feature, {})[ + "dependencies" + ] = _make_requirement_dict(deps, version) + + if "pre-commit" in config["feature"][feature]["dependencies"]: + config["feature"][feature]["tasks"] = { + "qa": "pre-commit run --all-files" + } + # this feature can be separated from the rest + config.setdefault("environments", {})["qa"] = [feature] + cmds.append("To do quality-assurance, run: `pixi run qa`") + + if "sphinx" in config["feature"][feature]["dependencies"]: + config["feature"][feature]["tasks"] = { + "doc": { + "cmd": "rm -rf doc/api && rm -rf html && sphinx-build -aEW doc html", + "depends_on": "build", + } + } + # this feature needs to have the package installed + config.setdefault("environments", {}).setdefault( + "default", [] + ).insert(0, feature) + cmds.append("To do build docs, run: `pixi run doc`") + + if "pytest" in config["feature"][feature]["dependencies"]: + config["feature"][feature]["tasks"] = { + "test": { + "cmd": "pytest -sv tests/", + "depends_on": "build", + } + } + # this feature needs to have the package installed + config.setdefault("environments", {}).setdefault( + "default", [] + ).insert(0, feature) + cmds.append("To do run test, run: `pixi run test`") + + # backup previous installation plan, if one exists + if output.exists(): + backup = output.parent / (output.name + "~") + shutil.copy(output, backup) + + with output.open("w") as f: + import tomlkit + + tomlkit.dump(config, f) + click.secho( + f"pixi configuration recorded at {str(output)}", + fg="yellow", + bold=True, + ) + for k in cmds: + click.secho(k, fg="yellow", bold=True)