diff --git a/docs/advanced/pyproject_toml.md b/docs/advanced/pyproject_toml.md index 266e55505..8197f7579 100644 --- a/docs/advanced/pyproject_toml.md +++ b/docs/advanced/pyproject_toml.md @@ -103,6 +103,49 @@ matplotlib = "*" This would result in the conda dependencies being installed and the pypi dependencies being ignored. As pixi takes the conda dependencies over the pypi dependencies. +## Optional dependencies +If your python project includes groups of optional dependencies, pixi will automatically interpret them as [pixi features](../configuration.md#the-feature-table) of the same name with the associated `pypi-dependencies`. + +You can add them to pixi environments manually, or use `pixi init` to setup the project, which will create one environment per feature. Self-references to other groups of optional dependencies are also handled. + +For instance, imagine you have a project folder with a `pyproject.toml` file similar to: +```toml +[project] +name = "my_project" +dependencies = ["package1"] + +[project.optional-dependencies] +test = ["pytest"] +all = ["package2","my_project[test]"] +``` + +Running `pixi init` in that project folder will transform the `pyproject.toml` file into: +```toml +[project] +name = "my_project" +dependencies = ["package1"] + +[project.optional-dependencies] +test = ["pytest"] +all = ["package2","my_project[test]"] + +[tool.pixi.project] +channels = ["conda-forge"] +platforms = ["linux-64"] # if executed on linux + +[tool.pixi.environments] +default = {features = [], solve-group = "default"} +test = {features = ["test"], solve-group = "default"} +all = {features = ["all", "test"], solve-group = "default"} +``` +In this example, three environments will be created by pixi: + + - **default** with 'package1' as pypi dependency + - **test** with 'package1' and 'pytest' as pypi dependencies + - **all** with 'package1', 'package2' and 'pytest' as pypi dependencies + +All environments will be solved together, as indicated by the common `solve-group`, and added to the lock file. You can edit the `[tool.pixi.environments]` section manually to adapt it to your use case (e.g. if you do not need a particular environment). + ## Example As the `pyproject.toml` file supports the full pixi spec with `[tool.pixi]` prepended an example would look like this: ```toml title="pyproject.toml" diff --git a/examples/flask-hello-world-pyproject/.gitattributes b/examples/flask-hello-world-pyproject/.gitattributes new file mode 100644 index 000000000..d5799bd69 --- /dev/null +++ b/examples/flask-hello-world-pyproject/.gitattributes @@ -0,0 +1,2 @@ +# GitHub syntax highlighting +pixi.lock linguist-language=YAML diff --git a/examples/flask-hello-world-pyproject/.gitignore b/examples/flask-hello-world-pyproject/.gitignore index 11041c783..096b5eb54 100644 --- a/examples/flask-hello-world-pyproject/.gitignore +++ b/examples/flask-hello-world-pyproject/.gitignore @@ -1 +1,3 @@ +# pixi environments +.pixi *.egg-info diff --git a/examples/flask-hello-world-pyproject/pixi.lock b/examples/flask-hello-world-pyproject/pixi.lock index 709004e6f..ca0892a8e 100644 --- a/examples/flask-hello-world-pyproject/pixi.lock +++ b/examples/flask-hello-world-pyproject/pixi.lock @@ -1,6 +1,109 @@ version: 4 environments: default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h41732ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.6.2-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h807b86a_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h807b86a_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.2-h2797004_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.4.20240210-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.2.1-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.2-hab00c5b_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/fa/2a/7f3714cbc6356a0efec525ce7a0613d581072ed6eb53eb7b9754f33db807/blinker-1.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/56/26f0be8adc2b4257df20c1c4260ddd0aa396cf8e75d90ab2f7ff99bc34f9/flask-2.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/68/5f/447e04e828f47465eeab35b5d408b7ebaaaee207f48b7136c5a7267a30ae/itsdangerous-2.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/6d/6de6be2d02603ab56e72997708809e8a5b0fbfee080735109b40a3564843/Jinja2-3.1.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e3/23/c9843d7550092ae7ad380611c238f44afef66f58f76c1dab7dcf313e4339/werkzeug-3.0.2-py3-none-any.whl + - pypi: . + osx-64: + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h10d778d_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ca-certificates-2024.2.2-h8857fd0_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.6.2-h73e2aa4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.4.2-h0d85af4_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.45.2-h92b6c6a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.2.13-h8a1eda9_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.4.20240210-h73e2aa4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.2.1-hd75f5a5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.12.2-h9f0c242_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h9e318b2_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h1abcd95_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/xz-5.2.6-h775f41a_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/fa/2a/7f3714cbc6356a0efec525ce7a0613d581072ed6eb53eb7b9754f33db807/blinker-1.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/56/26f0be8adc2b4257df20c1c4260ddd0aa396cf8e75d90ab2f7ff99bc34f9/flask-2.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/68/5f/447e04e828f47465eeab35b5d408b7ebaaaee207f48b7136c5a7267a30ae/itsdangerous-2.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/6d/6de6be2d02603ab56e72997708809e8a5b0fbfee080735109b40a3564843/Jinja2-3.1.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/e3/23/c9843d7550092ae7ad380611c238f44afef66f58f76c1dab7dcf313e4339/werkzeug-3.0.2-py3-none-any.whl + - pypi: . + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.6.2-hebf3989_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.2-h091b4b1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-h53f4e23_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.4.20240210-h078ce10_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.2.1-h0d3ecfb_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.2-hdf0ec26_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/fa/2a/7f3714cbc6356a0efec525ce7a0613d581072ed6eb53eb7b9754f33db807/blinker-1.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/56/26f0be8adc2b4257df20c1c4260ddd0aa396cf8e75d90ab2f7ff99bc34f9/flask-2.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/68/5f/447e04e828f47465eeab35b5d408b7ebaaaee207f48b7136c5a7267a30ae/itsdangerous-2.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/6d/6de6be2d02603ab56e72997708809e8a5b0fbfee080735109b40a3564843/Jinja2-3.1.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl + - pypi: https://files.pythonhosted.org/packages/e3/23/c9843d7550092ae7ad380611c238f44afef66f58f76c1dab7dcf313e4339/werkzeug-3.0.2-py3-none-any.whl + - pypi: . + win-64: + - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-hcfcfb64_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ca-certificates-2024.2.2-h56e8100_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.6.2-h63175ca_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.4.2-h8ffe710_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.45.2-hcfcfb64_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.2.13-hcfcfb64_5.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.2.1-hcfcfb64_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.2-h2628c8c_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h5226925_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.22621.0-h57928b3_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.3-hcf57466_18.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.38.33130-h82b7239_18.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vs2015_runtime-14.38.33130-hcb4865c_18.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/xz-5.2.6-h8d14728_0.tar.bz2 + - pypi: https://files.pythonhosted.org/packages/fa/2a/7f3714cbc6356a0efec525ce7a0613d581072ed6eb53eb7b9754f33db807/blinker-1.7.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fd/56/26f0be8adc2b4257df20c1c4260ddd0aa396cf8e75d90ab2f7ff99bc34f9/flask-2.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/68/5f/447e04e828f47465eeab35b5d408b7ebaaaee207f48b7136c5a7267a30ae/itsdangerous-2.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/6d/6de6be2d02603ab56e72997708809e8a5b0fbfee080735109b40a3564843/Jinja2-3.1.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/e3/23/c9843d7550092ae7ad380611c238f44afef66f58f76c1dab7dcf313e4339/werkzeug-3.0.2-py3-none-any.whl + - pypi: . + test: channels: - url: https://conda.anaconda.org/conda-forge/ packages: @@ -291,10 +394,10 @@ packages: name: flask-hello-world-pyproject version: 0.1.0 path: . - sha256: 472d48341f3660c59666006a765c70932272cb4b0a1017c66b1d5ae8ab7778d0 + sha256: abf0f05892c27341f38a76b6fb0b65003e78b083fdda7721519f047503327870 requires_dist: - flask ==2.* - - pytest + - pytest ; extra == 'test' requires_python: '>=3.11' editable: true - kind: pypi @@ -649,12 +752,6 @@ packages: url: https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl sha256: 823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb requires_python: '>=3.7' -- kind: pypi - name: markupsafe - version: 2.1.5 - url: https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl - sha256: 3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4 - requires_python: '>=3.7' - kind: pypi name: markupsafe version: 2.1.5 @@ -667,6 +764,12 @@ packages: url: https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl sha256: 8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1 requires_python: '>=3.7' +- kind: pypi + name: markupsafe + version: 2.1.5 + url: https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl + sha256: 3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4 + requires_python: '>=3.7' - kind: conda name: ncurses version: 6.4.20240210 diff --git a/examples/flask-hello-world-pyproject/pyproject.toml b/examples/flask-hello-world-pyproject/pyproject.toml index 7fecef713..4bc8a9be4 100644 --- a/examples/flask-hello-world-pyproject/pyproject.toml +++ b/examples/flask-hello-world-pyproject/pyproject.toml @@ -4,7 +4,10 @@ version = "0.1.0" description = "Example how to get started with flask in a pixi environment." readme = "README.md" requires-python = ">=3.11" -dependencies = ["flask==2.*", "pytest"] +dependencies = ["flask==2.*"] + +[project.optional-dependencies] +test = ["pytest"] [build-system] requires = ["setuptools", "wheel"] @@ -17,6 +20,12 @@ platforms = ["linux-64", "osx-arm64", "osx-64", "win-64"] [tool.pixi.pypi-dependencies] flask-hello-world-pyproject = { path = ".", editable = true } +[tool.pixi.environments] +default = { features = [], solve-group = "default" } +test = { features = ["test"], solve-group = "default" } + [tool.pixi.tasks] start = "python -m flask --app flask_hello_world_pyproject.app:app run --port=5050" + +[tool.pixi.feature.test.tasks] test = "pytest -v tests/*" diff --git a/src/cli/init.rs b/src/cli/init.rs index d7bbc24dd..e6f075d06 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,5 +1,6 @@ use crate::config::Config; use crate::environment::{get_up_to_date_prefix, LockFileUsage}; +use crate::project::manifest::pyproject; use crate::utils::conda_environment_file::CondaEnvFile; use crate::{config::get_default_author, consts}; use crate::{FeatureName, Project}; @@ -7,7 +8,6 @@ use clap::Parser; use indexmap::IndexMap; use miette::IntoDiagnostic; use minijinja::{context, Environment}; -use pyproject_toml::PyProjectToml; use rattler_conda_types::Platform; use std::io::{Error, ErrorKind, Write}; use std::path::Path; @@ -43,8 +43,8 @@ description = "Add a short description here" {%- if author %} authors = ["{{ author[0] }} <{{ author[1] }}>"] {%- endif %} -channels = [{%- if channels %}"{{ channels|join("\", \"") }}"{%- endif %}] -platforms = ["{{ platforms|join("\", \"") }}"] +channels = {{ channels }} +platforms = {{ platforms }} [tasks] @@ -55,11 +55,19 @@ platforms = ["{{ platforms|join("\", \"") }}"] /// The pyproject.toml template const PYROJECT_TEMPLATE: &str = r#" [tool.pixi.project] -channels = [{%- if channels %}"{{ channels|join("\", \"") }}"{%- endif %}] -platforms = ["{{ platforms|join("\", \"") }}"] +channels = {{ channels }} +platforms = {{ platforms }} [tool.pixi.pypi-dependencies] {{ name }} = { path = ".", editable = true } +{%- for env, features in environments|items %} +{%- if loop.first %} + +[tool.pixi.environments] +default = { features = [], solve-group = "default" } +{%- endif %} +{{env}} = { features = {{ features }}, solve-group = "default" } +{%- endfor %} "#; @@ -150,44 +158,58 @@ pub async fn execute(args: Args) -> miette::Result<()> { // Inject a tool.pixi.project section into an existing pyproject.toml file if there is one without '[tool.pixi.project]' if pyproject_manifest_path.is_file() { let file = fs::read_to_string(pyproject_manifest_path.clone()).unwrap(); - if !file.contains("[tool.pixi.project]") { - // Get name from the pyproject [project] table - let name = match PyProjectToml::new(file.as_str()) { - Ok(pyproject) => pyproject - .project - .map(|p| p.name) - .expect("'name' should be defined in the [project] table"), - Err(e) => miette::bail!("Failed to parse 'pyproject.toml'. Error is {}", e), - }; - let rv = env - .render_named_str( - consts::PYPROJECT_MANIFEST, - PYROJECT_TEMPLATE, - context! { - name, - channels, - platforms - }, - ) - .unwrap(); - if let Err(e) = { - fs::OpenOptions::new() - .append(true) - .open(pyproject_manifest_path.clone()) - .and_then(|mut p| p.write_all(rv.as_bytes())) - } { - tracing::warn!( - "Warning, couldn't update '{}' because of: {}", - pyproject_manifest_path.to_string_lossy(), - e - ); - } else { - // Inform about the addition of the package itself as an editable dependency of the project + + // Early exit if 'pyproject.toml' already contains a '[tool.pixi.project]' section + if file.contains("[tool.pixi.project]") { + eprintln!( + "{}Nothing to do here: 'pyproject.toml' already contains a '[tool.pixi.project]' section.", + console::style(console::Emoji("🤔 ", "")).blue(), + ); + return Ok(()); + } + + let pyproject = pyproject::pyproject(&file)?; + let name = pyproject.project.as_ref().unwrap().name.clone(); + let environments = pyproject::environments_from_extras(&pyproject); + let rv = env + .render_named_str( + consts::PYPROJECT_MANIFEST, + PYROJECT_TEMPLATE, + context! { + name, + channels, + platforms, + environments, + }, + ) + .unwrap(); + if let Err(e) = { + fs::OpenOptions::new() + .append(true) + .open(pyproject_manifest_path.clone()) + .and_then(|mut p| p.write_all(rv.as_bytes())) + } { + tracing::warn!( + "Warning, couldn't update '{}' because of: {}", + pyproject_manifest_path.to_string_lossy(), + e + ); + } else { + // Inform about the addition of the package itself as an editable dependency of the project + eprintln!( + "{}Added package '{}' as an editable dependency.", + console::style(console::Emoji("✔ ", "")).green(), + name + ); + // Inform about the addition of environments from extras (if any) + if !environments.is_empty() { + let envs: Vec<&str> = environments.keys().map(AsRef::as_ref).collect(); eprintln!( - "{}Added package '{}' as an editable dependency.", + "{}Added environment{} '{}' from optional extras.", console::style(console::Emoji("✔ ", "")).green(), - name - ); + if envs.len() > 1 { "s" } else { "" }, + envs.join("', '") + ) } } diff --git a/src/project/manifest/mod.rs b/src/project/manifest/mod.rs index 104177745..9e7df07d8 100644 --- a/src/project/manifest/mod.rs +++ b/src/project/manifest/mod.rs @@ -5,7 +5,7 @@ mod environment; mod error; mod feature; mod metadata; -mod pyproject; +pub mod pyproject; pub mod python; mod system_requirements; mod target; @@ -751,6 +751,31 @@ impl SolveGroups { pub fn iter(&self) -> impl Iterator + '_ { self.solve_groups.iter() } + + /// Adds an environment (by index) to a solve-group. + /// If the solve-group does not exist, it is created + /// + /// Returns the index of the solve-group + fn add(&mut self, name: &str, environment_idx: usize) -> usize { + match self.by_name.get(name) { + Some(idx) => { + // The solve-group exists, add the environment index to it + self.solve_groups[*idx].environments.push(environment_idx); + *idx + } + None => { + // The solve-group does not exist, create it + // and initialise it with the environment index + let idx = self.solve_groups.len(); + self.solve_groups.push(SolveGroup { + name: name.to_string(), + environments: vec![environment_idx], + }); + self.by_name.insert(name.to_string(), idx); + idx + } + } + } } /// Describes the contents of a project manifest. @@ -1038,9 +1063,6 @@ impl<'de> Deserialize<'de> for ProjectManifest { // Add all named environments for (name, env) in toml_manifest.environments { - let environment_idx = environments.environments.len(); - environments.by_name.insert(name.clone(), environment_idx); - // Decompose the TOML let (features, features_source_loc, solve_group) = match env { TomlEnvironmentMapOrSeq::Map(env) => { @@ -1049,34 +1071,13 @@ impl<'de> Deserialize<'de> for ProjectManifest { TomlEnvironmentMapOrSeq::Seq(features) => (features, None, None), }; - // Add to the solve group if defined - let solve_group = if let Some(solve_group) = solve_group { - Some(match solve_groups.by_name.get(&solve_group) { - Some(idx) => { - solve_groups.solve_groups[*idx] - .environments - .push(environment_idx); - *idx - } - None => { - let idx = solve_groups.solve_groups.len(); - solve_groups.solve_groups.push(SolveGroup { - name: solve_group.clone(), - environments: vec![environment_idx], - }); - solve_groups.by_name.insert(solve_group, idx); - idx - } - }) - } else { - None - }; - + let environment_idx = environments.environments.len(); + environments.by_name.insert(name.clone(), environment_idx); environments.environments.push(Environment { name, features, features_source_loc, - solve_group, + solve_group: solve_group.map(|sg| solve_groups.add(&sg, environment_idx)), }); } diff --git a/src/project/manifest/pyproject.rs b/src/project/manifest/pyproject.rs index eb9a554db..025bc0205 100644 --- a/src/project/manifest/pyproject.rs +++ b/src/project/manifest/pyproject.rs @@ -1,9 +1,16 @@ +use miette::Report; use pep508_rs::VersionOrUrl; +use pyproject_toml::PyProjectToml; use rattler_conda_types::{NamelessMatchSpec, PackageName, ParseStrictness::Lenient, VersionSpec}; use serde::Deserialize; -use std::str::FromStr; +use std::{ + collections::{HashMap, HashSet}, + str::FromStr, +}; use toml_edit; +use crate::FeatureName; + use super::{ error::{RequirementConversionError, TomlError}, python::PyPiPackageName, @@ -13,7 +20,7 @@ use super::{ #[derive(Deserialize, Debug, Clone)] pub struct PyProjectManifest { #[serde(flatten)] - inner: pyproject_toml::PyProjectToml, + inner: PyProjectToml, tool: Tool, } @@ -23,7 +30,7 @@ struct Tool { } impl std::ops::Deref for PyProjectManifest { - type Target = pyproject_toml::PyProjectToml; + type Target = PyProjectToml; fn deref(&self) -> &Self::Target { &self.inner @@ -59,7 +66,7 @@ impl From for ProjectManifest { // Get tool.pixi.project.name from project.name // TODO: could copy across / convert some other optional fields if relevant - manifest.project.name = item.project.as_ref().map(|p| p.name.clone()); + manifest.project.name = Some(pyproject.name.clone()); // Add python as dependency based on the project.requires_python property (if any) let pythonspec = pyproject @@ -84,9 +91,37 @@ impl From for ProjectManifest { } // For each extra group, create a feature of the same name if it does not exist, - // add dependencies and create corresponding environments if they do not exist - // TODO: Add solve groups as well? - // TODO: Deal with self referencing extras? + // and add pypi dependencies from project.optional-dependencies, + // filtering out unused features and self-references + if let Some(extras) = pyproject.optional_dependencies.as_ref() { + let project_name = pep508_rs::PackageName::new(pyproject.name.clone()).unwrap(); + let mut features_used = HashSet::new(); + for env in manifest.environments.iter() { + for feature in env.features.iter() { + features_used.insert(feature); + } + } + for (extra, reqs) in extras { + // Filter out unused features + if features_used.contains(extra) { + let target = manifest + .features + .entry(FeatureName::Named(extra.to_string())) + .or_default() + .targets + .default_mut(); + for req in reqs.iter() { + // filter out any self references in groups of extra dependencies + if project_name != req.name { + target.add_pypi_dependency( + PyPiPackageName::from_normalized(req.name.clone()), + PyPiRequirement::from(req.clone()), + ) + } + } + } + } + } manifest } @@ -112,6 +147,49 @@ fn version_or_url_to_nameless_matchspec( } } +/// Builds a list of pixi environments from pyproject groups of extra dependencies: +/// - one environment is created per group of extra, with the same name as the group of extra +/// - each environment includes the feature of the same name as the group of extra +/// - it will also include other features inferred from any self references to other groups of extras +pub fn environments_from_extras(pyproject: &PyProjectToml) -> HashMap> { + let mut environments = HashMap::new(); + if let Some(Some(extras)) = &pyproject.project.as_ref().map(|p| &p.optional_dependencies) { + let pname = &pyproject + .project + .as_ref() + .map(|p| pep508_rs::PackageName::new(p.name.clone()).unwrap()); + for (extra, reqs) in extras { + let mut features = vec![extra.to_string()]; + // Add any references to other groups of extra dependencies + for req in reqs.iter() { + if pname.as_ref().is_some_and(|n| n == &req.name) { + for extra in &req.extras { + features.push(extra.to_string()) + } + } + } + environments.insert(extra.clone(), features); + } + } + environments +} + +/// Parses a non-pixi pyproject.toml string. +pub fn pyproject(source: &str) -> Result { + match toml_edit::de::from_str::(source).map_err(TomlError::from) { + Err(e) => e.to_fancy("pyproject.toml", source), + Ok(pyproject) => { + // Make sure [project] exists in pyproject.toml, + // This will ensure project.name is defined + if pyproject.project.is_none() { + TomlError::NoProjectTable.to_fancy("pyproject.toml", source) + } else { + Ok(pyproject) + } + } + } +} + #[cfg(test)] mod tests { use std::path::Path;