From 2834e0ec01483bc7eace33a08d5aa1efb1d1bd84 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 12 Aug 2024 10:28:21 +0200 Subject: [PATCH] FEAT: automatically create Pixi environment (#374) * DX: avoid installing `sphinx-autobuild` v2024.4 * DX: highlight `pixi.lock` as YAML file * DX: ignore `.gitattributes` with cSpell * ENH: check if pixi.lock is linguist YAML * ENH: ensure that `.pixi/` is listed under `.gitignore` * ENH: execute `direnv` check after `pixi` update * ENH: import environment variables * ENH: import environment variables from Conda * ENH: include default arguments for `posargs` * ENH: pack strings in single quotation marks in `tox.ini` * ENH: run `cpsell` check at end of `check-dev-files` * ENH: switch to post-release version scheme This avoids git clutter in the editable package version * FEAT: define combined local CI job for Pixi * FIX: specify Pixi environment name in `.envrc` * MAINT: remove redundant quotation marks in `environment.yml` --- .cspell.json | 4 + .envrc | 3 + .gitattributes | 1 + .gitignore | 2 + .vscode/settings.json | 3 + environment.yml | 2 +- pyproject.toml | 49 +++ src/compwa_policy/.template/.cspell.json | 1 + src/compwa_policy/check_dev_files/__init__.py | 12 +- src/compwa_policy/check_dev_files/direnv.py | 61 ++- src/compwa_policy/check_dev_files/pixi.py | 405 ++++++++++++++++++ src/compwa_policy/utilities/__init__.py | 2 + .../utilities/pyproject/__init__.py | 46 +- tests/check_dev_files/test_pixi.py | 44 ++ 14 files changed, 611 insertions(+), 24 deletions(-) create mode 100644 .gitattributes create mode 100644 src/compwa_policy/check_dev_files/pixi.py create mode 100644 tests/check_dev_files/test_pixi.py diff --git a/.cspell.json b/.cspell.json index 247e5db8..92978179 100644 --- a/.cspell.json +++ b/.cspell.json @@ -26,6 +26,7 @@ "*.rst_t", ".editorconfig", ".envrc", + ".gitattributes", ".gitignore", ".gitpod.*", ".pre-commit-config.yaml", @@ -75,6 +76,9 @@ "commitlint", "conda", "direnv", + "doclive", + "docnb", + "docnblive", "envrc", "fromdict", "indentless", diff --git a/.envrc b/.envrc index a00cf019..e1d415a5 100644 --- a/.envrc +++ b/.envrc @@ -2,6 +2,9 @@ if [ -e .venv ]; then source .venv/bin/activate elif [ -e venv ]; then source venv/bin/activate +elif [ -e .pixi ]; then + watch_file pixi.lock + eval "$(pixi shell-hook)" else layout anaconda fi diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..18f6c7e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +pixi.lock linguist-language=YAML linguist-generated=true diff --git a/.gitignore b/.gitignore index 9ea7ce1e..b9b70df8 100644 --- a/.gitignore +++ b/.gitignore @@ -35,8 +35,10 @@ prof/ # Virtual environments *venv/ +.pixi/ .tox/ pyvenv*/ +pixi.lock # Settings .idea/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 8fd9d604..6cb7c6d2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,6 +33,9 @@ "cSpell.enabled": true, "diffEditor.experimental.showMoves": true, "editor.formatOnSave": true, + "files.associations": { + "**/pixi.lock": "yaml" + }, "files.watcherExclude": { "**/*_cache/**": true, "**/.eggs/**": true, diff --git a/environment.yml b/environment.yml index 72ddb419..0fe9bac9 100644 --- a/environment.yml +++ b/environment.yml @@ -7,4 +7,4 @@ dependencies: - pip: - -e .[dev] variables: - PRETTIER_LEGACY_CLI: "1" + PRETTIER_LEGACY_CLI: 1 diff --git a/pyproject.toml b/pyproject.toml index 3c7d10f0..11457715 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dev = [ "pydeps", "sphinx-autobuild", "tox >=1.9", # for skip_install, use_develop + 'sphinx-autobuild!=2024.4.*; python_version <"3.10.0"', ] doc = [ "Sphinx", @@ -115,6 +116,8 @@ namespaces = false where = ["src"] [tool.setuptools_scm] +local_scheme = "no-local-version" +version_scheme = "post-release" write_to = "src/compwa_policy/version.py" [tool.coverage.run] @@ -147,6 +150,52 @@ module = ["ruamel.*"] ignore_missing_imports = true module = ["nbformat.*"] +[tool.pixi.project] +channels = ["conda-forge"] +platforms = ["linux-64"] + +[tool.pixi.activation] +env = {PRETTIER_LEGACY_CLI = "1"} + +[tool.pixi.dependencies] +python = "3.9.*" + +[tool.pixi.environments] +default = {features = [ + "dev", + "doc", + "sty", + "test", + "types", +]} + +[tool.pixi.feature.dev.tasks.ci] +depends_on = ["cov", "doc", "linkcheck", "sty"] + +[tool.pixi.feature.dev.tasks.cov] +cmd = "tox -e cov" + +[tool.pixi.feature.dev.tasks.doc] +cmd = "tox -e doc" + +[tool.pixi.feature.dev.tasks.doclive] +cmd = "tox -e doclive" + +[tool.pixi.feature.dev.tasks.linkcheck] +cmd = "tox -e linkcheck" + +[tool.pixi.feature.dev.tasks.pydeps] +cmd = "tox -e pydeps" + +[tool.pixi.feature.dev.tasks.sty] +cmd = "pre-commit run --all-files" + +[tool.pixi.feature.dev.tasks.tests] +cmd = "pytest" + +[tool.pixi.pypi-dependencies] +compwa-policy = {path = ".", editable = true} + [tool.pyright] exclude = [ "**/.git", diff --git a/src/compwa_policy/.template/.cspell.json b/src/compwa_policy/.template/.cspell.json index d264b409..c38ef86b 100644 --- a/src/compwa_policy/.template/.cspell.json +++ b/src/compwa_policy/.template/.cspell.json @@ -28,6 +28,7 @@ ".constraints/*.txt", ".editorconfig", ".envrc", + ".gitattributes", ".gitignore", ".gitpod.*", ".mypy.ini", diff --git a/src/compwa_policy/check_dev_files/__init__.py b/src/compwa_policy/check_dev_files/__init__.py index b71b98f2..8ec7b887 100644 --- a/src/compwa_policy/check_dev_files/__init__.py +++ b/src/compwa_policy/check_dev_files/__init__.py @@ -22,6 +22,7 @@ jupyter, mypy, nbstripout, + pixi, precommit, prettier, pyright, @@ -57,9 +58,7 @@ def main(argv: Sequence[str] | None = None) -> int: do(citation.main, precommit_config) do(commitlint.main) do(conda.main, dev_python_version) - do(cspell.main, precommit_config, args.no_cspell_update) do(dependabot.main, args.dependabot) - do(direnv.main) do(editorconfig.main, precommit_config) if not args.allow_labels: do(github_labels.main) @@ -82,6 +81,8 @@ def main(argv: Sequence[str] | None = None) -> int: if has_notebooks: do(jupyter.main, args.no_ruff) do(nbstripout.main, precommit_config, _to_list(args.allowed_cell_metadata)) + do(pixi.main, is_python_repo, dev_python_version, args.outsource_pixi_to_tox) + do(direnv.main) do(toml.main, precommit_config) # has to run before pre-commit do(prettier.main, precommit_config, args.no_prettierrc) if is_python_repo: @@ -112,6 +113,7 @@ def main(argv: Sequence[str] | None = None) -> int: do(gitpod.main, args.no_gitpod, dev_python_version) do(precommit.main, precommit_config, has_notebooks) do(tox.main, has_notebooks) + do(cspell.main, precommit_config, args.no_cspell_update) return 1 if do.error_messages else 0 @@ -178,6 +180,12 @@ def _create_argparse() -> ArgumentParser: action="store_true", default=False, ) + parser.add_argument( + "--outsource-pixi-to-tox", + action="store_true", + default=False, + help="Run ", + ) parser.add_argument( "--no-cspell-update", action="store_true", diff --git a/src/compwa_policy/check_dev_files/direnv.py b/src/compwa_policy/check_dev_files/direnv.py index d21850ff..ba90b0ab 100644 --- a/src/compwa_policy/check_dev_files/direnv.py +++ b/src/compwa_policy/check_dev_files/direnv.py @@ -4,37 +4,60 @@ from textwrap import dedent, indent +import rtoml + +from compwa_policy.check_dev_files.pixi import has_pixi_config from compwa_policy.errors import PrecommitError from compwa_policy.utilities import CONFIG_PATH from compwa_policy.utilities.pyproject import Pyproject -__SCRIPTS = { - "conda": "layout anaconda", - "pixi": """ - watch_file pixi.lock - eval "$(pixi shell-hook)" - """, - "venv": "source venv/bin/activate", - "uv-venv": "source .venv/bin/activate", -} - def main() -> None: statements: list[tuple[str | None, str]] = [ - (".venv", __SCRIPTS["uv-venv"]), - ("venv", __SCRIPTS["venv"]), + (".venv", "source .venv/bin/activate"), + ("venv", "source venv/bin/activate"), ] - if ( - CONFIG_PATH.pixi_lock.exists() - or CONFIG_PATH.pixi_toml.exists() - or (CONFIG_PATH.pyproject.exists() and Pyproject.load().has_table("tool.pixi")) - ): - statements.append((".pixi", __SCRIPTS["pixi"])) + if has_pixi_config(): + dev_environment = __determine_pixi_dev_environment() + if dev_environment is None: + environment_flag = "" + else: + environment_flag = f" --environment {dev_environment}" + script = f""" + watch_file pixi.lock + eval "$(pixi shell-hook{environment_flag})" + """ + statements.append((".pixi", script)) if CONFIG_PATH.conda.exists(): - statements.append((None, __SCRIPTS["conda"])) + statements.append((None, "layout anaconda")) _update_envrc(statements) +def __determine_pixi_dev_environment() -> str | None: + search_terms = ["dev"] + if CONFIG_PATH.pyproject.exists(): + pyproject = Pyproject.load() + package_name = pyproject.get_package_name() + if package_name is not None: + search_terms.append(package_name) + available_environments = __get_pixi_environment_names() + for candidate in search_terms: + if candidate in available_environments: + return candidate + return None + + +def __get_pixi_environment_names() -> set[str]: + if CONFIG_PATH.pixi_toml.exists(): + pixi_config = rtoml.load(CONFIG_PATH.pixi_toml) + return set(pixi_config.get("environments", set())) + if CONFIG_PATH.pyproject.exists(): + pyproject = Pyproject.load() + if pyproject.has_table("tool.pixi.environments"): + return set(pyproject.get_table("tool.pixi.environments")) + return set() + + def _update_envrc(statements: list[tuple[str | None, str]]) -> None: expected = "" for i, (trigger_path, script) in enumerate(statements): diff --git a/src/compwa_policy/check_dev_files/pixi.py b/src/compwa_policy/check_dev_files/pixi.py new file mode 100644 index 00000000..0df36bd8 --- /dev/null +++ b/src/compwa_policy/check_dev_files/pixi.py @@ -0,0 +1,405 @@ +"""Update pixi implementation.""" + +from __future__ import annotations + +import re +from textwrap import dedent +from typing import TYPE_CHECKING + +import yaml +from tomlkit import inline_table, string + +from compwa_policy.errors import PrecommitError +from compwa_policy.utilities import CONFIG_PATH, vscode +from compwa_policy.utilities.cfg import open_config +from compwa_policy.utilities.executor import Executor +from compwa_policy.utilities.match import filter_files +from compwa_policy.utilities.pyproject import ( + ModifiablePyproject, + Pyproject, + complies_with_subset, +) +from compwa_policy.utilities.toml import to_toml_array + +if TYPE_CHECKING: + from configparser import ConfigParser + from pathlib import Path + + from tomlkit.items import InlineTable, String, Table + + from compwa_policy.utilities.pyproject.getters import PythonVersion + + +def main( + is_python_package: bool, + dev_python_version: PythonVersion, + outsource_pixi_to_tox: bool, +) -> None: + with Executor() as do, ModifiablePyproject.load() as pyproject: + do(_configure_setuptools_scm, pyproject) + do(_define_minimal_project, pyproject) + if is_python_package: + do(_install_package_editable, pyproject) + do(_import_conda_dependencies, pyproject) + do(_import_conda_environment, pyproject) + do(_import_tox_tasks, pyproject) + do(_define_combined_ci_job, pyproject) + do(_set_dev_python_version, pyproject, dev_python_version) + do(_update_dev_environment, pyproject) + do(_clean_up_task_env, pyproject) + do(_update_docnb_and_doclive, pyproject, "tool.pixi.tasks") + do(_update_docnb_and_doclive, pyproject, "tool.pixi.feature.dev.tasks") + do( + vscode.update_settings, + {"files.associations": {"**/pixi.lock": "yaml"}}, + ) + if outsource_pixi_to_tox: + do(_outsource_pixi_tasks_to_tox, pyproject) + if has_pixi_config(pyproject): + do(_update_gitattributes) + do(_update_gitignore) + + +def _configure_setuptools_scm(pyproject: ModifiablePyproject) -> None: + """Configure :code:`setuptools_scm` to not include git info in package version.""" + if not pyproject.has_table("tool.setuptools_scm"): + return + setuptools_scm = pyproject.get_table("tool.setuptools_scm") + expected_scheme = { + "local_scheme": "no-local-version", + "version_scheme": "post-release", + } + if not complies_with_subset(setuptools_scm, expected_scheme): + setuptools_scm.update(expected_scheme) + msg = "Configured setuptools_scm to not include git info in package version for pixi" + pyproject.append_to_changelog(msg) + + +def _define_combined_ci_job(pyproject: ModifiablePyproject) -> None: + if not pyproject.has_table("tool.pixi.feature.dev.tasks"): + return + tasks = set(pyproject.get_table("tool.pixi.feature.dev.tasks")) + expected = {"linkcheck", "sty"} & tasks + if {"cov", "coverage"} & tasks: + expected.add("cov") + elif "tests" in tasks: + expected.add("tests") + if "docnb" in tasks: # cspelL:ignore docnb + expected.add("docnb") + elif "doc" in tasks: + expected.add("doc") + ci = pyproject.get_table("tool.pixi.feature.dev.tasks.ci", create=True) + existing = set(ci.get("depends_on", set())) + if not expected <= existing: + depends_on = expected | existing & tasks + ci["depends_on"] = to_toml_array(sorted(depends_on), multiline=False) + msg = "Updated combined CI job for Pixi" + pyproject.append_to_changelog(msg) + + +def _define_minimal_project(pyproject: ModifiablePyproject) -> None: + """Create a minimal Pixi project definition if it does not exist.""" + settings = pyproject.get_table("tool.pixi.project", create=True) + minimal_settings = dict( + channels=["conda-forge"], + platforms=["linux-64"], + ) + if not complies_with_subset(settings, minimal_settings, exact_value_match=False): + settings.update(minimal_settings) + msg = "Defined minimal Pixi project settings" + pyproject.append_to_changelog(msg) + + +def _import_conda_dependencies(pyproject: ModifiablePyproject) -> None: + if not CONFIG_PATH.conda.exists(): + return + with CONFIG_PATH.conda.open() as stream: + conda = yaml.safe_load(stream) + conda_dependencies = conda.get("dependencies", []) + if not conda_dependencies: + return + blacklisted_dependencies = {"pip"} + expected_dependencies = {} + for dep in conda.get("dependencies", []): + if not isinstance(dep, str): + continue + package, version = __to_pixi_dependency(dep) + if package in blacklisted_dependencies: + continue + expected_dependencies[package] = version + dependencies = pyproject.get_table("tool.pixi.dependencies", create=True) + if not complies_with_subset(dependencies, expected_dependencies): + dependencies.update(expected_dependencies) + msg = "Imported conda dependencies into Pixi" + pyproject.append_to_changelog(msg) + + +def __to_pixi_dependency(conda_dependency: str) -> tuple[str, str]: + """Extract package name and version from a conda dependency string. + + >>> __to_pixi_dependency("julia") + ('julia', '*') + >>> __to_pixi_dependency("python==3.9.*") + ('python', '3.9.*') + >>> __to_pixi_dependency("graphviz # for binder") + ('graphviz', '*') + >>> __to_pixi_dependency("pip > 19 # needed") + ('pip', '>19') + >>> __to_pixi_dependency("compwa-policy!= 3.14") + ('compwa-policy', '!=3.14') + >>> __to_pixi_dependency("my_package~=1.2") + ('my_package', '~=1.2') + """ + matches = re.match(r"^([a-zA-Z0-9_-]+)([\!<=>~\s]*)([^ ^#]*)", conda_dependency) + if not matches: + msg = f"Could not extract package name and version from {conda_dependency}" + raise ValueError(msg) + package, operator, version = matches.groups() + if not version: + version = "*" + if operator in {"=", "=="}: + operator = "" + return package.strip(), f"{operator.strip()}{version.strip()}" + + +def _import_conda_environment(pyproject: ModifiablePyproject) -> None: + if not CONFIG_PATH.conda.exists(): + return + with CONFIG_PATH.conda.open() as stream: + conda = yaml.safe_load(stream) + conda_variables = {k: str(v) for k, v in conda.get("variables", {}).items()} + if not conda_variables: + return + activation_table = pyproject.get_table("tool.pixi.activation", create=True) + pixi_variables = dict(activation_table.get("env", {})) + if not complies_with_subset(pixi_variables, conda_variables): + new_env = pixi_variables + new_env.update(conda_variables) + activation_table["env"] = new_env + msg = "Imported conda environment variables for Pixi" + pyproject.append_to_changelog(msg) + + +def _import_tox_tasks(pyproject: ModifiablePyproject) -> None: + if not CONFIG_PATH.tox.exists(): + return + tox = open_config(CONFIG_PATH.tox) + tox_jobs = __get_tox_job_names(tox) + imported_tasks = [] + blacklisted_jobs = {"jcache"} # cspell:ignore jcache + for job_name, task_name in tox_jobs.items(): + if job_name in blacklisted_jobs: + continue + pixi_table_name = f"tool.pixi.feature.dev.tasks.{task_name}" + if pyproject.has_table(pixi_table_name): + continue + section = f"testenv:{job_name}" if job_name else "testenv" + if not tox.has_option(section, "commands"): + continue + command = tox.get(section, option="commands", raw=True) + pixi_table = pyproject.get_table(pixi_table_name, create=True) + pixi_table["cmd"] = __to_pixi_command(command) + if tox.has_option(section, "setenv"): # cspell:ignore setenv + job_environment = tox.get(section, option="setenv", raw=True) + environment_variables = __convert_tox_environment_variables(job_environment) + if environment_variables: + pixi_table["env"] = environment_variables + imported_tasks.append(task_name) + if imported_tasks: + msg = f"Imported the following tox jobs: {', '.join(sorted(imported_tasks))}" + pyproject.append_to_changelog(msg) + + +def __get_tox_job_names(cfg: ConfigParser) -> dict[str, str]: + tox_jobs = [ + section[8:] + for section in cfg.sections() + if section.startswith("testenv") # cspell:ignore testenv + ] + return {job: job or "tests" for job in tox_jobs} + + +def __convert_tox_environment_variables(tox_env: str) -> InlineTable: + lines = tox_env.splitlines() + lines = [s.strip() for s in lines] + lines = [s for s in lines if s] + environment_variables = inline_table() + for line in lines: + key, value = line.split("=", 1) + key = key.strip() + if not key: + continue + environment_variables[key] = string(value.strip()) + return environment_variables + + +def _clean_up_task_env(pyproject: ModifiablePyproject) -> None: + if not pyproject.has_table("tool.pixi.feature.dev.tasks"): + return + global_env = __load_pixi_environment_variables(pyproject) + tasks = pyproject.get_table("tool.pixi.feature.dev.tasks") + updated_tasks = [] + for task_name, task_table in tasks.items(): + local_env = task_table.get("env", {}) + if not local_env: + continue + expected = inline_table() + expected.update({k: v for k, v in local_env.items() if v != global_env.get(k)}) + if local_env != expected: + if expected: + task_table["env"] = expected + else: + del task_table["env"] + updated_tasks.append(task_name) + if updated_tasks: + msg = f"Removed redundant environment variables from Pixi tasks {', '.join(updated_tasks)}" + pyproject.append_to_changelog(msg) + + +def __load_pixi_environment_variables(pyproject: Pyproject) -> dict[str, str]: + if not pyproject.has_table("tool.pixi.activation"): + return {} + activation_table = pyproject.get_table("tool.pixi.activation", create=True) + return dict(activation_table.get("env", {})) + + +def __to_pixi_command(tox_command: str) -> String: + """Convert a tox command to a Pixi command. + + >>> __to_pixi_command("pytest {posargs}") + 'pytest' + >>> __to_pixi_command("pytest {posargs:benchmarks}") + 'pytest benchmarks' + >>> __to_pixi_command("pytest {posargs src tests}") + 'pytest src tests' + """ + # cspell:ignore posargs + tox_command = re.sub(r"\s*{posargs:?\s*([^}]*)}", r" \1", tox_command) + pixi_command = dedent(tox_command).strip() + if "\n" in pixi_command: + pixi_command = "\n" + pixi_command + "\n" + pixi_command = pixi_command.replace("\\\n", "\\\n" + 4 * " ") + return string(pixi_command, multiline="\n" in pixi_command) + + +def _install_package_editable(pyproject: ModifiablePyproject) -> None: + editable = inline_table() + editable.update({ + "path": ".", + "editable": True, + }) + package_name = pyproject.get_package_name(raise_on_missing=True) + existing = pyproject.get_table("tool.pixi.pypi-dependencies", create=True) + if dict(existing.get(package_name, {})) != dict(editable): + existing[package_name] = editable + msg = "Installed Python package in editable mode in Pixi" + pyproject.append_to_changelog(msg) + + +def _outsource_pixi_tasks_to_tox(pyproject: ModifiablePyproject) -> None: + if not CONFIG_PATH.tox.exists(): + return + tox = open_config(CONFIG_PATH.tox) + blacklisted_jobs = {"sty"} + updated_tasks = [] + for tox_job, pixi_task in __get_tox_job_names(tox).items(): + if pixi_task in blacklisted_jobs: + continue + if not pyproject.has_table(f"tool.pixi.feature.dev.tasks.{pixi_task}"): + continue + task = pyproject.get_table(f"tool.pixi.feature.dev.tasks.{pixi_task}") + expected_cmd = f"tox -e {tox_job}" + if task.get("cmd") != expected_cmd: + task["cmd"] = expected_cmd + task.pop("env", None) + updated_tasks.append(pixi_task) + if updated_tasks: + msg = f"Outsourced Pixi tasks to tox: {', '.join(updated_tasks)}" + pyproject.append_to_changelog(msg) + + +def _set_dev_python_version( + pyproject: ModifiablePyproject, dev_python_version: PythonVersion +) -> None: + dependencies = pyproject.get_table("tool.pixi.dependencies", create=True) + version = f"{dev_python_version}.*" + if dependencies.get("python") != version: + dependencies["python"] = version + msg = f"Set Python version for Pixi developer environment to {version}" + pyproject.append_to_changelog(msg) + + +def _update_gitattributes() -> None: + expected_line = "pixi.lock linguist-language=YAML linguist-generated=true" + msg = f"Added linguist definition for pixi.lock under {CONFIG_PATH.gitattributes}" + return __safe_update_file(expected_line, CONFIG_PATH.gitattributes, msg) + + +def _update_gitignore() -> None: + ignore_path = ".pixi/" + msg = f"Added {ignore_path} under {CONFIG_PATH.gitignore}" + return __safe_update_file(ignore_path, CONFIG_PATH.gitignore, msg) + + +def __safe_update_file(expected_line: str, path: Path, msg: str) -> None: + if path.exists() and __contains_line(path, expected_line): + return + with path.open("a") as stream: + stream.write(expected_line + "\n") + raise PrecommitError(msg) + + +def __contains_line(path: Path, expected_line: str) -> bool: + with path.open() as stream: + lines = stream.readlines() + return expected_line in {line.strip() for line in lines} + + +def _update_dev_environment(pyproject: ModifiablePyproject) -> None: + if not pyproject.has_table("project.optional-dependencies"): + return + optional_dependencies = pyproject.get_table("project.optional-dependencies") + expected = inline_table() + expected["features"] = to_toml_array(sorted(optional_dependencies)) + environments = pyproject.get_table("tool.pixi.environments", create=True) + if environments.get("default") != expected: + environments["default"] = expected + msg = "Updated Pixi developer environment" + pyproject.append_to_changelog(msg) + + +def _update_docnb_and_doclive(pyproject: ModifiablePyproject, table_key: str) -> None: + if not pyproject.has_table(table_key): + return + tasks = pyproject.get_table(table_key) + tables_to_overwrite = { + "doc": ["docnb", "docnb-force"], + "doclive": ["docnblive"], + } + updated_tasks = [] + for template_task_name, target_task_names in tables_to_overwrite.items(): + for task_name in target_task_names: + task = tasks.get(task_name) + if task is None: + continue + if __outsource_cmd(task, template_task_name): + updated_tasks.append(task_name) + if updated_tasks: + msg = f"Updated `cmd` of Pixi tasks {', '.join(updated_tasks)}" + pyproject.append_to_changelog(msg) + + +def __outsource_cmd(task: Table, other_task_name: str) -> bool: + expected_cmd = f"pixi run {other_task_name}" + if task.get("cmd") != expected_cmd: + task["cmd"] = expected_cmd + return True + return False + + +def has_pixi_config(pyproject: Pyproject | None = None) -> bool: + if filter_files(["pixi.lock", "pixi.toml"]): + return True + if pyproject is not None: + return pyproject.has_table("tool.pixi") + return CONFIG_PATH.pyproject.exists() and Pyproject.load().has_table("tool.pixi") diff --git a/src/compwa_policy/utilities/__init__.py b/src/compwa_policy/utilities/__init__.py index 6d199336..ee93dfa4 100644 --- a/src/compwa_policy/utilities/__init__.py +++ b/src/compwa_policy/utilities/__init__.py @@ -23,7 +23,9 @@ class _ConfigFilePaths(NamedTuple): cspell: Path = Path(".cspell.json") editorconfig: Path = Path(".editorconfig") envrc: Path = Path(".envrc") + gitattributes: Path = Path(".gitattributes") github_workflow_dir: Path = Path(".github/workflows") + gitignore: Path = Path(".gitignore") gitpod: Path = Path(".gitpod.yml") pip_constraints: Path = Path(".constraints") pixi_lock: Path = Path("pixi.lock") diff --git a/src/compwa_policy/utilities/pyproject/__init__.py b/src/compwa_policy/utilities/pyproject/__init__.py index 4d696558..a05ec440 100644 --- a/src/compwa_policy/utilities/pyproject/__init__.py +++ b/src/compwa_policy/utilities/pyproject/__init__.py @@ -4,6 +4,7 @@ import io import sys +from collections import abc from contextlib import AbstractContextManager from pathlib import Path from textwrap import indent @@ -231,8 +232,49 @@ def append_to_changelog(self, message: str) -> None: self._changelog.append(message) -def complies_with_subset(settings: Mapping, minimal_settings: Mapping) -> bool: - return all(settings.get(key) == value for key, value in minimal_settings.items()) +def complies_with_subset( + settings: Mapping, + minimal_settings: Mapping, + *, + exact_value_match: bool = True, +) -> bool: + """Compare if a nested mapping fits inside another nested mapping. + + >>> complies_with_subset( + ... {"channels": ["conda-forge"]}, + ... {"channels": ["conda-forge"], "platforms": ["linux-64"]}, + ... ) + False + >>> complies_with_subset( + ... {"channels": ["conda-forge"], "platforms": ["linux-64"]}, + ... {"channels": ["conda-forge"]}, + ... ) + True + >>> complies_with_subset( + ... {"channels": ["conda-forge", "default"]}, + ... {"channels": ["conda-forge"]}, + ... exact_value_match=False, + ... ) + True + """ + if exact_value_match: + return all( + settings.get(key) == expected for key, expected in minimal_settings.items() + ) + for key, expected in minimal_settings.items(): + if not _complies_minimally(settings.get(key), expected): + return False + return True + + +def _complies_minimally(obj: Any, other: Any) -> bool: + if isinstance(other, abc.Mapping): + return complies_with_subset(obj, other, exact_value_match=False) + if isinstance(other, str): + return obj == other + if isinstance(obj, abc.Iterable): + return set(other) <= set(obj) + return obj == other def get_build_system() -> Literal["pyproject", "setup.cfg"] | None: diff --git a/tests/check_dev_files/test_pixi.py b/tests/check_dev_files/test_pixi.py new file mode 100644 index 00000000..707fc5aa --- /dev/null +++ b/tests/check_dev_files/test_pixi.py @@ -0,0 +1,44 @@ +from textwrap import dedent + +import pytest + +from compwa_policy.check_dev_files.pixi import _update_docnb_and_doclive +from compwa_policy.errors import PrecommitError +from compwa_policy.utilities.pyproject import ModifiablePyproject + + +@pytest.mark.parametrize( + "table_key", + [ + "tool.pixi.feature.dev.tasks", + "tool.pixi.tasks", + ], +) +def test_update_docnb_and_doclive(table_key: str): + content = dedent(f""" + [{table_key}.doc] + cmd = "command executed by doc" + + [{table_key}.docnb] + cmd = "some outdated command" + + [{table_key}.docnb-test] + cmd = "should not change" + """) + with pytest.raises( + PrecommitError, + match="Updated `cmd` of Pixi tasks docnb", + ), ModifiablePyproject.load(content) as pyproject: + _update_docnb_and_doclive(pyproject, table_key) + new_content = pyproject.dumps() + expected = dedent(f""" + [{table_key}.doc] + cmd = "command executed by doc" + + [{table_key}.docnb] + cmd = "pixi run doc" + + [{table_key}.docnb-test] + cmd = "should not change" + """) + assert new_content.strip() == expected.strip()