diff --git a/.gitignore b/.gitignore index 228becc27..b63ca0fca 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ site .pixi target-pixi *.conda + +# Test files generated during pyproject recipe development +*example*.toml +*recipe*.yaml +/recipe/ diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 834d10298..c08ec6c0a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -122,6 +122,11 @@ Build a package from a recipe Variant configuration files for the build +- `--variant ` + + Override specific variant values (e.g. --variant python=3.12 or --variant python=3.12,3.11). Multiple values separated by commas will create multiple build variants + + - `--ignore-recipe-variants` Do not read the `variants.yaml` file next to a recipe @@ -684,6 +689,7 @@ Generate a recipe from PyPI, CRAN, CPAN, or LuaRocks * `cran` — Generate a recipe for an R package from CRAN * `cpan` — Generate a recipe for a Perl package from CPAN * `luarocks` — Generate a recipe for a Lua package from LuaRocks +* `pyproject` — Generate a recipe from a local pyproject.toml file @@ -819,6 +825,59 @@ Generate a recipe for a Lua package from LuaRocks +#### `pyproject` + +Generate a recipe from a local pyproject.toml file + +**Usage:** `rattler-build generate-recipe pyproject [OPTIONS]` + +##### **Options:** + +- `-i`, `--input ` + + Path to the pyproject.toml file (defaults to pyproject.toml in current directory) + + - Default value: `pyproject.toml` + +- `-o`, `--output ` + + Path to write the recipe.yaml file. If not provided, output will be printed to stdout + + +- `--overwrite` + + Whether to overwrite existing recipe file + + +- `--format ` + + Output format: yaml or json + + - Default value: `yaml` + +- `--sort-keys` + + Sort keys in output + + +- `--include-comments` + + Include helpful comments in the output + + +- `--exclude-sections ` + + Exclude specific sections from the output (comma-separated) + + +- `--validate` + + Validate the generated recipe + + + + + ### `auth` Handle authentication to external channels diff --git a/pixi.lock b/pixi.lock index d47657df6..c66789b93 100644 --- a/pixi.lock +++ b/pixi.lock @@ -128,8 +128,8 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/zstandard-0.23.0-py312h66e93f0_2.conda - conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda osx-64: - - conda: https://prefix.dev/conda-forge/noarch/boto3-1.40.13-pyhd8ed1ab_0.conda - - conda: https://prefix.dev/conda-forge/noarch/botocore-1.40.13-pyge310_1234567_0.conda + - conda: https://prefix.dev/conda-forge/noarch/boto3-1.40.18-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/botocore-1.40.18-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/osx-64/brotli-python-1.1.0-py312haafddd8_3.conda - conda: https://prefix.dev/conda-forge/osx-64/bzip2-1.0.8-hfdf4475_7.conda - conda: https://prefix.dev/conda-forge/osx-64/c-ares-1.34.5-hf13058a_0.conda @@ -183,9 +183,9 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/libev-4.33-h10d778d_2.conda - conda: https://prefix.dev/conda-forge/osx-64/libexpat-2.7.1-h21dd04a_0.conda - conda: https://prefix.dev/conda-forge/osx-64/libffi-3.4.6-h281671d_1.conda - - conda: https://prefix.dev/conda-forge/osx-64/libgfortran-15.1.0-h5f6db21_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libgfortran-15.1.0-h5f6db21_1.conda - conda: https://prefix.dev/conda-forge/noarch/libgfortran-devel_osx-64-12.4.0-h3f4828c_105.conda - - conda: https://prefix.dev/conda-forge/osx-64/libgfortran5-15.1.0-hfa3c126_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libgfortran5-15.1.0-hfa3c126_1.conda - conda: https://prefix.dev/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda - conda: https://prefix.dev/conda-forge/osx-64/libllvm15-15.0.7-hc29ff6c_5.conda - conda: https://prefix.dev/conda-forge/osx-64/liblzma-5.8.1-hd471939_2.conda @@ -196,7 +196,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/libuv-1.51.0-h58003a5_1.conda - conda: https://prefix.dev/conda-forge/osx-64/libxml2-2.13.8-he1bc88e_1.conda - conda: https://prefix.dev/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda - - conda: https://prefix.dev/conda-forge/osx-64/llvm-openmp-20.1.8-hf4e0ed4_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/llvm-openmp-20.1.8-hf4e0ed4_2.conda - conda: https://prefix.dev/conda-forge/osx-64/llvm-tools-15.0.7-hc29ff6c_5.conda - conda: https://prefix.dev/conda-forge/osx-64/make-4.3-h22f3db7_1.tar.bz2 - conda: https://prefix.dev/conda-forge/osx-64/mpc-1.3.1-h9d8efa1_1.conda @@ -233,7 +233,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-hf689a15_2.conda - conda: https://prefix.dev/conda-forge/noarch/tomli-2.2.1-pyhe01879c_2.conda - conda: https://prefix.dev/conda-forge/noarch/tomlkit-0.13.3-pyha770c72_0.conda - - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.14.1-pyhe01879c_0.conda + - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - conda: https://prefix.dev/conda-forge/noarch/unidecode-1.3.8-pyh29332c3_1.conda - conda: https://prefix.dev/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda @@ -242,7 +242,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/xz-tools-5.8.1-hd471939_2.conda - conda: https://prefix.dev/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda - conda: https://prefix.dev/conda-forge/osx-64/zlib-1.3.1-hd23fc13_2.conda - - conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.23.0-py312h01d7ebd_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.23.0-py312h2f459f6_3.conda - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-h8210216_2.conda osx-arm64: - conda: https://prefix.dev/conda-forge/noarch/boto3-1.40.13-pyhd8ed1ab_0.conda @@ -612,7 +612,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/fonts-conda-forge-1-0.tar.bz2 - conda: https://prefix.dev/conda-forge/osx-64/freetype-2.13.3-h694c41f_1.conda - conda: https://prefix.dev/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - - conda: https://prefix.dev/conda-forge/noarch/griffe-1.12.1-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/griffe-1.13.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/h2-4.2.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda @@ -646,9 +646,9 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/mike-2.0.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - - conda: https://prefix.dev/conda-forge/noarch/mkdocs-autorefs-1.4.2-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - - conda: https://prefix.dev/conda-forge/noarch/mkdocs-material-9.6.17-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/mkdocs-material-9.6.18-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/mkdocstrings-0.27.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/mkdocstrings-python-1.13.0-pyhff2d567_0.conda @@ -661,7 +661,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/pcre2-10.45-hf733adb_0.conda - conda: https://prefix.dev/conda-forge/osx-64/pillow-11.3.0-py312hd9f36e3_0.conda - conda: https://prefix.dev/conda-forge/osx-64/pixman-0.46.4-ha059160_1.conda - - conda: https://prefix.dev/conda-forge/noarch/platformdirs-4.3.8-pyhe01879c_0.conda + - conda: https://prefix.dev/conda-forge/noarch/platformdirs-4.4.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/osx-64/pthread-stubs-0.4-h00291cd_1002.conda - conda: https://prefix.dev/conda-forge/noarch/pyaml-25.7.0-pyhe01879c_0.conda - conda: https://prefix.dev/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda @@ -681,18 +681,18 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://prefix.dev/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-hf689a15_2.conda - - conda: https://prefix.dev/conda-forge/noarch/typing-extensions-4.14.1-h4440ef1_0.conda - - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.14.1-pyhe01879c_0.conda + - conda: https://prefix.dev/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - conda: https://prefix.dev/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/verspec-0.1.0-pyh29332c3_2.conda - - conda: https://prefix.dev/conda-forge/osx-64/watchdog-6.0.0-py312h01d7ebd_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/watchdog-6.0.0-py312h2f459f6_1.conda - conda: https://prefix.dev/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/osx-64/xorg-libxau-1.0.12-h6e16a3a_0.conda - conda: https://prefix.dev/conda-forge/osx-64/xorg-libxdmcp-1.1.5-h00291cd_0.conda - conda: https://prefix.dev/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda - conda: https://prefix.dev/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda - - conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.23.0-py312h01d7ebd_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.23.0-py312h2f459f6_3.conda - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-h8210216_2.conda osx-arm64: - conda: https://prefix.dev/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda @@ -1042,8 +1042,8 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda osx-64: - conda: https://prefix.dev/conda-forge/osx-64/actionlint-1.7.7-h23c3e72_0.conda - - conda: https://prefix.dev/conda-forge/noarch/boto3-1.40.13-pyhd8ed1ab_0.conda - - conda: https://prefix.dev/conda-forge/noarch/botocore-1.40.13-pyge310_1234567_0.conda + - conda: https://prefix.dev/conda-forge/noarch/boto3-1.40.18-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/botocore-1.40.18-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/osx-64/brotli-python-1.1.0-py312haafddd8_3.conda - conda: https://prefix.dev/conda-forge/osx-64/bzip2-1.0.8-hfdf4475_7.conda - conda: https://prefix.dev/conda-forge/osx-64/c-ares-1.34.5-hf13058a_0.conda @@ -1096,9 +1096,9 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/libev-4.33-h10d778d_2.conda - conda: https://prefix.dev/conda-forge/osx-64/libexpat-2.7.1-h21dd04a_0.conda - conda: https://prefix.dev/conda-forge/osx-64/libffi-3.4.6-h281671d_1.conda - - conda: https://prefix.dev/conda-forge/osx-64/libgfortran-15.1.0-h5f6db21_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libgfortran-15.1.0-h5f6db21_1.conda - conda: https://prefix.dev/conda-forge/noarch/libgfortran-devel_osx-64-12.4.0-h3f4828c_105.conda - - conda: https://prefix.dev/conda-forge/osx-64/libgfortran5-15.1.0-hfa3c126_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libgfortran5-15.1.0-hfa3c126_1.conda - conda: https://prefix.dev/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda - conda: https://prefix.dev/conda-forge/osx-64/libllvm15-15.0.7-hc29ff6c_5.conda - conda: https://prefix.dev/conda-forge/osx-64/liblzma-5.8.1-hd471939_2.conda @@ -1109,7 +1109,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/libuv-1.51.0-h58003a5_1.conda - conda: https://prefix.dev/conda-forge/osx-64/libxml2-2.13.8-he1bc88e_1.conda - conda: https://prefix.dev/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda - - conda: https://prefix.dev/conda-forge/osx-64/llvm-openmp-20.1.8-hf4e0ed4_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/llvm-openmp-20.1.8-hf4e0ed4_2.conda - conda: https://prefix.dev/conda-forge/osx-64/llvm-tools-15.0.7-hc29ff6c_5.conda - conda: https://prefix.dev/conda-forge/osx-64/make-4.3-h22f3db7_1.tar.bz2 - conda: https://prefix.dev/conda-forge/osx-64/mpc-1.3.1-h9d8efa1_1.conda @@ -1145,7 +1145,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/taplo-0.9.3-hf3953a5_1.conda - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-hf689a15_2.conda - conda: https://prefix.dev/conda-forge/noarch/tomli-2.2.1-pyhe01879c_2.conda - - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.14.1-pyhe01879c_0.conda + - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/osx-64/typos-1.35.5-hb440939_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - conda: https://prefix.dev/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda @@ -1154,7 +1154,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/xz-tools-5.8.1-hd471939_2.conda - conda: https://prefix.dev/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda - conda: https://prefix.dev/conda-forge/osx-64/zlib-1.3.1-hd23fc13_2.conda - - conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.23.0-py312h01d7ebd_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.23.0-py312h2f459f6_3.conda - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-h8210216_2.conda osx-arm64: - conda: https://prefix.dev/conda-forge/osx-arm64/actionlint-1.7.7-h48c0fde_0.conda @@ -1510,6 +1510,18 @@ packages: license_family: Apache size: 84257 timestamp: 1755698589437 +- conda: https://prefix.dev/conda-forge/noarch/boto3-1.40.18-pyhd8ed1ab_0.conda + sha256: 118618c6d4868ffc4b05b67950626fd3743b782b0b7e7027aba65d73156d958b + md5: fb19c785e87d1ecc97112f621aa9d4e6 + depends: + - botocore >=1.40.18,<1.41.0 + - jmespath >=0.7.1,<2.0.0 + - python >=3.10 + - s3transfer >=0.13.0,<0.14.0 + license: Apache-2.0 + license_family: Apache + size: 84356 + timestamp: 1756250866749 - conda: https://prefix.dev/conda-forge/noarch/botocore-1.40.13-pyge310_1234567_0.conda sha256: 1485d174ceb8399ad4f171e6cc25f750f58214719321c79954e55f7765a4e6cc md5: e7319e5653efad4c55198805cbe41d9e @@ -1522,6 +1534,18 @@ packages: license_family: Apache size: 7991383 timestamp: 1755646274651 +- conda: https://prefix.dev/conda-forge/noarch/botocore-1.40.18-pyhd8ed1ab_0.conda + sha256: ae41db8e340a72fcf7f4804fbaa12c8c46da033637cfd205a6bf05a811ab40bb + md5: 42f86fbce14d7a025ff914cfc7e5450e + depends: + - jmespath >=0.7.1,<2.0.0 + - python >=3.10 + - python-dateutil >=2.1,<3.0.0 + - urllib3 >=1.25.4,!=2.2.0,<3 + license: Apache-2.0 + license_family: Apache + size: 7870142 + timestamp: 1756247044936 - conda: https://prefix.dev/conda-forge/linux-64/brotli-python-1.1.0-py312h2ec8cdc_3.conda sha256: dc27c58dc717b456eee2d57d8bc71df3f562ee49368a2351103bc8f1b67da251 md5: a32e0c069f6c3dcac635f7b0b0dac67e @@ -2907,6 +2931,15 @@ packages: license: ISC size: 106635 timestamp: 1755255182711 +- conda: https://prefix.dev/conda-forge/noarch/griffe-1.13.0-pyhd8ed1ab_0.conda + sha256: 3d213847f13484d24c7c853b115cd00baaa4951d1fc102230ca531496f99b5f0 + md5: 9068891737efb797e11b2eba5ef557ce + depends: + - colorama >=0.4 + - python >=3.10 + license: ISC + size: 106604 + timestamp: 1756240237809 - conda: https://prefix.dev/conda-forge/linux-64/gxx-12.4.0-h236703b_2.conda sha256: 6c3ea9877dc6babf064bafacd9e67280072b676864c26e90cbfec52eaa32a60e md5: 5735863174438abb776bd1fefccec00a @@ -3793,15 +3826,15 @@ packages: license_family: GPL size: 29249 timestamp: 1753903872571 -- conda: https://prefix.dev/conda-forge/osx-64/libgfortran-15.1.0-h5f6db21_0.conda - sha256: 10efd2a1e18641dfcb57bdc14aaebabe9b24020cf1a5d9d2ec8d7cd9b2352583 - md5: bca8f1344f0b6e3002a600f4379f8f2f +- conda: https://prefix.dev/conda-forge/osx-64/libgfortran-15.1.0-h5f6db21_1.conda + sha256: 844500c9372d455f6ae538ffd3cdd7fda5f53d25a2a6b3ba33060a302c37bc3e + md5: 07cfad6b37da6e79349c6e3a0316a83b depends: - - libgfortran5 15.1.0 hfa3c126_0 + - libgfortran5 15.1.0 hfa3c126_1 license: GPL-3.0-only WITH GCC-exception-3.1 license_family: GPL - size: 134053 - timestamp: 1750181840950 + size: 133973 + timestamp: 1756239628906 - conda: https://prefix.dev/conda-forge/osx-arm64/libgfortran-15.1.0-hfdf1602_0.conda sha256: 9620b4ac9d32fe7eade02081cd60d6a359a927d42bb8e121bd16489acd3c4d8c md5: e3b7dca2c631782ca1317a994dfe19ec @@ -3837,17 +3870,17 @@ packages: license_family: GPL size: 1564595 timestamp: 1753903882088 -- conda: https://prefix.dev/conda-forge/osx-64/libgfortran5-15.1.0-hfa3c126_0.conda - sha256: b8e892f5b96d839f7bf6de267329c145160b1f33d399b053d8602085fdbf26b2 - md5: c97d2a80518051c0e88089c51405906b +- conda: https://prefix.dev/conda-forge/osx-64/libgfortran5-15.1.0-hfa3c126_1.conda + sha256: c4bb79d9e9be3e3a335282b50d18a7965e2a972b95508ea47e4086f1fd699342 + md5: 696e408f36a5a25afdb23e862053ca82 depends: - llvm-openmp >=8.0.0 constrains: - libgfortran 15.1.0 license: GPL-3.0-only WITH GCC-exception-3.1 license_family: GPL - size: 1226396 - timestamp: 1750181111194 + size: 1225193 + timestamp: 1756238834726 - conda: https://prefix.dev/conda-forge/osx-arm64/libgfortran5-15.1.0-hb74de2c_0.conda sha256: 44b8ce4536cc9a0e59c09ff404ef1b0120d6a91afc32799331d85268cbe42438 md5: 8b158ccccd67a40218e12626a39065a1 @@ -4722,9 +4755,9 @@ packages: license: BSD-3-Clause license_family: BSD size: 2667 -- conda: https://prefix.dev/conda-forge/osx-64/llvm-openmp-20.1.8-hf4e0ed4_1.conda - sha256: 881975b8e13fb65d5e3d1cd7dd574581082af10c675c27c342e317c03ddfeaac - md5: 55ae491cc02d64a55b75ffae04d7369b +- conda: https://prefix.dev/conda-forge/osx-64/llvm-openmp-20.1.8-hf4e0ed4_2.conda + sha256: e91aab8de03406a3c7798d939997eeea021de7c3da263869ded0b980ce74b756 + md5: ffb5c09a0f4576942082a3a8fc37c4a0 depends: - __osx >=10.13 constrains: @@ -4732,8 +4765,8 @@ packages: - openmp 20.1.8|20.1.8.* license: Apache-2.0 WITH LLVM-exception license_family: APACHE - size: 307933 - timestamp: 1753978812327 + size: 307983 + timestamp: 1756144829047 - conda: https://prefix.dev/conda-forge/osx-arm64/llvm-openmp-20.1.8-hbb9b287_1.conda sha256: e56f46b253dd1a99cc01dde038daba7789fc6ed35b2a93e3fc44b8578a82b3ec md5: a10bdc3e5d9e4c1ce554c83855dff6c4 @@ -5000,6 +5033,18 @@ packages: license: ISC size: 34912 timestamp: 1747758093008 +- conda: https://prefix.dev/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda + sha256: 1631568d0d36bc182ec20c5b4c58cc053cdd77698b4741977776f592996d345b + md5: 1c024504ac97f1199023327a69066a8f + depends: + - markdown >=3.3 + - markupsafe >=2.0.1 + - mkdocs >=1.1 + - pymdown-extensions + - python >=3.10 + license: ISC + size: 35016 + timestamp: 1756236211878 - conda: https://prefix.dev/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda sha256: e0b501b96f7e393757fb2a61d042015966f6c5e9ac825925e43f9a6eafa907b6 md5: 84382acddb26c27c70f2de8d4c830830 @@ -5033,6 +5078,27 @@ packages: license_family: MIT size: 5009623 timestamp: 1755298828452 +- conda: https://prefix.dev/conda-forge/noarch/mkdocs-material-9.6.18-pyhcf101f3_0.conda + sha256: 0b1d1cada3e48685e5813e6e39862bc3cccf91cde97483f01e938764693c2fcc + md5: 11cff74f181a9a348425704b20ca4d40 + depends: + - python >=3.10 + - jinja2 >=3.0,<4.dev0 + - markdown >=3.2,<4.dev0 + - mkdocs >=1.6,<2.dev0 + - mkdocs-material-extensions >=1.3,<2.dev0 + - pygments >=2.16,<3.dev0 + - pymdown-extensions >=10.2,<11.dev0 + - babel >=2.10,<3.dev0 + - colorama >=0.4,<1.dev0 + - paginate >=0.5,<1.dev0 + - backrefs >=5.7.post1,<6.dev0 + - requests >=2.26,<3.dev0 + - python + license: MIT + license_family: MIT + size: 4738235 + timestamp: 1755878847003 - conda: https://prefix.dev/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda sha256: f62955d40926770ab65cc54f7db5fde6c073a3ba36a0787a7a5767017da50aa3 md5: de8af4000a4872e16fb784c649679c8e @@ -5585,6 +5651,15 @@ packages: license_family: MIT size: 23531 timestamp: 1746710438805 +- conda: https://prefix.dev/conda-forge/noarch/platformdirs-4.4.0-pyhcf101f3_0.conda + sha256: dfe0fa6e351d2b0cef95ac1a1533d4f960d3992f9e0f82aeb5ec3623a699896b + md5: cc9d9a3929503785403dbfad9f707145 + depends: + - python >=3.10 + - python + license: MIT + size: 23653 + timestamp: 1756227402815 - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda sha256: a8eb555eef5063bbb7ba06a379fa7ea714f57d9741fe0efdb9442dbbc2cccbcc md5: 7da7ccd349dbf6487a7778579d2bb971 @@ -6428,6 +6503,15 @@ packages: license_family: PSF size: 90486 timestamp: 1751643513473 +- conda: https://prefix.dev/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + sha256: 7c2df5721c742c2a47b2c8f960e718c930031663ac1174da67c1ed5999f7938c + md5: edd329d7d3a4ab45dcf905899a7a6115 + depends: + - typing_extensions ==4.15.0 pyhcf101f3_0 + license: PSF-2.0 + license_family: PSF + size: 91383 + timestamp: 1756220668932 - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.14.1-pyhe01879c_0.conda sha256: 4f52390e331ea8b9019b87effaebc4f80c6466d09f68453f52d5cdc2a3e1194f md5: e523f4f1e980ed7a4240d7e27e9ec81f @@ -6438,6 +6522,16 @@ packages: license_family: PSF size: 51065 timestamp: 1751643513473 +- conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 + md5: 0caa1af407ecff61170c9437a808404d + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + size: 51692 + timestamp: 1756220668932 - conda: https://prefix.dev/conda-forge/linux-64/typos-1.35.5-hdab8a38_0.conda sha256: 44a36fe05097a7380a7c1873d084f9909708eb0028da9babd3e26a182c35d6eb md5: e51bb70d5532fe25c867c07590de7635 @@ -6606,9 +6700,9 @@ packages: license_family: APACHE size: 140940 timestamp: 1730493008472 -- conda: https://prefix.dev/conda-forge/osx-64/watchdog-6.0.0-py312h01d7ebd_0.conda - sha256: 81ca10842962d6d8d18cb58f36f365e85a916fdf5fe7ac98351eafdfcd3c1030 - md5: 6bf329e9cdc3e6d65c0d11a43eca6e21 +- conda: https://prefix.dev/conda-forge/osx-64/watchdog-6.0.0-py312h2f459f6_1.conda + sha256: 8e25ea20e6c8e80a085dcc8d9bf7de9289ea3d42009c92d51a374cf7588138d0 + md5: 14743a96d1a4df100fbff4618cf69d52 depends: - __osx >=10.13 - python >=3.12,<3.13.0a0 @@ -6616,8 +6710,8 @@ packages: - pyyaml >=3.10 license: Apache-2.0 license_family: APACHE - size: 148305 - timestamp: 1730493092622 + size: 149242 + timestamp: 1756135617786 - conda: https://prefix.dev/conda-forge/osx-arm64/watchdog-6.0.0-py312hea69d52_0.conda sha256: f6c2eb941ffc25fc4fc637c71a5465678ed20e57b53698020a50dca86c584f04 md5: ce2a02fd5a911d4eb963af9a84c00d2c @@ -7008,9 +7102,9 @@ packages: license_family: BSD size: 732224 timestamp: 1745869780524 -- conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.23.0-py312h01d7ebd_2.conda - sha256: 970db6b96b9ac7c1418b8743cf63c3ee6285ec7f56ffc94ac7850b4c2ebc3095 - md5: 64aea64b791ab756ef98c79f0e48fee5 +- conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.23.0-py312h2f459f6_3.conda + sha256: ae6a6f87f27270d3c58c826ba3e344780816793af7586fe7f3fa4d4b07c9e274 + md5: f53fa375c2e4a2e42a64578db302145d depends: - __osx >=10.13 - cffi >=1.11 @@ -7018,8 +7112,8 @@ packages: - python_abi 3.12.* *_cp312 license: BSD-3-Clause license_family: BSD - size: 690063 - timestamp: 1745869852235 + size: 644204 + timestamp: 1756075773049 - conda: https://prefix.dev/conda-forge/osx-arm64/zstandard-0.23.0-py312hea69d52_2.conda sha256: c499a2639c2981ac2fd33bae2d86c15d896bc7524f1c5651a7d3b088263f7810 md5: ba0eb639914e4033e090b46f53bec31c diff --git a/pixi.toml b/pixi.toml index 273429844..c5696e745 100644 --- a/pixi.toml +++ b/pixi.toml @@ -130,4 +130,3 @@ channels = [ [package] name = "rattler-build" version = "0.0.0dev" # NOTE: how to set this automatically? - diff --git a/rust-tests/src/lib.rs b/rust-tests/src/lib.rs index a51461bfb..51f4fde8c 100644 --- a/rust-tests/src/lib.rs +++ b/rust-tests/src/lib.rs @@ -98,6 +98,25 @@ mod tests { output } + + fn generate_recipe_pyproject, O: AsRef>( + &self, + input: I, + output: O, + ) -> Output { + let input_str = input.as_ref().display().to_string(); + let output_str = output.as_ref().display().to_string(); + let args = vec![ + "--log-style=plain", + "generate-recipe", + "pyproject", + "-i", + input_str.as_str(), + "-o", + output_str.as_str(), + ]; + self.with_args(args) + } } #[allow(unreachable_code)] @@ -715,4 +734,129 @@ requirements: assert!(output.contains("No license files were copied")); assert!(output.contains("The following license files were not found: *.license")); } + + #[test] + fn test_generate_recipe_pyproject_basic() { + let tmp = tmp("test_generate_recipe_pyproject_basic"); + // Create the temp directory + fs::create_dir_all(tmp.as_dir()).unwrap(); + + // Create a basic pyproject.toml + let pyproject_content = r#" +[project] +name = "test-package" +version = "1.0.0" +description = "A test package for pyproject recipe generation" +dependencies = [ + "requests>=2.25.0", + "click>=8.0.0" +] + +[project.scripts] +test-tool = "test_package.cli:main" + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" +"#; + + let pyproject_path = tmp.as_dir().join("pyproject.toml"); + fs::write(&pyproject_path, pyproject_content).unwrap(); + + let recipe_path = tmp.as_dir().join("recipe.yaml"); + + // Run rattler-build generate-recipe pyproject + let rattler_build = rattler().generate_recipe_pyproject(&pyproject_path, &recipe_path); + + assert!( + rattler_build.status.success(), + "Command failed: {}", + String::from_utf8_lossy(&rattler_build.stdout) + ); + + // Check that recipe.yaml was created + assert!(recipe_path.exists(), "recipe.yaml was not created"); + + // Check recipe content + let recipe_content = fs::read_to_string(&recipe_path).unwrap(); + + // Should have schema header + assert!(recipe_content.contains("# yaml-language-server: $schema=")); + assert!(recipe_content.contains("schema_version: 1")); + + // Should have correct package info + assert!(recipe_content.contains("name: test-package")); + assert!(recipe_content.contains("version: 1.0.0")); + + // Should have dependencies converted + assert!(recipe_content.contains("requests >=2.25.0")); + assert!(recipe_content.contains("click >=8.0.0")); + + // Should have entry points + assert!(recipe_content.contains("test-tool = test_package.cli:main")); + + // Should have build system requirements + assert!(recipe_content.contains("setuptools")); + assert!(recipe_content.contains("wheel")); + } + + #[test] + fn test_generate_recipe_pyproject_with_conda_overrides() { + let tmp = tmp("test_generate_recipe_pyproject_conda_overrides"); + // Create the temp directory + fs::create_dir_all(tmp.as_dir()).unwrap(); + + // Create a pyproject.toml with conda recipe overrides + let pyproject_content = r#" +[project] +name = "advanced-package" +version = "2.0.0" +description = "An advanced package with conda overrides" +dependencies = [ + "numpy>=1.21.0" +] + +[tool.conda.recipe] +schema_version = 2 + +[tool.conda.recipe.context] +custom_var = "custom_value" + +[tool.conda.recipe.about] +license = "MIT" +homepage = "https://example.com" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +"#; + + let pyproject_path = tmp.as_dir().join("pyproject.toml"); + fs::write(&pyproject_path, pyproject_content).unwrap(); + + let recipe_path = tmp.as_dir().join("recipe.yaml"); + + // Run rattler-build generate-recipe pyproject + let rattler_build = rattler().generate_recipe_pyproject(&pyproject_path, &recipe_path); + + assert!( + rattler_build.status.success(), + "Command failed: {}", + String::from_utf8_lossy(&rattler_build.stdout) + ); + + // Check recipe content + let recipe_content = fs::read_to_string(&recipe_path).unwrap(); + + // Should have custom schema version + assert!(recipe_content.contains("schema_version: 2")); + + // Should have conda overrides applied + assert!(recipe_content.contains("custom_var: custom_value")); + assert!(recipe_content.contains("license: MIT")); + assert!(recipe_content.contains("homepage: https://example.com")); + + // Should have hatchling build system + assert!(recipe_content.contains("hatchling")); + } } diff --git a/src/recipe_generator/luarocks/mod.rs b/src/recipe_generator/luarocks/mod.rs index 7d77a04ee..ed34ca12c 100644 --- a/src/recipe_generator/luarocks/mod.rs +++ b/src/recipe_generator/luarocks/mod.rs @@ -380,6 +380,7 @@ fn rockspec_to_recipe(rockspec: &LuarocksRockspec) -> miette::Result { }; let mut recipe = Recipe { + schema_version: Some(1), context, package: crate::recipe_generator::serialize::Package { name: package_name.as_normalized().to_string(), @@ -388,6 +389,7 @@ fn rockspec_to_recipe(rockspec: &LuarocksRockspec) -> miette::Result { source: vec![source_element], build: Build { script: "# Take the first `rockspec` we find (in non-deterministic places unfortunately)\nROCK=$(find . -name \"*.rockspec\" | sort -n -r | head -n 1)\nluarocks install ${ROCK} --tree=${{ PREFIX }}".to_string(), + number: Some(0), python: Python::default(), noarch: None, }, diff --git a/src/recipe_generator/mod.rs b/src/recipe_generator/mod.rs index 27db5a7de..0f998f09c 100644 --- a/src/recipe_generator/mod.rs +++ b/src/recipe_generator/mod.rs @@ -1,16 +1,18 @@ -//! Module for generating recipes for Python (PyPI), R (CRAN), Perl (CPAN), or Lua (LuaRocks) packages +//! Module for generating recipes for Python (PyPI), R (CRAN), Perl (CPAN), Lua (LuaRocks) packages, or local Python projects use clap::Parser; mod cpan; mod cran; mod luarocks; mod pypi; +mod pyproject; mod serialize; use cpan::{CpanOpts, generate_cpan_recipe}; use cran::{CranOpts, generate_r_recipe}; use luarocks::{LuarocksOpts, generate_luarocks_recipe}; use pypi::PyPIOpts; +use pyproject::{PyprojectOpts, generate_pyproject_recipe}; pub use serialize::write_recipe; use self::pypi::generate_pypi_recipe; @@ -29,6 +31,9 @@ pub enum Source { /// Generate a recipe for a Lua package from LuaRocks Luarocks(LuarocksOpts), + + /// Generate a recipe from a local pyproject.toml file + Pyproject(PyprojectOpts), } /// Options for generating a recipe @@ -46,6 +51,7 @@ pub async fn generate_recipe(args: GenerateRecipeOpts) -> miette::Result<()> { Source::Cran(opts) => generate_r_recipe(&opts).await?, Source::Cpan(opts) => generate_cpan_recipe(&opts).await?, Source::Luarocks(opts) => generate_luarocks_recipe(&opts).await?, + Source::Pyproject(opts) => generate_pyproject_recipe(&opts).await?, } Ok(()) diff --git a/src/recipe_generator/pyproject.rs b/src/recipe_generator/pyproject.rs new file mode 100644 index 000000000..7c9265e73 --- /dev/null +++ b/src/recipe_generator/pyproject.rs @@ -0,0 +1,1116 @@ +use clap::Parser; +use fs_err as fs; +use indexmap::IndexMap; +use miette::{IntoDiagnostic, WrapErr}; +use regex::Regex; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::recipe_generator::serialize; + +#[derive(Debug, Clone, Parser)] +pub struct PyprojectOpts { + /// Path to the pyproject.toml file (defaults to pyproject.toml in current directory) + #[arg(short, long, default_value = "pyproject.toml")] + pub input: PathBuf, + + /// Path to write the recipe.yaml file. If not provided, output will be printed to stdout + #[arg(short, long)] + pub output: Option, + + /// Whether to overwrite existing recipe file + #[arg(long)] + pub overwrite: bool, + + /// Output format: yaml or json + #[arg(long, default_value = "yaml")] + pub format: String, + + /// Sort keys in output + #[arg(long)] + pub sort_keys: bool, + + /// Include helpful comments in the output + #[arg(long, default_value = "true")] + pub include_comments: bool, + + /// Exclude specific sections from the output (comma-separated) + #[arg(long)] + pub exclude_sections: Option, + + /// Validate the generated recipe + #[arg(long, default_value = "true")] + pub validate: bool, +} + +/// Generate a recipe from a pyproject.toml file +pub async fn generate_pyproject_recipe(opts: &PyprojectOpts) -> miette::Result<()> { + tracing::info!("Generating recipe from {}", opts.input.display()); + + // Check if input file exists + if !opts.input.exists() { + return Err(miette::miette!( + "pyproject.toml file not found: {}", + opts.input.display() + )); + } + + // Load and parse pyproject.toml + let toml_data = load_pyproject_toml(&opts.input)?; + + // Generate the recipe + let project_root = opts + .input + .parent() + .unwrap_or(&PathBuf::from(".")) + .to_path_buf(); + let recipe = assemble_recipe(toml_data, &project_root).wrap_err("Failed to assemble recipe")?; + + // Convert to the requested format + let recipe_content = match opts.format.as_str() { + "json" => { + let json_value = serde_json::to_value(&recipe).into_diagnostic()?; + serde_json::to_string_pretty(&json_value).into_diagnostic()? + } + _ => { + // Convert to YAML and add schema comment + let yaml_content = serde_yaml::to_string(&recipe).into_diagnostic()?; + format_yaml_with_schema(&yaml_content) + } + }; + + // Write or print the recipe + if let Some(output_path) = &opts.output { + // Check if output file exists and we're not overwriting + if output_path.exists() && !opts.overwrite { + return Err(miette::miette!( + "Output file {} already exists. Use --overwrite to replace it.", + output_path.display() + )); + } + + // Create parent directory if it doesn't exist + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).into_diagnostic()?; + } + + // Write to the specified output file + fs::write(output_path, &recipe_content).into_diagnostic()?; + tracing::info!("Recipe written to {}", output_path.display()); + } else { + print!("{}", recipe_content); + } + + Ok(()) +} + +/// Load and parse a pyproject.toml file +fn load_pyproject_toml(path: &PathBuf) -> miette::Result> { + let content = fs::read_to_string(path) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to read {}", path.display()))?; + + let toml_value: toml::Value = toml::from_str(&content) + .into_diagnostic() + .wrap_err("Failed to parse pyproject.toml")?; + + // Convert to JSON Value for easier manipulation + let json_str = serde_json::to_string(&toml_value).into_diagnostic()?; + let json_value: HashMap = serde_json::from_str(&json_str).into_diagnostic()?; + + Ok(json_value) +} + +/// Assemble a complete recipe from pyproject.toml data +fn assemble_recipe( + toml_data: HashMap, + _project_root: &Path, +) -> miette::Result { + let mut recipe = serialize::Recipe::default(); + + // Extract project metadata + let project = toml_data + .get("project") + .and_then(|p| p.as_object()) + .ok_or_else(|| miette::miette!("No [project] section found in pyproject.toml"))?; + + // Build base sections from [project] metadata + let context = build_context_section(project, &toml_data)?; + recipe.context = context; + + recipe.package = build_package_section(project)?; + recipe.source = build_source_section(project, &toml_data)?; + recipe.build = build_build_section(&toml_data)?; + recipe.requirements = build_requirements_section(project, &toml_data)?; + + if let Some(test_section) = build_test_section(project, &toml_data)? { + recipe.tests.push(test_section); + } + + recipe.about = build_about_section(project)?; + + // Handle schema version from tool.conda.recipe or set default + recipe.schema_version = build_schema_version(&toml_data); + + // Apply conda-specific overrides from tool.conda.recipe.* sections + // This mirrors the pyrattler-recipe-autogen approach where each section + // can be overridden via tool.conda.recipe. + apply_conda_recipe_overrides(&mut recipe, &toml_data)?; + + Ok(recipe) +} + +/// Build the context section +fn build_context_section( + project: &serde_json::Map, + toml_data: &HashMap, +) -> miette::Result> { + let mut context = IndexMap::new(); + + // Extract name and version + let name = project + .get("name") + .and_then(|n| n.as_str()) + .ok_or_else(|| miette::miette!("Project name not found"))?; + + let version = if let Some(v) = project.get("version").and_then(|v| v.as_str()) { + v.to_string() + } else { + // Check for dynamic version + let default_dynamic = vec![]; + let dynamic = project + .get("dynamic") + .and_then(|d| d.as_array()) + .unwrap_or(&default_dynamic); + + if dynamic.iter().any(|d| d.as_str() == Some("version")) { + // Try to resolve dynamic version + resolve_dynamic_version(toml_data)? + } else { + return Err(miette::miette!("No version found in project metadata")); + } + }; + + context.insert("name".to_string(), name.to_lowercase().replace(" ", "-")); + context.insert("version".to_string(), version); + + // Extract Python version requirement + if let Some(requires_python) = project.get("requires-python").and_then(|r| r.as_str()) { + if let Some(min_version) = extract_min_python_version(requires_python) { + context.insert("python_min".to_string(), min_version); + } + } + + Ok(context) +} + +/// Build the package section +fn build_package_section( + _project: &serde_json::Map, +) -> miette::Result { + Ok(serialize::Package { + name: "${{ name }}".to_string(), + version: "${{ version }}".to_string(), + }) +} + +/// Build the source section +fn build_source_section( + project: &serde_json::Map, + _toml_data: &HashMap, +) -> miette::Result> { + let name = project + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("package"); + + // Check for explicit source URLs in project.urls + if let Some(urls) = project.get("urls").and_then(|u| u.as_object()) { + if let Some(source_url) = urls + .get("Source") + .or_else(|| urls.get("Homepage")) + .and_then(|u| u.as_str()) + { + if source_url.contains("github.com") || source_url.contains("gitlab.com") { + // Git repository source + return Ok(vec![serialize::SourceElement::Url( + serialize::UrlSourceElement { + url: vec![format!( + "{}/archive/v${{{{ version }}}}.tar.gz", + source_url.trim_end_matches('/') + )], + sha256: None, + md5: None, + }, + )]); + } + } + } + + // Default to PyPI source + let package_name = name.to_lowercase().replace("-", "_"); + let pypi_url = format!( + "https://pypi.org/packages/source/{}/{}/{}-${{{{ version }}}}.tar.gz", + &package_name[..1], + package_name, + package_name + ); + + Ok(vec![serialize::SourceElement::Url( + serialize::UrlSourceElement { + url: vec![pypi_url], + sha256: None, + md5: None, + }, + )]) +} + +/// Build the build section +fn build_build_section(toml_data: &HashMap) -> miette::Result { + let mut build = serialize::Build { + script: "${{ PYTHON }} -m pip install . -vv --no-build-isolation".to_string(), + number: Some(0), + noarch: Some("python".to_string()), + ..Default::default() + }; + + // Check for entry points + if let Some(project) = toml_data.get("project").and_then(|p| p.as_object()) { + if let Some(scripts) = project.get("scripts").and_then(|s| s.as_object()) { + let mut entry_points = Vec::new(); + for (name, command) in scripts { + if let Some(cmd) = command.as_str() { + entry_points.push(format!("{} = {}", name, cmd)); + } + } + if !entry_points.is_empty() { + build.python.entry_points = entry_points; + } + } + } + + Ok(build) +} + +/// Build the requirements section +fn build_requirements_section( + project: &serde_json::Map, + toml_data: &HashMap, +) -> miette::Result { + let mut requirements = serialize::Requirements { + build: vec![], + ..Default::default() + }; + + // Host requirements - Python and pip, plus build system requirements + let mut host_deps = vec!["python".to_string(), "pip".to_string()]; + + // Add Python version constraint if specified in requires-python + if let Some(requires_python) = project.get("requires-python").and_then(|r| r.as_str()) { + host_deps[0] = format_python_constraint(requires_python); + } + + // Add build system requirements + if let Some(build_system) = toml_data.get("build-system").and_then(|b| b.as_object()) { + if let Some(requires) = build_system.get("requires").and_then(|r| r.as_array()) { + for req in requires { + if let Some(req_str) = req.as_str() { + host_deps.push(req_str.to_string()); + } + } + } + } + + requirements.host = host_deps; + + // Runtime requirements - Python plus all project dependencies + let mut run_deps = vec![]; + + // Add Python constraint first + if let Some(requires_python) = project.get("requires-python").and_then(|r| r.as_str()) { + run_deps.push(format_python_constraint(requires_python)); + } else { + run_deps.push("python".to_string()); + } + + // Add project dependencies exactly as specified (following pyrattler-recipe-autogen pattern) + if let Some(deps) = project.get("dependencies").and_then(|d| d.as_array()) { + for dep in deps { + if let Some(dep_str) = dep.as_str() { + // Convert Python dependency format to conda format + let conda_dep = convert_python_to_conda_dependency(dep_str); + run_deps.push(conda_dep); + } + } + } + + requirements.run = run_deps; + + Ok(requirements) +} + +/// Build the test section +fn build_test_section( + project: &serde_json::Map, + _toml_data: &HashMap, +) -> miette::Result> { + let name = project + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("package"); + + // Create a simple import test + let import_name = name.to_lowercase().replace("-", "_"); + + Ok(Some(serialize::Test::Python(serialize::PythonTest { + python: serialize::PythonTestInner { + imports: vec![import_name], + pip_check: true, + }, + }))) +} + +/// Build the about section +fn build_about_section( + project: &serde_json::Map, +) -> miette::Result { + let mut about = serialize::About { + summary: project + .get("description") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()), + license: project + .get("license") + .and_then(|l| l.as_object()) + .and_then(|l| l.get("text")) + .and_then(|t| t.as_str()) + .map(|s| s.to_string()), + ..Default::default() + }; + + // Extract URLs + if let Some(urls) = project.get("urls").and_then(|u| u.as_object()) { + about.homepage = urls + .get("Homepage") + .and_then(|h| h.as_str()) + .map(|s| s.to_string()); + about.repository = urls + .get("Source") + .or_else(|| urls.get("Repository")) + .and_then(|r| r.as_str()) + .map(|s| s.to_string()); + about.documentation = urls + .get("Documentation") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + } + + Ok(about) +} + +/// Build schema version from tool.conda.recipe.schema_version or use default +fn build_schema_version(toml_data: &HashMap) -> Option { + // Check for tool.conda.recipe.schema_version + if let Some(tool) = toml_data.get("tool").and_then(|t| t.as_object()) { + if let Some(conda) = tool.get("conda").and_then(|c| c.as_object()) { + if let Some(recipe) = conda.get("recipe").and_then(|r| r.as_object()) { + if let Some(schema_version) = recipe.get("schema_version").and_then(|v| v.as_u64()) + { + return Some(schema_version as u32); + } + } + } + } + + // Default schema version if not specified + Some(1) +} + +/// Resolve dynamic version from build system +fn resolve_dynamic_version(toml_data: &HashMap) -> miette::Result { + // Check build system for version resolution + if let Some(build_system) = toml_data.get("build-system").and_then(|b| b.as_object()) { + if let Some(backend) = build_system.get("build-backend").and_then(|b| b.as_str()) { + if backend.contains("setuptools_scm") { + return Ok( + "${{ environ.get('SETUPTOOLS_SCM_PRETEND_VERSION', '0.1.0') }}".to_string(), + ); + } else if backend.contains("hatch") { + return Ok("${{ environ.get('HATCH_BUILD_VERSION', '0.1.0') }}".to_string()); + } + } + } + + // Default fallback + Ok("0.1.0".to_string()) +} + +/// Extract minimum Python version from requires-python string +fn extract_min_python_version(requires_python: &str) -> Option { + // Simple regex to extract version like ">=3.8" -> "3.8" + if let Ok(re) = Regex::new(r">=\s*([0-9]+\.[0-9]+)") { + if let Some(captures) = re.captures(requires_python) { + return captures.get(1).map(|m| m.as_str().to_string()); + } + } + None +} + +/// Format YAML content with schema comment at the top +fn format_yaml_with_schema(yaml_content: &str) -> String { + let schema_comment = "# yaml-language-server: $schema=https://raw.githubusercontent.com/prefix-dev/recipe-format/main/schema.json"; + format!("{}\n{}", schema_comment, yaml_content) +} + +/// Format Python version constraint for conda +fn format_python_constraint(requires_python: &str) -> String { + // Convert requires-python format to conda format + // e.g., ">=3.9" -> "python >=3.9" + // e.g., ">=3.9,<4.0" -> "python >=3.9,<4.0" + format!("python {}", requires_python) +} + +/// Convert Python dependency format to conda dependency format +/// Following the same pattern as pyrattler-recipe-autogen +fn convert_python_to_conda_dependency(dep: &str) -> String { + // Handle environment markers (e.g., 'package>=1.0; python_version >= "3.8"') + let base_dep = if dep.contains(';') { + dep.split(';').next().unwrap_or(dep).trim() + } else { + dep + }; + + // Convert Python version operators to conda format + // Process in order from longest to shortest to avoid conflicts + let mut conda_dep = base_dep.to_string(); + + // Handle multi-character operators first + conda_dep = conda_dep.replace("==", " ="); // Python == becomes conda = + conda_dep = conda_dep.replace("~=", " ~="); // Compatible release stays the same + conda_dep = conda_dep.replace(">=", " >="); // Greater than or equal + conda_dep = conda_dep.replace("<=", " <="); // Less than or equal + conda_dep = conda_dep.replace("!=", " !="); // Not equal + + // Handle single character operators, but only if not already processed + // and not immediately after a comma + let chars: Vec = conda_dep.chars().collect(); + let mut result = String::new(); + + for (i, &ch) in chars.iter().enumerate() { + if (ch == '>' || ch == '<') && i > 0 { + let prev_char = chars[i - 1]; + // Add space only if previous char is not space and not comma + if prev_char != ' ' && prev_char != ',' { + result.push(' '); + } + } + result.push(ch); + } + + let conda_dep = result; + + // Handle common Python package to conda package name mappings + // This is a subset - in a full implementation this would be more comprehensive + apply_package_name_mapping(&conda_dep) +} + +/// Apply common Python package name to conda package name mappings +fn apply_package_name_mapping(dep: &str) -> String { + // Common package name mappings from PyPI to conda-forge + let mappings = [ + ("pillow", "pillow"), + ("pyyaml", "pyyaml"), + ("scikit-learn", "scikit-learn"), + ("beautifulsoup4", "beautifulsoup4"), + ("python-dateutil", "python-dateutil"), + // Add more mappings as needed + ]; + + let mut result = dep.to_string(); + + for (pypi_name, conda_name) in mappings { + if result.starts_with(pypi_name) { + result = result.replace(pypi_name, conda_name); + break; + } + } + + result +} + +/// Apply conda-specific overrides from tool.conda.recipe.* sections +/// This follows the same pattern as pyrattler-recipe-autogen where each recipe section +/// can be overridden via tool.conda.recipe. +fn apply_conda_recipe_overrides( + recipe: &mut serialize::Recipe, + toml_data: &HashMap, +) -> miette::Result<()> { + // Get the tool.conda.recipe section if it exists + let conda_recipe_config = toml_data + .get("tool") + .and_then(|tool| tool.as_object()) + .and_then(|tool| tool.get("conda")) + .and_then(|conda| conda.as_object()) + .and_then(|conda| conda.get("recipe")) + .and_then(|recipe| recipe.as_object()); + + let conda_recipe_config = match conda_recipe_config { + Some(config) => config, + None => return Ok(()), // No conda recipe config found + }; + + // Apply overrides following pyrattler-recipe-autogen pattern: + + // 1. tool.conda.recipe.context - override context variables + if let Some(context_override) = conda_recipe_config + .get("context") + .and_then(|c| c.as_object()) + { + apply_context_overrides(&mut recipe.context, context_override)?; + } + + // 2. tool.conda.recipe.package - override package metadata + if let Some(package_override) = conda_recipe_config + .get("package") + .and_then(|p| p.as_object()) + { + apply_package_overrides(&mut recipe.package, package_override)?; + } + + // 3. tool.conda.recipe.source - override source section + if let Some(source_override) = conda_recipe_config + .get("source") + .and_then(|s| s.as_object()) + { + apply_source_overrides(&mut recipe.source, source_override)?; + } + + // 4. tool.conda.recipe.build - override build section + if let Some(build_override) = conda_recipe_config.get("build").and_then(|b| b.as_object()) { + apply_build_overrides(&mut recipe.build, build_override)?; + } + + // 5. tool.conda.recipe.requirements - override requirements section + if let Some(req_override) = conda_recipe_config + .get("requirements") + .and_then(|r| r.as_object()) + { + apply_requirements_overrides(&mut recipe.requirements, req_override)?; + } + + // 6. tool.conda.recipe.test - override test section + if let Some(test_override) = conda_recipe_config.get("test").and_then(|t| t.as_object()) { + apply_test_overrides(&mut recipe.tests, test_override)?; + } + + // 7. tool.conda.recipe.about - override about section + if let Some(about_override) = conda_recipe_config.get("about").and_then(|a| a.as_object()) { + apply_about_overrides(&mut recipe.about, about_override)?; + } + + Ok(()) +} + +/// Apply context section overrides from tool.conda.recipe.context +fn apply_context_overrides( + context: &mut IndexMap, + config: &serde_json::Map, +) -> miette::Result<()> { + for (key, value) in config { + if let Some(string_value) = value.as_str() { + context.insert(key.clone(), string_value.to_string()); + } + } + Ok(()) +} + +/// Apply package section overrides from tool.conda.recipe.package +fn apply_package_overrides( + package: &mut serialize::Package, + config: &serde_json::Map, +) -> miette::Result<()> { + if let Some(name) = config.get("name").and_then(|n| n.as_str()) { + package.name = name.to_string(); + } + + if let Some(version) = config.get("version").and_then(|v| v.as_str()) { + package.version = version.to_string(); + } + + Ok(()) +} + +/// Apply source section overrides from tool.conda.recipe.source +fn apply_source_overrides( + sources: &mut Vec, + config: &serde_json::Map, +) -> miette::Result<()> { + // If config contains a complete source definition, replace existing sources + if config.contains_key("url") || config.contains_key("git") || config.contains_key("path") { + sources.clear(); + + if let Some(url) = config.get("url").and_then(|u| u.as_str()) { + let mut url_source = serialize::UrlSourceElement { + url: vec![url.to_string()], + ..Default::default() + }; + + // Add optional fields + if let Some(sha256) = config.get("sha256").and_then(|s| s.as_str()) { + url_source.sha256 = Some(sha256.to_string()); + } + if let Some(md5) = config.get("md5").and_then(|m| m.as_str()) { + url_source.md5 = Some(md5.to_string()); + } + + sources.push(serialize::SourceElement::Url(url_source)); + } else if let Some(git_url) = config.get("git").and_then(|g| g.as_str()) { + let mut git_source = serialize::GitSourceElement { + git: git_url.to_string(), + ..Default::default() + }; + + if let Some(tag) = config.get("tag").and_then(|t| t.as_str()) { + git_source.tag = Some(tag.to_string()); + } + if let Some(branch) = config.get("branch").and_then(|b| b.as_str()) { + git_source.branch = Some(branch.to_string()); + } + + sources.push(serialize::SourceElement::Git(git_source)); + } + // Note: Path sources would be handled here if the serialize module supported them + } else { + // Partial updates to existing source + if !sources.is_empty() { + if let serialize::SourceElement::Url(url_source) = &mut sources[0] { + if let Some(sha256) = config.get("sha256").and_then(|s| s.as_str()) { + url_source.sha256 = Some(sha256.to_string()); + } + if let Some(md5) = config.get("md5").and_then(|m| m.as_str()) { + url_source.md5 = Some(md5.to_string()); + } + } + } + } + + Ok(()) +} + +/// Apply build section overrides from tool.conda.recipe.build +fn apply_build_overrides( + build: &mut serialize::Build, + config: &serde_json::Map, +) -> miette::Result<()> { + if let Some(script) = config.get("script").and_then(|s| s.as_str()) { + build.script = script.to_string(); + } + + if let Some(noarch) = config.get("noarch").and_then(|n| n.as_str()) { + build.noarch = Some(noarch.to_string()); + } + + if let Some(number) = config.get("number").and_then(|n| n.as_u64()) { + build.number = Some(number as u32); + } + + // Handle python section overrides + if let Some(python_config) = config.get("python").and_then(|p| p.as_object()) { + if let Some(entry_points) = python_config + .get("entry_points") + .and_then(|ep| ep.as_array()) + { + build.python.entry_points = entry_points + .iter() + .filter_map(|ep| ep.as_str().map(|s| s.to_string())) + .collect(); + } + } + + // Handle skip conditions if present + if let Some(skip) = config.get("skip").and_then(|s| s.as_array()) { + // Note: The serialize module doesn't currently have a skip field, + // but this shows where it would be handled + tracing::info!( + "Skip conditions found but not yet supported in serialize module: {:?}", + skip + ); + } + + Ok(()) +} + +/// Apply requirements section overrides from tool.conda.recipe.requirements +fn apply_requirements_overrides( + requirements: &mut serialize::Requirements, + config: &serde_json::Map, +) -> miette::Result<()> { + // Handle build requirements + if let Some(build) = config.get("build").and_then(|b| b.as_array()) { + requirements.build = build + .iter() + .filter_map(|dep| dep.as_str().map(|s| s.to_string())) + .collect(); + } + + // Handle host requirements + if let Some(host) = config.get("host").and_then(|h| h.as_array()) { + requirements.host = host + .iter() + .filter_map(|dep| dep.as_str().map(|s| s.to_string())) + .collect(); + } + + // Handle run requirements + if let Some(run) = config.get("run").and_then(|r| r.as_array()) { + requirements.run = run + .iter() + .filter_map(|dep| dep.as_str().map(|s| s.to_string())) + .collect(); + } + + // Note: pyrattler-recipe-autogen also supports conditional requirements + // with selectors like: + // run_constrained, run_exports, etc. These could be added here + // when the serialize module supports them + + Ok(()) +} + +/// Apply test section overrides from tool.conda.recipe.test +fn apply_test_overrides( + tests: &mut Vec, + config: &serde_json::Map, +) -> miette::Result<()> { + // If we have test configuration, ensure we have at least one test + if tests.is_empty() { + tests.push(serialize::Test::Python(serialize::PythonTest::default())); + } + + // Handle python test configuration + if let Some(python_config) = config.get("python").and_then(|p| p.as_object()) { + // Ensure we have a Python test + if let serialize::Test::Python(python_test) = &mut tests[0] { + if let Some(imports) = python_config.get("imports").and_then(|i| i.as_array()) { + python_test.python.imports = imports + .iter() + .filter_map(|imp| imp.as_str().map(|s| s.to_string())) + .collect(); + } + + if let Some(pip_check) = python_config.get("pip_check").and_then(|pc| pc.as_bool()) { + python_test.python.pip_check = pip_check; + } + } + } + + // Handle script-based test commands + if let Some(commands) = config.get("commands").and_then(|c| c.as_array()) { + let script_commands: Vec = commands + .iter() + .filter_map(|cmd| cmd.as_str().map(|s| s.to_string())) + .collect(); + + if !script_commands.is_empty() { + let script_test = serialize::Test::Script(serialize::ScriptTest { + script: script_commands, + }); + tests.push(script_test); + } + } + + // Handle test requirements (if supported in the future) + if let Some(requires) = config.get("requires").and_then(|r| r.as_array()) { + let _test_requires: Vec = requires + .iter() + .filter_map(|req| req.as_str().map(|s| s.to_string())) + .collect(); + // Note: Test requirements would be added here when serialize module supports them + tracing::info!("Test requirements found but not yet supported in serialize module"); + } + + Ok(()) +} + +/// Apply about section overrides from tool.conda.recipe.about +fn apply_about_overrides( + about: &mut serialize::About, + config: &serde_json::Map, +) -> miette::Result<()> { + if let Some(homepage) = config.get("homepage").and_then(|h| h.as_str()) { + about.homepage = Some(homepage.to_string()); + } + + if let Some(summary) = config.get("summary").and_then(|s| s.as_str()) { + about.summary = Some(summary.to_string()); + } + + if let Some(description) = config.get("description").and_then(|d| d.as_str()) { + about.description = Some(description.to_string()); + } + + if let Some(license) = config.get("license").and_then(|l| l.as_str()) { + about.license = Some(license.to_string()); + } + + if let Some(license_file) = config.get("license_file") { + match license_file { + Value::String(file) => { + about.license_file = Some(file.clone()); + } + Value::Array(files) => { + let file_strings: Vec = files + .iter() + .filter_map(|f| f.as_str().map(|s| s.to_string())) + .collect(); + if !file_strings.is_empty() { + // For now, take the first file. In future, serialize module might support arrays + about.license_file = Some(file_strings[0].clone()); + } + } + _ => {} + } + } + + if let Some(repository) = config.get("repository").and_then(|r| r.as_str()) { + about.repository = Some(repository.to_string()); + } + + if let Some(documentation) = config.get("documentation").and_then(|d| d.as_str()) { + about.documentation = Some(documentation.to_string()); + } + + // Handle common aliases used in pyrattler-recipe-autogen + if let Some(doc_url) = config.get("doc_url").and_then(|d| d.as_str()) { + about.documentation = Some(doc_url.to_string()); + } + + if let Some(dev_url) = config.get("dev_url").and_then(|d| d.as_str()) { + about.repository = Some(dev_url.to_string()); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + #[test] + fn test_convert_python_to_conda_dependency() { + // Test basic version constraints + assert_eq!( + convert_python_to_conda_dependency("numpy>=1.21.0"), + "numpy >=1.21.0" + ); + assert_eq!( + convert_python_to_conda_dependency("pandas==1.3.0"), + "pandas =1.3.0" + ); + assert_eq!( + convert_python_to_conda_dependency("requests~=2.25.0"), + "requests ~=2.25.0" + ); + + // Test environment markers + assert_eq!( + convert_python_to_conda_dependency("typing-extensions>=3.7; python_version<'3.8'"), + "typing-extensions >=3.7" + ); + + // Test multiple constraints + assert_eq!( + convert_python_to_conda_dependency("click>=7.0,<9.0"), + "click >=7.0,<9.0" + ); + } + + #[test] + fn test_format_python_constraint() { + assert_eq!(format_python_constraint(">=3.9"), "python >=3.9"); + assert_eq!(format_python_constraint(">=3.9,<4.0"), "python >=3.9,<4.0"); + } + + #[test] + fn test_apply_package_name_mapping() { + assert_eq!( + apply_package_name_mapping("pillow >=8.0.0"), + "pillow >=8.0.0" + ); + assert_eq!( + apply_package_name_mapping("pyyaml >=5.4.0"), + "pyyaml >=5.4.0" + ); + } + + #[test] + fn test_build_schema_version() { + // Test default schema version + let toml_data = HashMap::new(); + assert_eq!(build_schema_version(&toml_data), Some(1)); + + // Test custom schema version + let mut toml_data = HashMap::new(); + let tool_data = json!({ + "conda": { + "recipe": { + "schema_version": 2 + } + } + }); + toml_data.insert("tool".to_string(), tool_data); + assert_eq!(build_schema_version(&toml_data), Some(2)); + + // Test missing schema version in tool.conda.recipe + let mut toml_data = HashMap::new(); + let tool_data = json!({ + "conda": { + "recipe": {} + } + }); + toml_data.insert("tool".to_string(), tool_data); + assert_eq!(build_schema_version(&toml_data), Some(1)); + } + + #[test] + fn test_format_yaml_with_schema() { + let yaml_content = "schema_version: 1\npackage:\n name: test"; + let result = format_yaml_with_schema(yaml_content); + assert!(result.starts_with("# yaml-language-server: $schema=https://raw.githubusercontent.com/prefix-dev/recipe-format/main/schema.json")); + assert!(result.contains("schema_version: 1")); + } + + #[test] + fn test_build_context_section() { + let mut project = serde_json::Map::new(); + project.insert("name".to_string(), json!("test-package")); + project.insert("version".to_string(), json!("1.0.0")); + + let toml_data = HashMap::new(); + let context = build_context_section(&project, &toml_data).unwrap(); + + assert_eq!(context.get("name"), Some(&"test-package".to_string())); + assert_eq!(context.get("version"), Some(&"1.0.0".to_string())); + } + + #[test] + fn test_build_context_section_dynamic_version() { + let mut project = serde_json::Map::new(); + project.insert("name".to_string(), json!("test-package")); + project.insert("dynamic".to_string(), json!(["version"])); + + let mut toml_data = HashMap::new(); + let build_system = json!({ + "build-backend": "hatchling.build" + }); + toml_data.insert("build-system".to_string(), build_system); + + let context = build_context_section(&project, &toml_data).unwrap(); + assert_eq!(context.get("name"), Some(&"test-package".to_string())); + assert_eq!( + context.get("version"), + Some(&"${{ environ.get('HATCH_BUILD_VERSION', '0.1.0') }}".to_string()) + ); + } + + #[test] + fn test_build_package_section() { + let mut project = serde_json::Map::new(); + project.insert("name".to_string(), json!("test-package")); + project.insert("version".to_string(), json!("1.0.0")); + + let package = build_package_section(&project).unwrap(); + assert_eq!(package.name, "${{ name }}"); + assert_eq!(package.version, "${{ version }}"); + } + + #[test] + fn test_build_requirements_section() { + let mut project = serde_json::Map::new(); + project.insert("name".to_string(), json!("test-package")); + project.insert( + "dependencies".to_string(), + json!(["numpy>=1.21.0", "pandas>=1.3.0", "click>=8.0.0"]), + ); + project.insert("requires-python".to_string(), json!(">=3.9")); + + let mut toml_data = HashMap::new(); + let build_system = json!({ + "requires": ["setuptools", "wheel"] + }); + toml_data.insert("build-system".to_string(), build_system); + + let requirements = build_requirements_section(&project, &toml_data).unwrap(); + + // Check host dependencies include build system requirements + assert!(requirements.host.contains(&"setuptools".to_string())); + assert!(requirements.host.contains(&"wheel".to_string())); + + // Check run dependencies include project dependencies + assert!(requirements.run.contains(&"numpy >=1.21.0".to_string())); + assert!(requirements.run.contains(&"pandas >=1.3.0".to_string())); + assert!(requirements.run.contains(&"click >=8.0.0".to_string())); + + // Check python constraint is included + assert!(requirements.run.contains(&"python >=3.9".to_string())); + } + + #[test] + fn test_build_about_section() { + let mut project = serde_json::Map::new(); + project.insert("description".to_string(), json!("A test package")); + project.insert("license".to_string(), json!({"text": "MIT"})); + + let urls = json!({ + "Homepage": "https://example.com", + "Source": "https://github.com/example/test", + "Documentation": "https://docs.example.com" + }); + project.insert("urls".to_string(), urls); + + let about = build_about_section(&project).unwrap(); + assert_eq!(about.summary, Some("A test package".to_string())); + assert_eq!(about.license, Some("MIT".to_string())); + assert_eq!(about.homepage, Some("https://example.com".to_string())); + assert_eq!( + about.repository, + Some("https://github.com/example/test".to_string()) + ); + assert_eq!( + about.documentation, + Some("https://docs.example.com".to_string()) + ); + } + + #[test] + fn test_resolve_dynamic_version() { + // Test setuptools_scm + let mut toml_data = HashMap::new(); + let build_system = json!({ + "build-backend": "setuptools_scm.build_meta" + }); + toml_data.insert("build-system".to_string(), build_system); + + let version = resolve_dynamic_version(&toml_data).unwrap(); + assert_eq!( + version, + "${{ environ.get('SETUPTOOLS_SCM_PRETEND_VERSION', '0.1.0') }}" + ); + + // Test hatchling + let mut toml_data = HashMap::new(); + let build_system = json!({ + "build-backend": "hatchling.build" + }); + toml_data.insert("build-system".to_string(), build_system); + + let version = resolve_dynamic_version(&toml_data).unwrap(); + assert_eq!( + version, + "${{ environ.get('HATCH_BUILD_VERSION', '0.1.0') }}" + ); + } +} diff --git a/src/recipe_generator/serialize.rs b/src/recipe_generator/serialize.rs index 228c3116b..414672c2f 100644 --- a/src/recipe_generator/serialize.rs +++ b/src/recipe_generator/serialize.rs @@ -47,6 +47,8 @@ pub struct GitSourceElement { #[derive(Default, Debug, Serialize)] pub struct Build { pub script: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub number: Option, #[serde(skip_serializing_if = "Python::is_default")] pub python: Python, #[serde(skip_serializing_if = "Option::is_none")] @@ -114,6 +116,8 @@ pub enum Test { #[derive(Default, Debug, Serialize)] pub struct Recipe { + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_version: Option, pub context: IndexMap, pub package: Package, pub source: Vec, diff --git a/test/end-to-end/test_generate_recipe_pyproject.py b/test/end-to-end/test_generate_recipe_pyproject.py new file mode 100644 index 000000000..28629b4a9 --- /dev/null +++ b/test/end-to-end/test_generate_recipe_pyproject.py @@ -0,0 +1,278 @@ +""" +End-to-end tests for pyproject.toml recipe generation functionality. +""" + +from pathlib import Path +from textwrap import dedent + +import yaml + +from helpers import RattlerBuild + + +def test_basic_pyproject_generation(rattler_build: RattlerBuild, tmp_path: Path): + """Test basic pyproject.toml recipe generation.""" + # Create a simple pyproject.toml + pyproject_content = dedent(""" + [build-system] + requires = ["setuptools>=45", "wheel"] + build-backend = "setuptools.build_meta" + + [project] + name = "test-package" + version = "1.0.0" + description = "A test package" + authors = [{name = "Test Author", email = "test@example.com"}] + license = {text = "MIT"} + readme = "README.md" + requires-python = ">=3.8" + dependencies = [ + "numpy>=1.20.0", + "requests", + ] + keywords = ["test", "example"] + classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + ] + + [project.urls] + Homepage = "https://github.com/example/test-package" + Repository = "https://github.com/example/test-package.git" + """).strip() + + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text(pyproject_content) + + # Generate recipe + output_dir = tmp_path / "output" + output_dir.mkdir() + + result = rattler_build( + "generate-recipe", + "pyproject", + "--input", + str(pyproject_file), + "--output", + str(output_dir / "recipe.yaml"), + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + + # Check that recipe.yaml was created + recipe_file = output_dir / "recipe.yaml" + assert recipe_file.exists() + + # Parse and validate the generated recipe + with open(recipe_file) as f: + recipe = yaml.safe_load(f) + + # Validate basic structure + assert recipe["schema_version"] == 1 + assert recipe["context"]["name"] == "test-package" + assert recipe["context"]["version"] == "1.0.0" + assert recipe["context"]["python_min"] == "3.8" + assert recipe["package"]["name"] == "${{ name }}" + assert recipe["package"]["version"] == "${{ version }}" + assert recipe["about"]["summary"] == "A test package" + assert recipe["about"]["license"] == "MIT" + assert recipe["about"]["homepage"] == "https://github.com/example/test-package" + assert ( + recipe["about"]["repository"] == "https://github.com/example/test-package.git" + ) + + # Check requirements + assert "python >=3.8" in recipe["requirements"]["host"] + assert "python >=3.8" in recipe["requirements"]["run"] + assert "numpy >=1.20.0" in recipe["requirements"]["run"] + assert "requests" in recipe["requirements"]["run"] + assert "setuptools>=45" in recipe["requirements"]["host"] + assert "wheel" in recipe["requirements"]["host"] + + # Check source + assert ( + recipe["source"][0]["url"] + == "https://github.com/example/test-package/archive/v${{ version }}.tar.gz" + ) + + +def test_pyproject_help_command(rattler_build: RattlerBuild): + """Test that pyproject help is available.""" + result = rattler_build( + "generate-recipe", "pyproject", "--help", capture_output=True, text=True + ) + + assert result.returncode == 0 + assert "pyproject.toml" in result.stdout.lower() + + +def test_pyproject_missing_file_error(rattler_build: RattlerBuild, tmp_path: Path): + """Test error handling for missing pyproject.toml file.""" + nonexistent_file = tmp_path / "nonexistent.toml" + + result = rattler_build( + "generate-recipe", + "pyproject", + "--input", + str(nonexistent_file), + "--output", + str(tmp_path / "output.yaml"), + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "No such file" in result.stderr or "not found" in result.stderr.lower() + + +def test_pyproject_overwrite_protection(rattler_build: RattlerBuild, tmp_path: Path): + """Test that existing files are not overwritten without --overwrite.""" + pyproject_content = dedent(""" + [project] + name = "overwrite-test" + version = "1.0.0" + """).strip() + + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text(pyproject_content) + + recipe_file = tmp_path / "recipe.yaml" + + # Create existing recipe file + recipe_file.write_text("existing: content") + + # Should fail without --overwrite + result = rattler_build( + "generate-recipe", + "pyproject", + "--input", + str(pyproject_file), + "--output", + str(recipe_file), + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "already exists" in result.stderr or "overwrite" in result.stderr.lower() + + # Original content should be preserved + assert recipe_file.read_text() == "existing: content" + + +def test_pyproject_overwrite_flag(rattler_build: RattlerBuild, tmp_path: Path): + """Test that --overwrite allows overwriting existing files.""" + pyproject_content = dedent(""" + [project] + name = "overwrite-test" + version = "1.0.0" + """).strip() + + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text(pyproject_content) + + recipe_file = tmp_path / "recipe.yaml" + + # Create existing recipe file + recipe_file.write_text("existing: content") + + # Should succeed with --overwrite + result = rattler_build( + "generate-recipe", + "pyproject", + "--input", + str(pyproject_file), + "--output", + str(recipe_file), + "--overwrite", + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + + # Should have new content + with open(recipe_file) as f: + recipe = yaml.safe_load(f) + + assert recipe["context"]["name"] == "overwrite-test" + + +def test_pyproject_schema_version_in_output( + rattler_build: RattlerBuild, tmp_path: Path +): + """Test that schema_version is correctly set in generated recipes.""" + pyproject_content = dedent(""" + [project] + name = "schema-test" + version = "1.0.0" + """).strip() + + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text(pyproject_content) + + output_dir = tmp_path / "output" + output_dir.mkdir() + + result = rattler_build( + "generate-recipe", + "pyproject", + "--input", + str(pyproject_file), + "--output", + str(output_dir / "recipe.yaml"), + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + + recipe_file = output_dir / "recipe.yaml" + + # Check that the raw YAML starts with schema_version + content = recipe_file.read_text() + # Skip any comments at the top + lines = [line for line in content.strip().split("\n") if not line.startswith("#")] + assert lines[0] == "schema_version: 1" + + # Also verify it parses correctly + with open(recipe_file) as f: + recipe = yaml.safe_load(f) + assert recipe["schema_version"] == 1 + + +def test_pyproject_stdout_output(rattler_build: RattlerBuild, tmp_path: Path): + """Test that output goes to stdout when no --output is provided.""" + pyproject_content = dedent(""" + [project] + name = "stdout-test" + version = "1.0.0" + description = "Test stdout output" + """).strip() + + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text(pyproject_content) + + result = rattler_build( + "generate-recipe", + "pyproject", + "--input", + str(pyproject_file), + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + + # Check that output was written to stdout + assert "schema_version: 1" in result.stdout + assert "stdout-test" in result.stdout + + # Parse the YAML output from stdout + import yaml + + recipe = yaml.safe_load(result.stdout) + assert recipe["context"]["name"] == "stdout-test" + assert recipe["context"]["version"] == "1.0.0"