From 07025a0ce944e3b9dbd2733b7784570dc154e69e Mon Sep 17 00:00:00 2001 From: Ezra Brooks Date: Thu, 12 Nov 2026 12:26:58 -0700 Subject: [PATCH] Make file parsing safer and statically typed Utilizes pydantic, py-rattler-build, and conda-forge-metadata to better-validate input YAML and JSON files throughout the Vinca codebase. Then ran `pyrefly infer` again to populate now-inferable types. --- pixi.lock | 823 +++++++++++++++++++++++++---- pyproject.toml | 5 +- vinca/configuration.py | 196 ++++--- vinca/distro.py | 90 ++-- vinca/generate_gha.py | 12 +- vinca/main.py | 163 +++--- vinca/migrate.py | 22 +- vinca/mutex.py | 87 +-- vinca/naming.py | 55 +- vinca/pinning.py | 135 +++-- vinca/pipeline.py | 37 +- vinca/rebuild.py | 2 +- vinca/recipes.py | 74 ++- vinca/resolve.py | 56 +- vinca/schemas.py | 405 ++++++++++++++ vinca/skip_existing.py | 60 ++- vinca/sources.py | 36 +- vinca/template.py | 53 +- vinca/test_archive_sources.py | 30 +- vinca/test_config_layering.py | 59 ++- vinca/test_configuration.py | 63 ++- vinca/test_distro_dependencies.py | 15 +- vinca/test_github_raw_url.py | 32 +- vinca/test_mutex.py | 30 +- vinca/test_mutex_build_number.py | 25 +- vinca/test_naming_scheme.py | 99 +++- vinca/test_package_exclusions.py | 32 +- vinca/test_pinning.py | 96 +++- vinca/test_provenance.py | 59 ++- vinca/test_recipes.py | 28 +- vinca/test_skip_existing_builds.py | 33 +- vinca/test_snapshot_metadata.py | 49 +- vinca/test_sources.py | 31 +- vinca/test_template.py | 21 +- vinca/test_utils.py | 26 +- vinca/test_variants.py | 34 +- vinca/utils.py | 32 +- vinca/variant_algebra.py | 35 +- vinca/variants.py | 33 +- 39 files changed, 2140 insertions(+), 1033 deletions(-) create mode 100644 vinca/schemas.py diff --git a/pixi.lock b/pixi.lock index f6ab70d..859dc59 100644 --- a/pixi.lock +++ b/pixi.lock @@ -30,6 +30,7 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 - conda: https://prefix.dev/conda-forge/linux-64/brotli-python-1.2.0-py314hcd2bdb6_3.conda - conda: https://prefix.dev/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda + - conda: https://prefix.dev/conda-forge/linux-64/cffi-2.1.1-py314h4a8dc5f_0.conda - conda: https://prefix.dev/conda-forge/linux-64/ld_impl_linux-64-2.44-h1aa0949_4.conda - conda: https://prefix.dev/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda - conda: https://prefix.dev/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda @@ -42,32 +43,44 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda - conda: https://prefix.dev/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - conda: https://prefix.dev/conda-forge/linux-64/markupsafe-3.0.3-py314h67df5f8_1.conda + - conda: https://prefix.dev/conda-forge/linux-64/msgpack-python-1.2.4-py314h5383ef5_0.conda - conda: https://prefix.dev/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda - conda: https://prefix.dev/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda - conda: https://prefix.dev/conda-forge/linux-64/patchelf-0.19.2-hee9eb32_0.conda - conda: https://prefix.dev/conda-forge/linux-64/py-rattler-0.28.1-py311hc91d8b8_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/py-rattler-build-0.73.0-py311h30674b1_0.conda - conda: https://prefix.dev/conda-forge/linux-64/pydantic-core-2.46.5-py314h7e8cd81_2.conda - conda: https://prefix.dev/conda-forge/linux-64/pyrefly-1.2.0-hb17b654_0.conda - conda: https://prefix.dev/conda-forge/linux-64/python-3.14.0-h32b2ec7_102_cp314.conda - conda: https://prefix.dev/conda-forge/linux-64/pyyaml-6.0.3-py314h67df5f8_1.conda - conda: https://prefix.dev/conda-forge/linux-64/rattler-build-0.77.0-hf01adef_1.conda - conda: https://prefix.dev/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda + - conda: https://prefix.dev/conda-forge/linux-64/rpds-py-2026.9.1-py314h7e8cd81_0.conda - conda: https://prefix.dev/conda-forge/linux-64/ruamel.yaml.clib-0.2.15-py314hfe1a184_3.conda - conda: https://prefix.dev/conda-forge/linux-64/ruff-0.14.2-ha3a3aed_0.conda - conda: https://prefix.dev/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda + - conda: https://prefix.dev/conda-forge/linux-64/wrapt-2.5.2-py314hfe1a184_0.conda - conda: https://prefix.dev/conda-forge/linux-64/yaml-0.2.5-hebe6cf0_3.conda + - conda: https://prefix.dev/conda-forge/linux-64/zstandard-0.25.0-py314hfe1a184_4.conda - conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/annotated-doc-0.0.5-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://prefix.dev/conda-forge/noarch/boolean.py-5.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/catkin_pkg-1.1.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/click-8.5.1-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-forge-metadata-2026.9.10-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-oci-mirror-0.3.0-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-package-handling-2.6.0-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-package-streaming-0.13.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda + - conda: https://prefix.dev/conda-forge/noarch/deprecated-3.0.1-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/docutils-0.23-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/empy-3.3.4-pyh9f0ad1d_1.tar.bz2 @@ -78,12 +91,16 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/idna-3.19-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://prefix.dev/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_1.conda + - conda: https://prefix.dev/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda - conda: https://prefix.dev/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/oras-py-0.1.14-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pycparser-3.12-pyh5ded981_1.conda - conda: https://prefix.dev/conda-forge/noarch/pydantic-2.13.5-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda @@ -92,6 +109,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://prefix.dev/conda-forge/noarch/python-gil-3.14.10-h4df99d1_100.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://prefix.dev/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/rosdistro-1.0.1-pyhd8ed1ab_0.conda @@ -107,19 +125,26 @@ environments: - 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.7.0-pyhd8ed1ab_0.conda - - conda_source: vinca[9761a393] @ . + - conda_source: vinca[69cbd77e] @ . osx-64: - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/annotated-doc-0.0.5-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://prefix.dev/conda-forge/noarch/boolean.py-5.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/catkin_pkg-1.1.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/click-8.5.1-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-forge-metadata-2026.9.10-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-oci-mirror-0.3.0-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-package-handling-2.6.0-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-package-streaming-0.13.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda + - conda: https://prefix.dev/conda-forge/noarch/deprecated-3.0.1-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/docutils-0.23-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/empy-3.3.4-pyh9f0ad1d_1.tar.bz2 @@ -130,12 +155,16 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/idna-3.19-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://prefix.dev/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_1.conda + - conda: https://prefix.dev/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda - conda: https://prefix.dev/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/oras-py-0.1.14-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pycparser-3.12-pyh5ded981_1.conda - conda: https://prefix.dev/conda-forge/noarch/pydantic-2.13.5-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda @@ -144,6 +173,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://prefix.dev/conda-forge/noarch/python-gil-3.14.10-h4df99d1_100.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://prefix.dev/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/rosdistro-1.0.1-pyhd8ed1ab_0.conda @@ -161,6 +191,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/osx-64/brotli-python-1.2.0-py314h21a1a37_3.conda - conda: https://prefix.dev/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda + - conda: https://prefix.dev/conda-forge/osx-64/cffi-2.1.1-py314hb60be56_0.conda - conda: https://prefix.dev/conda-forge/osx-64/libcxx-23.1.0-h19cb2f5_0.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.5.2-h750e83c_0.conda @@ -169,33 +200,45 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/libsqlite-3.50.4-h39a8b3b_0.conda - conda: https://prefix.dev/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda - conda: https://prefix.dev/conda-forge/osx-64/markupsafe-3.0.3-py314h77fa6c7_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/msgpack-python-1.2.4-py314h83cadc3_0.conda - conda: https://prefix.dev/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda - - conda: https://prefix.dev/conda-forge/osx-64/openssl-3.5.4-h230baf5_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/openssl-3.6.5-h332eb6d_0.conda - conda: https://prefix.dev/conda-forge/osx-64/py-rattler-0.28.1-py311h85c30bf_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/py-rattler-build-0.73.0-py311h7a3dd7d_0.conda - conda: https://prefix.dev/conda-forge/osx-64/pydantic-core-2.46.5-py314h3cfb53e_2.conda - conda: https://prefix.dev/conda-forge/osx-64/pyrefly-1.2.0-h19f9e61_0.conda - conda: https://prefix.dev/conda-forge/osx-64/python-3.14.0-hf88997e_102_cp314.conda - conda: https://prefix.dev/conda-forge/osx-64/pyyaml-6.0.3-py314h10d0514_1.conda - conda: https://prefix.dev/conda-forge/osx-64/rattler-build-0.77.0-h9a0f642_1.conda - conda: https://prefix.dev/conda-forge/osx-64/readline-8.2-h7cca4af_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/rpds-py-2026.9.1-py314h3cfb53e_0.conda - conda: https://prefix.dev/conda-forge/osx-64/ruamel.yaml.clib-0.2.15-py314h17af80e_3.conda - conda: https://prefix.dev/conda-forge/osx-64/ruff-0.14.2-hba89d1c_0.conda - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-hf689a15_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/wrapt-2.5.2-py314he3ee658_0.conda - conda: https://prefix.dev/conda-forge/osx-64/yaml-0.2.5-had63097_3.conda + - conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.25.0-py314h17af80e_4.conda - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-h8210216_2.conda - - conda_source: vinca[ddc7a8c1] @ . + - conda_source: vinca[40b6087e] @ . osx-arm64: - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/annotated-doc-0.0.5-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://prefix.dev/conda-forge/noarch/boolean.py-5.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/catkin_pkg-1.1.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/click-8.5.1-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-forge-metadata-2026.9.10-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-oci-mirror-0.3.0-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-package-handling-2.6.0-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-package-streaming-0.13.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda + - conda: https://prefix.dev/conda-forge/noarch/deprecated-3.0.1-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/docutils-0.23-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/empy-3.3.4-pyh9f0ad1d_1.tar.bz2 @@ -206,12 +249,16 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/idna-3.19-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://prefix.dev/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_1.conda + - conda: https://prefix.dev/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda - conda: https://prefix.dev/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/oras-py-0.1.14-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pycparser-3.12-pyh5ded981_1.conda - conda: https://prefix.dev/conda-forge/noarch/pydantic-2.13.5-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda @@ -220,6 +267,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://prefix.dev/conda-forge/noarch/python-gil-3.14.10-h4df99d1_100.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://prefix.dev/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/rosdistro-1.0.1-pyhd8ed1ab_0.conda @@ -237,6 +285,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/brotli-python-1.2.0-py314hee34562_3.conda - conda: https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/cffi-2.1.1-py314h7bede21_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/libcxx-23.1.0-h55c6f16_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda @@ -246,33 +295,45 @@ environments: - conda: https://prefix.dev/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - conda: https://prefix.dev/conda-forge/osx-arm64/markupsafe-3.0.3-py314h6e9b3f0_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/msgpack-python-1.2.4-py314h1433326_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.5.4-h5503f6c_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.5-h55eecbc_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/py-rattler-0.28.1-py311h1e0e963_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/py-rattler-build-0.73.0-py311h15292d5_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/pydantic-core-2.46.5-py314hc05cd11_2.conda - conda: https://prefix.dev/conda-forge/osx-arm64/pyrefly-1.2.0-h6fdd925_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/python-3.14.0-h40d2674_102_cp314.conda - conda: https://prefix.dev/conda-forge/osx-arm64/pyyaml-6.0.3-py314h6e9b3f0_1.conda - conda: https://prefix.dev/conda-forge/osx-arm64/rattler-build-0.77.0-h2ace698_1.conda - conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/rpds-py-2026.9.1-py314hc05cd11_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/ruamel.yaml.clib-0.2.15-py314hd98292b_3.conda - conda: https://prefix.dev/conda-forge/osx-arm64/ruff-0.14.2-h492a034_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/wrapt-2.5.2-py314hef190fd_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/yaml-0.2.5-h74c22ad_3.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/zstandard-0.25.0-py314hd98292b_4.conda - conda: https://prefix.dev/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda - - conda_source: vinca[485b9dee] @ . + - conda_source: vinca[c949dab8] @ . win-64: - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/annotated-doc-0.0.5-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda - conda: https://prefix.dev/conda-forge/noarch/boolean.py-5.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2025.10.5-h4c7d964_0.conda - conda: https://prefix.dev/conda-forge/noarch/catkin_pkg-1.1.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/charset-normalizer-3.5.1-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/click-8.5.1-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-forge-metadata-2026.9.10-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-oci-mirror-0.3.0-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-package-handling-2.6.0-pyh5ded981_0.conda + - conda: https://prefix.dev/conda-forge/noarch/conda-package-streaming-0.13.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda + - conda: https://prefix.dev/conda-forge/noarch/deprecated-3.0.1-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/docutils-0.23-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/empy-3.3.4-pyh9f0ad1d_1.tar.bz2 @@ -283,12 +344,16 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/idna-3.19-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://prefix.dev/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_1.conda + - conda: https://prefix.dev/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda - conda: https://prefix.dev/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda + - conda: https://prefix.dev/conda-forge/noarch/oras-py-0.1.14-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda + - conda: https://prefix.dev/conda-forge/noarch/pycparser-3.12-pyh5ded981_1.conda - conda: https://prefix.dev/conda-forge/noarch/pydantic-2.13.5-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda @@ -297,6 +362,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://prefix.dev/conda-forge/noarch/python-gil-3.14.10-h4df99d1_100.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://prefix.dev/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/rosdistro-1.0.1-pyhd8ed1ab_0.conda @@ -315,6 +381,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - conda: https://prefix.dev/conda-forge/win-64/brotli-python-1.2.0-py314h85cf176_3.conda - conda: https://prefix.dev/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda + - conda: https://prefix.dev/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_3.conda - conda: https://prefix.dev/conda-forge/win-64/libexpat-2.7.1-hac47afa_0.conda - conda: https://prefix.dev/conda-forge/win-64/libffi-3.5.2-h52bdfb6_0.conda - conda: https://prefix.dev/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda @@ -322,13 +389,16 @@ environments: - conda: https://prefix.dev/conda-forge/win-64/libsqlite-3.50.4-hf5d6505_0.conda - conda: https://prefix.dev/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda - conda: https://prefix.dev/conda-forge/win-64/markupsafe-3.0.3-py314h2359020_1.conda + - conda: https://prefix.dev/conda-forge/win-64/msgpack-python-1.2.4-py314hf309875_0.conda - conda: https://prefix.dev/conda-forge/win-64/openssl-3.5.4-h725018a_0.conda - conda: https://prefix.dev/conda-forge/win-64/py-rattler-0.28.1-py311h66afae6_0.conda + - conda: https://prefix.dev/conda-forge/win-64/py-rattler-build-0.73.0-py311hc3dac5c_0.conda - conda: https://prefix.dev/conda-forge/win-64/pydantic-core-2.46.5-py314h9f07db2_2.conda - conda: https://prefix.dev/conda-forge/win-64/pyrefly-1.2.0-h18a1a76_0.conda - conda: https://prefix.dev/conda-forge/win-64/python-3.14.0-h4b44e0e_102_cp314.conda - conda: https://prefix.dev/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda - conda: https://prefix.dev/conda-forge/win-64/rattler-build-0.77.0-he94b42d_1.conda + - conda: https://prefix.dev/conda-forge/win-64/rpds-py-2026.9.1-py314h9f07db2_0.conda - conda: https://prefix.dev/conda-forge/win-64/ruamel.yaml.clib-0.2.15-py314hc5dbbe4_3.conda - conda: https://prefix.dev/conda-forge/win-64/ruff-0.14.2-h3e3edff_0.conda - conda: https://prefix.dev/conda-forge/win-64/tk-8.6.13-h2c6b04d_2.conda @@ -336,9 +406,11 @@ environments: - conda: https://prefix.dev/conda-forge/win-64/vc-14.3-h2b53caa_32.conda - conda: https://prefix.dev/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_32.conda - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.44.35208-h818238b_32.conda + - conda: https://prefix.dev/conda-forge/win-64/wrapt-2.5.2-py314hc5dbbe4_0.conda - conda: https://prefix.dev/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda + - conda: https://prefix.dev/conda-forge/win-64/zstandard-0.25.0-py314hc5dbbe4_4.conda - conda: https://prefix.dev/conda-forge/win-64/zstd-1.5.7-hbeecb71_2.conda - - conda_source: vinca[da38084e] @ . + - conda_source: vinca[efcd1955] @ . packages: - conda: https://prefix.dev/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 @@ -417,6 +489,21 @@ packages: purls: [] size: 260341 timestamp: 1757437258798 +- conda: https://prefix.dev/conda-forge/linux-64/cffi-2.1.1-py314h4a8dc5f_0.conda + sha256: eec96c89f9f445da65cfc315e9c8572968335e127a18f17104bc9a90df103058 + md5: f10f311ad713701678c05ecc19c22784 + depends: + - __glibc >=2.17,<3.0.a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - pycparser + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + run_exports: {} + size: 306515 + timestamp: 1785810913099 - conda: https://prefix.dev/conda-forge/linux-64/icu-78.3-py310h44b86e0_2.conda sha256: 9f07834f0c546ab14d885ce0366285f61f44e326c0edd1fc63b8294e113ae432 md5: 72a381cbad04f24b1c2a43ef707f45b4 @@ -734,6 +821,20 @@ packages: run_exports: {} size: 27424 timestamp: 1772445227915 +- conda: https://prefix.dev/conda-forge/linux-64/msgpack-python-1.2.4-py314h5383ef5_0.conda + sha256: 0979bbb228ea5fc94ad2191cc92841ec6628a85b6c34d0b87f4a6e30fcb49f54 + md5: d8f34934b664c2f6ae2175833789df9a + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=15 + - libgcc >=15 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 122174 + timestamp: 1793105660971 - conda: https://prefix.dev/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 md5: 47e340acb35de30501a76c7c799c41d7 @@ -817,6 +918,24 @@ packages: run_exports: {} size: 15049429 timestamp: 1793309611253 +- conda: https://prefix.dev/conda-forge/linux-64/py-rattler-build-0.73.0-py311h30674b1_0.conda + noarch: python + sha256: 20d345d91f4b8c22444a5e0cf1f748b6c43ff790537337806f599e5c311b3b7a + md5: 22094f8dc5212ee1269d7f8bf499d8ea + depends: + - python + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - openssl >=3.5.8,<4.0a0 + - _python_abi3_support 1.* + - cpython >=3.11 + constrains: + - __glibc >=2.17 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 17985146 + timestamp: 1789639970798 - conda: https://prefix.dev/conda-forge/linux-64/pydantic-core-2.46.5-py314h7e8cd81_2.conda sha256: 7887472515d5d2d3b57bd38d3b055ae24f98c399b9a9387ac441e97bda775884 md5: 76d65459d9ac0eb2ecb48d6650c5ced1 @@ -962,6 +1081,21 @@ packages: - readline >=8.3,<9.0a0 size: 348899 timestamp: 1787033801506 +- conda: https://prefix.dev/conda-forge/linux-64/rpds-py-2026.9.1-py314h7e8cd81_0.conda + sha256: 831c47efcbbd77e60462a41ee610b553d38bb7b3830a17f09472836c6a7cc0d4 + md5: 2bf437da5f8d248f328f28efb27f297f + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - python_abi 3.14.* *_cp314 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + run_exports: {} + size: 302823 + timestamp: 1791202484532 - conda: https://prefix.dev/conda-forge/linux-64/ruamel.yaml.clib-0.2.15-py314hfe1a184_3.conda sha256: 2e7000a5e1627e4f3aa070e772be4cd1a6d83484f954c4b8e0a53b3e99bf1e88 md5: f86f9c197fa46247e5ccaaccdee18bf3 @@ -1031,6 +1165,19 @@ packages: run_exports: {} size: 17592253 timestamp: 1788240832085 +- conda: https://prefix.dev/conda-forge/linux-64/wrapt-2.5.2-py314hfe1a184_0.conda + sha256: 3bac0999051f29c79a72f621df1c1a97787d31f7741d912319f9600bbff97afd + md5: bcc76af5b1a9c1445950aea8c641f05c + depends: + - python + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 157827 + timestamp: 1793358807107 - conda: https://prefix.dev/conda-forge/linux-64/yaml-0.2.5-hebe6cf0_3.conda sha256: d164dfa75ecd538f6fd68765defcc06aa875bc697b9b215362d79a2a73125dd0 md5: e741576fb8f89821ac7c1c537322a33d @@ -1044,6 +1191,22 @@ packages: - yaml >=0.2.5,<0.3.0a0 size: 84936 timestamp: 1787228426393 +- conda: https://prefix.dev/conda-forge/linux-64/zstandard-0.25.0-py314hfe1a184_4.conda + sha256: c8d7983e80f6a6661e81da1449b2a9cc2c00db3ea5063afb2df440d61a766023 + md5: b09dac9bf89da373656ff947ca212d50 + depends: + - python + - cffi >=1.11 + - zstd >=1.5.7,<1.5.8.0a0 + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - zstd >=1.5.7,<1.6.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 472930 + timestamp: 1788604314653 - conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda sha256: 47d682b9f6d6ec9eb1a6e6c3e75ea6273e899e78fb7fc59f81d39745009fbc60 md5: aa459086047c0e5e27023ab19f8cb86a @@ -1103,6 +1266,17 @@ packages: run_exports: {} size: 19461 timestamp: 1784935220549 +- conda: https://prefix.dev/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda + sha256: 1b6124230bb4e571b1b9401537ecff575b7b109cc3a21ee019f65e083b8399ab + md5: c6b0543676ecb1fb2d7643941fe375f2 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 64927 + timestamp: 1773935801332 - conda: https://prefix.dev/conda-forge/noarch/backports.zstd-1.7.0-py314h680f03e_0.conda noarch: generic sha256: 19514d89d1e725e44b0650d31a4d43da8e070e4e93e4131e3b16bd139404f2b2 @@ -1193,6 +1367,17 @@ packages: run_exports: {} size: 64487 timestamp: 1786835648298 +- conda: https://prefix.dev/conda-forge/noarch/click-8.5.1-pyh5ded981_0.conda + sha256: f26981878e8ccdc82fc9025d25997aff119080486e5d089948cb718d428d8cb0 + md5: d996564510ceb5744c225b93891316dc + depends: + - python >=3.11 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 112921 + timestamp: 1794046236533 - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 md5: 962b9857ee8e7018c22f2776ffa0b2d7 @@ -1204,6 +1389,67 @@ packages: - pkg:pypi/colorama?source=hash-mapping size: 27011 timestamp: 1733218222191 +- conda: https://prefix.dev/conda-forge/noarch/conda-forge-metadata-2026.9.10-pyh5ded981_0.conda + sha256: 31cbe5ded7a0d340be9f2e1817835407a62fe8046a16f596191e811941b65eb9 + md5: 22004549d5a9a0b81d8b3f1870ecd532 + depends: + - python >=3.11 + - deprecated + - requests + - ruamel.yaml + - typing-extensions + - conda-oci-mirror + - conda-package-streaming >=0.12.0 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 23371 + timestamp: 1789086445904 +- conda: https://prefix.dev/conda-forge/noarch/conda-oci-mirror-0.3.0-pyh5ded981_0.conda + sha256: 18ac85b028c64a8c3dfb896581152af7febcd38ac3b84eac4f0fb03f2a2a991d + md5: 5ef8511883c4462bfde9f9e15f7414bf + depends: + - python >=3.11 + - click + - requests + - oras-py ==0.1.14 + - conda-package-handling + - zstandard + - packaging + - py-rattler >=0.15 + - msgpack-python + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 54703 + timestamp: 1791805631236 +- conda: https://prefix.dev/conda-forge/noarch/conda-package-handling-2.6.0-pyh5ded981_0.conda + sha256: 34b3c276e8426d851c62b905014ef4c05cfcc090f052d7ec4da1dda53e10bc57 + md5: 412f13a9700d4455dfca5c5e23be9404 + depends: + - python >=3.11 + - conda-package-streaming >=0.13.0 + - backports.zstd >=1.1.0 + - requests + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 257896 + timestamp: 1788438543851 +- conda: https://prefix.dev/conda-forge/noarch/conda-package-streaming-0.13.0-pyhd8ed1ab_0.conda + sha256: af3b666ea6043a146901cd151d6201126bbe19af8307b58fb175015e3710064c + md5: b4e3dcace82b486f69bc693f30120205 + depends: + - backports.zstd + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 23954 + timestamp: 1781293054998 - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda noarch: generic sha256: 80c395601d9cff6d342d3dc81b7c19fabf00a3644b7df053cff14783077e6d15 @@ -1215,6 +1461,18 @@ packages: run_exports: {} size: 52072 timestamp: 1793014778194 +- conda: https://prefix.dev/conda-forge/noarch/deprecated-3.0.1-pyhc364b38_0.conda + sha256: edf3e0e1e985b463ceaeebc4a762ab3d15609759dbc10bf41cbcc596bd364e2b + md5: 3e3aa1ff1a1042830bbaa446def7b80c + depends: + - python >=3.12 + - wrapt >=1.16,<3 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 23091 + timestamp: 1793404265265 - conda: https://prefix.dev/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda sha256: 5603c7d0321963bb9b4030eadabc3fd7ca6103a38475b4e0ed13ed6d97c86f4e md5: 0a2014fd9860f8b1eaa0b1f3d3771a08 @@ -1351,6 +1609,33 @@ packages: run_exports: {} size: 120685 timestamp: 1764517220861 +- conda: https://prefix.dev/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_1.conda + sha256: 328756a4555941d57af4a5f553e5d8598e9f3b37db028f557e30f9f0b3086db6 + md5: 204b5ca91eba7738790ecc333facbbbe + depends: + - attrs >=22.2.0 + - jsonschema-specifications >=2023.3.6 + - python >=3.10 + - referencing >=0.28.4 + - rpds-py >=0.25.0 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 82084 + timestamp: 1787579299493 +- conda: https://prefix.dev/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda + sha256: 0a4f3b132f0faca10c89fdf3b60e15abb62ded6fa80aebfc007d05965192aa04 + md5: 439cd0f567d697b20a8f45cb70a1005a + depends: + - python >=3.10 + - referencing >=0.31.0 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 19236 + timestamp: 1757335715225 - conda: https://prefix.dev/conda-forge/noarch/license-expression-30.4.4-pyhe01879c_0.conda sha256: c0fc62fa5c7552b5ec42324fdc6c9fef05eaa239a434c721df074b42e7a52611 md5: c9b00d4ac670f5401b9ed080c96eb9f1 @@ -1400,6 +1685,18 @@ packages: run_exports: {} size: 1587439 timestamp: 1765215107045 +- conda: https://prefix.dev/conda-forge/noarch/oras-py-0.1.14-pyhd8ed1ab_0.conda + sha256: 39b2cd630754de540ad4308942f78279cdc5d398815d684dbe3e6e59e144cc3e + md5: 0a1109e5353a81b0251583253d6ccf23 + depends: + - jsonschema + - python >=3.7 + - requests + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 34379 + timestamp: 1675555551728 - conda: https://prefix.dev/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda sha256: 289861ed0c13a15d7bbb408796af4de72c2fe67e2bcb0de98f4c3fce259d7991 md5: 58335b26c38bf4a20f399384c33cbcf9 @@ -1455,6 +1752,17 @@ packages: run_exports: {} size: 25877 timestamp: 1764896838868 +- conda: https://prefix.dev/conda-forge/noarch/pycparser-3.12-pyh5ded981_1.conda + sha256: eefb1b2140c1a6c33a85186f954e857e1a824365deeda4e11db9b99c1b5950aa + md5: 3c2d671ef0bc5a0e8d1f7e6c4286af0b + depends: + - python >=3.11 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 68212 + timestamp: 1792687210543 - conda: https://prefix.dev/conda-forge/noarch/pydantic-2.13.5-pyhcf101f3_0.conda sha256: 701ed8122021f62e1c9a46bdf4932ada911c10afa56ba0459f56da940bcbe5a7 md5: 2d0f9304fc1cb1e3e94e7b37c14cd70a @@ -1569,6 +1877,20 @@ packages: run_exports: {} size: 6989 timestamp: 1752805904792 +- conda: https://prefix.dev/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda + sha256: 0577eedfb347ff94d0f2fa6c052c502989b028216996b45c7f21236f25864414 + md5: 870293df500ca7e18bedefa5838a22ab + depends: + - attrs >=22.2.0 + - python >=3.10 + - rpds-py >=0.7.0 + - typing_extensions >=4.4.0 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 51788 + timestamp: 1760379115194 - conda: https://prefix.dev/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda sha256: 1715246b19c9f85ee022933b4845f2fc14ac9184981b7b7d9b728bec8e9588da md5: 4a85203c1d80c1059086ae860836ffb9 @@ -1838,6 +2160,20 @@ packages: purls: [] size: 132607 timestamp: 1757437730085 +- conda: https://prefix.dev/conda-forge/osx-64/cffi-2.1.1-py314hb60be56_0.conda + sha256: 4adecbfb8212fe44caaa135099b70dc00adbe14658d75f235c317d76b5944974 + md5: 92c2088c1c4b021d0f62cfe59b63465f + depends: + - __osx >=11.0 + - libffi >=3.5.2,<3.6.0a0 + - pycparser + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + run_exports: {} + size: 299378 + timestamp: 1785811225267 - conda: https://prefix.dev/conda-forge/osx-64/libcxx-23.1.0-h19cb2f5_0.conda sha256: da5b60cbdaf8d931e2534f3b17a0d9b5432ebc855509d36618d61a61f1f2909d md5: 925382e3a8a530f862be5be3b3aaf68b @@ -2011,6 +2347,19 @@ packages: run_exports: {} size: 26740 timestamp: 1772445674690 +- conda: https://prefix.dev/conda-forge/osx-64/msgpack-python-1.2.4-py314h83cadc3_0.conda + sha256: 8228f7e3174e39e850c878dd13110f9f79f951622e2a3c8baa4d0086c72fb40b + md5: 888cd6b9fbcfc84b0e25ead83bcab665 + depends: + - python + - __osx >=11.0 + - libcxx >=21 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 111463 + timestamp: 1793105768913 - conda: https://prefix.dev/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda sha256: ea4a5d27ded18443749aefa49dc79f6356da8506d508b5296f60b8d51e0c4bd9 md5: ced34dd9929f491ca6dab6a2927aff25 @@ -2031,17 +2380,6 @@ packages: - ncurses >=6.6,<7.0a0 size: 834865 timestamp: 1786356957789 -- conda: https://prefix.dev/conda-forge/osx-64/openssl-3.5.4-h230baf5_0.conda - sha256: 3ce8467773b2472b2919412fd936413f05a9b10c42e52c27bbddc923ef5da78a - md5: 075eaad78f96bbf5835952afbe44466e - depends: - - __osx >=10.13 - - ca-certificates - license: Apache-2.0 - license_family: Apache - purls: [] - size: 2747108 - timestamp: 1759326402264 - conda: https://prefix.dev/conda-forge/osx-64/openssl-3.6.4-h332eb6d_0.conda sha256: 73d648d24f0994fd8641972848d74bd57b8d80888a64600643e3fe88a1b50545 md5: 7a1b628e987d25df3ac6986d45bb0979 @@ -2055,6 +2393,19 @@ packages: - openssl >=3.6.4,<4.0a0 size: 2780873 timestamp: 1787700098779 +- conda: https://prefix.dev/conda-forge/osx-64/openssl-3.6.5-h332eb6d_0.conda + sha256: d992d27aec2f1beac81f792c85b449546c030f06937b5b3ea7cf58431861dde4 + md5: 615ac6cff316565b442cd1af8b94837f + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.5,<4.0a0 + size: 2786968 + timestamp: 1791099439455 - conda: https://prefix.dev/conda-forge/osx-64/py-rattler-0.28.1-py311h85c30bf_0.conda noarch: python sha256: 20e3660e16ad7994a8a69bf3e67bf3495b27b949cebb921a227cdfb47f123ba6 @@ -2074,6 +2425,23 @@ packages: run_exports: {} size: 14590908 timestamp: 1793309708900 +- conda: https://prefix.dev/conda-forge/osx-64/py-rattler-build-0.73.0-py311h7a3dd7d_0.conda + noarch: python + sha256: 3b0f095818fd085d0fe4ff32ecd865c85be10061a64715118f9180f6b72ee034 + md5: 3c7b8e54d37c9a678b20545d4644cc7e + depends: + - python + - __osx >=11.0 + - _python_abi3_support 1.* + - cpython >=3.11 + - openssl >=3.5.8,<4.0a0 + constrains: + - __osx >=11.0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 17111535 + timestamp: 1789640110185 - conda: https://prefix.dev/conda-forge/osx-64/pydantic-core-2.46.5-py314h3cfb53e_2.conda sha256: ec0f6728291df87422067b915d9946e3de57ab1c381dfee6bcdf7df20ae49602 md5: 83949db745fcf6b80714da51754e9e05 @@ -2205,6 +2573,20 @@ packages: - readline >=8.3,<9.0a0 size: 319279 timestamp: 1787034454836 +- conda: https://prefix.dev/conda-forge/osx-64/rpds-py-2026.9.1-py314h3cfb53e_0.conda + sha256: de979ff36d1946a1728e3bb61f10dd353514b1fa8724447d35018b6a6d2c989f + md5: 8d50f16e18f7bc1f0008a9a38a918db9 + depends: + - python + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: {} + size: 302047 + timestamp: 1791202491493 - conda: https://prefix.dev/conda-forge/osx-64/ruamel.yaml.clib-0.2.15-py314h17af80e_3.conda sha256: 76134192e5c82581f8b748832fc0e5394e76079068c4012a53007ab5a551f8e9 md5: ca59f7ecbc51d106dd0a4f5659a93346 @@ -2266,6 +2648,18 @@ packages: run_exports: {} size: 16414759 timestamp: 1787888537908 +- conda: https://prefix.dev/conda-forge/osx-64/wrapt-2.5.2-py314he3ee658_0.conda + sha256: d5a1e66733c445b222398a1f269bd7574c5af7aee305ab606c5ba9f8d633a765 + md5: 2efc0f395ec4246ea0da06f9a978772a + depends: + - python + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 154119 + timestamp: 1793358917673 - conda: https://prefix.dev/conda-forge/osx-64/yaml-0.2.5-had63097_3.conda sha256: b63a29a5e4968b28c8403525549fd662de8ff5863e6287f89cfaac7d1d688ea0 md5: 9b30f8e851965211d981aca9c9bd1196 @@ -2278,6 +2672,21 @@ packages: - yaml >=0.2.5,<0.3.0a0 size: 75645 timestamp: 1787228502493 +- conda: https://prefix.dev/conda-forge/osx-64/zstandard-0.25.0-py314h17af80e_4.conda + sha256: ee9294c0a478c42db6466ed4c1fc5c3e71486b5f1574096312118e38a782cfa3 + md5: 0c9e19df4d9433a903d5069364404ca7 + depends: + - python + - cffi >=1.11 + - zstd >=1.5.7,<1.5.8.0a0 + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 459796 + timestamp: 1788604367425 - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-h8210216_2.conda sha256: c171c43d0c47eed45085112cb00c8c7d4f0caa5a32d47f2daca727e45fb98dca md5: cd60a4a5a8d6a476b30d8aa4bb49251a @@ -2339,6 +2748,21 @@ packages: purls: [] size: 125061 timestamp: 1757437486465 +- conda: https://prefix.dev/conda-forge/osx-arm64/cffi-2.1.1-py314h7bede21_0.conda + sha256: b6c9358ee7cabfb417e6a8d2758c2241fe6b24f9fd316561a449ccac1959f8e1 + md5: 4648b9514e3cd095788963779cebcc7a + depends: + - __osx >=11.0 + - libffi >=3.5.2,<3.6.0a0 + - pycparser + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + run_exports: {} + size: 297593 + timestamp: 1785811327918 - conda: https://prefix.dev/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda sha256: 9ba12c93406f3df5ab0a43db8a4b4ef67a5871dfd401010fbe29b218b2cbe620 md5: 5eb22c1d7b3fc4abb50d92d621583137 @@ -2537,6 +2961,19 @@ packages: run_exports: {} size: 27256 timestamp: 1772445397216 +- conda: https://prefix.dev/conda-forge/osx-arm64/msgpack-python-1.2.4-py314h1433326_0.conda + sha256: ab2acdf472ccbf9f6492db16929e4a7e7198fb42011e60e5b4f9aca2fc671c34 + md5: 2c9b43961254c362cf8811131e569a38 + depends: + - python + - libcxx >=21 + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 107235 + timestamp: 1793105676025 - conda: https://prefix.dev/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda sha256: 2827ada40e8d9ca69a153a45f7fd14f32b2ead7045d3bbb5d10964898fe65733 md5: 068d497125e4bf8a66bf707254fff5ae @@ -2557,17 +2994,6 @@ packages: - ncurses >=6.6,<7.0a0 size: 804298 timestamp: 1786355189145 -- conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.5.4-h5503f6c_0.conda - sha256: f0512629f9589392c2fb9733d11e753d0eab8fc7602f96e4d7f3bd95c783eb07 - md5: 71118318f37f717eefe55841adb172fd - depends: - - __osx >=11.0 - - ca-certificates - license: Apache-2.0 - license_family: Apache - purls: [] - size: 3067808 - timestamp: 1759324763146 - conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.4-h55eecbc_0.conda sha256: f23239eacd75c4c50705e68fae1aa3292da473e6a3a4abe2330f1e6afa680704 md5: ae71ab40048c19a389a7dcccb86c2481 @@ -2581,6 +3007,19 @@ packages: - openssl >=3.6.4,<4.0a0 size: 3110142 timestamp: 1787698648639 +- conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.5-h55eecbc_0.conda + sha256: 44fbc526456571a9fb8fa18157f6f269d4d4bd40eef20183a93b9c9296f94497 + md5: 45d9179cdf6e53d88aec034246f0dad6 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.5,<4.0a0 + size: 3111573 + timestamp: 1791097959641 - conda: https://prefix.dev/conda-forge/osx-arm64/py-rattler-0.28.1-py311h1e0e963_0.conda noarch: python sha256: 32239dd953c2e6ecce9d1d79cd1ca145c0088a7261ba703c181adc88891d88c3 @@ -2600,6 +3039,23 @@ packages: run_exports: {} size: 13242554 timestamp: 1793309637666 +- conda: https://prefix.dev/conda-forge/osx-arm64/py-rattler-build-0.73.0-py311h15292d5_0.conda + noarch: python + sha256: 97d510e2b5bef563fb5f1a9875dc24b9e7285cf781ae974b44bd698ac7bd10de + md5: d8fa1035e14260ebc21b1417bcf00866 + depends: + - python + - __osx >=11.0 + - openssl >=3.5.8,<4.0a0 + - _python_abi3_support 1.* + - cpython >=3.11 + constrains: + - __osx >=11.0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 15663879 + timestamp: 1789640038643 - conda: https://prefix.dev/conda-forge/osx-arm64/pydantic-core-2.46.5-py314hc05cd11_2.conda sha256: 256814b2dc04478143e2d04edef1e607f78ea27afc89613e9f4b89716411436a md5: e68173ea947c93cdaa84bf0e22da6f8c @@ -2732,6 +3188,20 @@ packages: - readline >=8.3,<9.0a0 size: 314395 timestamp: 1787033878899 +- conda: https://prefix.dev/conda-forge/osx-arm64/rpds-py-2026.9.1-py314hc05cd11_0.conda + sha256: f8b96fa257e9f34af1168d1321ae6ef3ce7211d684fb429c4cbbe425583f9014 + md5: 4faa708a07c8ec44c08c3b2e9fb0e110 + depends: + - python + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: {} + size: 286096 + timestamp: 1791202353806 - conda: https://prefix.dev/conda-forge/osx-arm64/ruamel.yaml.clib-0.2.15-py314hd98292b_3.conda sha256: 7fa751851add4a11a7dea048defa9fd7f1997a9fe3838670efda25b744d76ba3 md5: 35cc651ba43a147c0e259ae959619439 @@ -2793,6 +3263,18 @@ packages: run_exports: {} size: 14657544 timestamp: 1788240782621 +- conda: https://prefix.dev/conda-forge/osx-arm64/wrapt-2.5.2-py314hef190fd_0.conda + sha256: 5a4b266a5d75210661d2dab4a25d03411e24571506ce949ff35f6efe6c1285ad + md5: 64c15fa6923f25a7fa23db66fbdcb145 + depends: + - python + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 152746 + timestamp: 1793358857652 - conda: https://prefix.dev/conda-forge/osx-arm64/yaml-0.2.5-h74c22ad_3.conda sha256: 3e78c43207502418fc5fb2317bbbefe5df8b6fc1051d90bdcd1c881c76bb4193 md5: 31cf6dcc138abe673c9440cd6fe6c6fe @@ -2805,6 +3287,21 @@ packages: - yaml >=0.2.5,<0.3.0a0 size: 77530 timestamp: 1787228556857 +- conda: https://prefix.dev/conda-forge/osx-arm64/zstandard-0.25.0-py314hd98292b_4.conda + sha256: e53c8d90a069deedf630563be17ae8f3b3a0c5b669111fc12b214241ac430236 + md5: e08e8d1a909b558f7531a813439b7477 + depends: + - python + - cffi >=1.11 + - zstd >=1.5.7,<1.5.8.0a0 + - __osx >=11.0 + - zstd >=1.5.7,<1.6.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 383112 + timestamp: 1788604314545 - conda: https://prefix.dev/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda sha256: 0d02046f57f7a1a3feae3e9d1aa2113788311f3cf37a3244c71e61a93177ba67 md5: e6f69c7bcccdefa417f056fa593b40f0 @@ -2871,6 +3368,21 @@ packages: purls: [] size: 55977 timestamp: 1757437738856 +- conda: https://prefix.dev/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_3.conda + sha256: 400e8869acc3e5a0633b9a8ccd6695ce8d36b63675708b8d3f5e1c018406ef6a + md5: 178930300441541491ece1cae80240b9 + depends: + - pycparser + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + run_exports: {} + size: 347452 + timestamp: 1788485352441 - conda: https://prefix.dev/conda-forge/win-64/libexpat-2.7.1-hac47afa_0.conda sha256: 8432ca842bdf8073ccecf016ccc9140c41c7114dc4ec77ca754551c01f780845 md5: 3608ffde260281fa641e70d6e34b1b96 @@ -3059,6 +3571,20 @@ packages: run_exports: {} size: 30022 timestamp: 1772445159549 +- conda: https://prefix.dev/conda-forge/win-64/msgpack-python-1.2.4-py314hf309875_0.conda + sha256: 45ad41e7c71ff8e245c77cd0f5aaf456ccb5d86dbe76ea0af0c2ad46acf56ac2 + md5: f1f6a318e4437f05a310925da7945192 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 103382 + timestamp: 1793105664680 - conda: https://prefix.dev/conda-forge/win-64/openssl-3.5.4-h725018a_0.conda sha256: 5ddc1e39e2a8b72db2431620ad1124016f3df135f87ebde450d235c212a61994 md5: f28ffa510fe055ab518cbd9d6ddfea23 @@ -3106,6 +3632,22 @@ packages: run_exports: {} size: 16468002 timestamp: 1793309604859 +- conda: https://prefix.dev/conda-forge/win-64/py-rattler-build-0.73.0-py311hc3dac5c_0.conda + noarch: python + sha256: 91b97048d1c59a80ce635f9d47dfef9bb7540ffa3bd97f83cd225876ebfbb8bc + md5: e846cc5deb3a48cdda2d424df85aaa6d + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - _python_abi3_support 1.* + - cpython >=3.11 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 18355438 + timestamp: 1789639984273 - conda: https://prefix.dev/conda-forge/win-64/pydantic-core-2.46.5-py314h9f07db2_2.conda sha256: bb6d18b646f00144bdd898984fcbf6987d9964adc6fed93111dc2e2b554261a4 md5: dac7a7c4b8a24342b62b31d9f850498d @@ -3215,6 +3757,20 @@ packages: run_exports: {} size: 22565961 timestamp: 1792604776870 +- conda: https://prefix.dev/conda-forge/win-64/rpds-py-2026.9.1-py314h9f07db2_0.conda + sha256: 03d56909370dae78f501718bb3f5ccc0b05bf4dcd56bfeecd0d39e61b3fa3d44 + md5: 26ca3c75e8d4dba8000448354f76008c + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + run_exports: {} + size: 221750 + timestamp: 1791202478076 - conda: https://prefix.dev/conda-forge/win-64/ruamel.yaml.clib-0.2.15-py314hc5dbbe4_3.conda sha256: 5c29679b31a28e2a75aca42e14ff0125c50f4402c63bd28d0e390cf75e96f92f md5: 86443a44633c1897d9bec6a099187c2d @@ -3366,6 +3922,20 @@ packages: - vcomp14 >=14.51.36247 size: 155910 timestamp: 1785359349999 +- conda: https://prefix.dev/conda-forge/win-64/wrapt-2.5.2-py314hc5dbbe4_0.conda + sha256: 9b95e99e8efc86e1d6d5fec9edf55481c9f718786c98cca5a72664a4734be1e3 + md5: 5782e08970cba0b7cf5e2014231e311a + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 152201 + timestamp: 1793358808613 - conda: https://prefix.dev/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda sha256: 80ee68c1e7683a35295232ea79bcc87279d31ffeda04a1665efdb43cbd50a309 md5: 433699cba6602098ae8957a323da2664 @@ -3383,6 +3953,23 @@ packages: - yaml >=0.2.5,<0.3.0a0 size: 63944 timestamp: 1753484092156 +- conda: https://prefix.dev/conda-forge/win-64/zstandard-0.25.0-py314hc5dbbe4_4.conda + sha256: 9c4207007a8fa0c105673778869eea29410eeac0fdc94b2b524564d509de3922 + md5: 53f5199adc1da5eaa67bf97a8e90867b + depends: + - python + - cffi >=1.11 + - zstd >=1.5.7,<1.5.8.0a0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - zstd >=1.5.7,<1.6.0a0 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 380549 + timestamp: 1788604303151 - conda: https://prefix.dev/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda sha256: ca7daae4f218a11fab82cc2857f0ea518ec3f46acec60490485347a4c22c6b3e md5: e4ac308c39d6d0e131154976da67cf3b @@ -3411,22 +3998,25 @@ packages: purls: [] size: 354697 timestamp: 1742433568506 -- conda_source: vinca[485b9dee] @ . +- conda_source: vinca[40b6087e] @ . variants: target_platform: noarch depends: - - python >=3.9 - - python * - catkin_pkg >=0.4.16 - - ruamel.yaml >=0.19.1,<0.20.0 - - rosdistro >=0.8.0 + - conda-forge-metadata >=2026.9.10 - empy >=3.3.4,<4.0.0 - - requests >=2.24.0 - - networkx >=2.5 - - rich >=10 - jinja2 >=3.0.0 - license-expression >=30.0.0 + - networkx >=2.5 - py-rattler >=0.28.1,<0.29 + - py-rattler-build >=0.73,<0.74 + - pydantic >=2.4 + - python * + - python >=3.11 + - requests >=2.24.0 + - rich >=10 + - rosdistro >=0.8.0 + - ruamel.yaml >=0.19.1,<0.20.0 - typer >=0.12 license: MIT host_packages: @@ -3441,39 +4031,41 @@ packages: - conda: https://prefix.dev/conda-forge/noarch/tomlkit-0.15.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/trove-classifiers-2026.6.1.19-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/libcxx-23.1.0-h55c6f16_0.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/libpython-3.14.7-h4311e70_104_cp314.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/libsqlite-3.53.4-hca69786_1.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.4-h55eecbc_0.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/python-3.14.7-h0ae1c2c_104_cp314.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.3-h8b90a29_1.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/uv-0.12.8-h0e72303_0.conda - - conda: https://prefix.dev/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda -- conda_source: vinca[9761a393] @ . + - conda: https://prefix.dev/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda + - conda: https://prefix.dev/conda-forge/osx-64/libcxx-23.1.0-h19cb2f5_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/libffi-3.7.0-h6b3a05b_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/libmpdec-4.0.0-ha1e9b39_2.conda + - conda: https://prefix.dev/conda-forge/osx-64/libpython-3.14.7-hfe304d0_104_cp314.conda + - conda: https://prefix.dev/conda-forge/osx-64/libsqlite-3.53.4-ha5db789_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda + - conda: https://prefix.dev/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/openssl-3.6.4-h332eb6d_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/python-3.14.7-hafa0b4b_104_cp314.conda + - conda: https://prefix.dev/conda-forge/osx-64/readline-8.3-h000c2f7_1.conda + - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda + - conda: https://prefix.dev/conda-forge/osx-64/uv-0.12.7-h6c1caad_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda +- conda_source: vinca[69cbd77e] @ . variants: target_platform: noarch depends: - - python >=3.9 - - python * - catkin_pkg >=0.4.16 - - ruamel.yaml >=0.19.1,<0.20.0 - - rosdistro >=0.8.0 + - conda-forge-metadata >=2026.9.10 - empy >=3.3.4,<4.0.0 - - requests >=2.24.0 - - networkx >=2.5 - - rich >=10 - jinja2 >=3.0.0 - license-expression >=30.0.0 + - networkx >=2.5 - py-rattler >=0.28.1,<0.29 + - py-rattler-build >=0.73,<0.74 + - pydantic >=2.4 + - python * + - python >=3.11 + - requests >=2.24.0 + - rich >=10 + - rosdistro >=0.8.0 + - ruamel.yaml >=0.19.1,<0.20.0 - typer >=0.12 license: MIT host_packages: @@ -3510,26 +4102,29 @@ packages: - conda: https://prefix.dev/conda-forge/noarch/tomlkit-0.15.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/trove-classifiers-2026.6.1.19-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda -- conda_source: vinca[da38084e] @ . +- conda_source: vinca[c949dab8] @ . variants: target_platform: noarch depends: - - python >=3.9 - - python * - catkin_pkg >=0.4.16 - - ruamel.yaml >=0.19.1,<0.20.0 - - rosdistro >=0.8.0 + - conda-forge-metadata >=2026.9.10 - empy >=3.3.4,<4.0.0 - - requests >=2.24.0 - - networkx >=2.5 - - rich >=10 - jinja2 >=3.0.0 - license-expression >=30.0.0 + - networkx >=2.5 - py-rattler >=0.28.1,<0.29 + - py-rattler-build >=0.73,<0.74 + - pydantic >=2.4 + - python * + - python >=3.11 + - requests >=2.24.0 + - rich >=10 + - rosdistro >=0.8.0 + - ruamel.yaml >=0.19.1,<0.20.0 - typer >=0.12 license: MIT host_packages: - - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/editables-0.6-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/hatchling-1.32.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda @@ -3540,43 +4135,46 @@ packages: - conda: https://prefix.dev/conda-forge/noarch/tomlkit-0.15.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/trove-classifiers-2026.6.1.19-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - conda: https://prefix.dev/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda - - conda: https://prefix.dev/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - - conda: https://prefix.dev/conda-forge/win-64/libffi-3.7.0-h3d046cb_1.conda - - conda: https://prefix.dev/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda - - conda: https://prefix.dev/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda - - conda: https://prefix.dev/conda-forge/win-64/libpython-3.14.7-h4f90d01_104_cp314.conda - - conda: https://prefix.dev/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_1.conda - - conda: https://prefix.dev/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda - - conda: https://prefix.dev/conda-forge/win-64/openssl-3.6.4-hf411b9b_0.conda - - conda: https://prefix.dev/conda-forge/win-64/python-3.14.7-h53f6dd8_104_cp314.conda - - conda: https://prefix.dev/conda-forge/win-64/tk-8.6.13-h967ab96_4.conda - - conda: https://prefix.dev/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://prefix.dev/conda-forge/win-64/uv-0.12.8-hc31ddbc_0.conda - - conda: https://prefix.dev/conda-forge/win-64/vc-14.5-ha367084_41.conda - - conda: https://prefix.dev/conda-forge/win-64/vc14_runtime-14.51.36247-habf1de7_41.conda - - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - - conda: https://prefix.dev/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda -- conda_source: vinca[ddc7a8c1] @ . + - conda: https://prefix.dev/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libcxx-23.1.0-h55c6f16_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libpython-3.14.7-h4311e70_104_cp314.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libsqlite-3.53.4-hca69786_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/openssl-3.6.4-h55eecbc_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/python-3.14.7-h0ae1c2c_104_cp314.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.3-h8b90a29_1.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/uv-0.12.8-h0e72303_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda +- conda_source: vinca[efcd1955] @ . variants: target_platform: noarch depends: - - python >=3.9 - - python * - catkin_pkg >=0.4.16 - - ruamel.yaml >=0.19.1,<0.20.0 - - rosdistro >=0.8.0 + - conda-forge-metadata >=2026.9.10 - empy >=3.3.4,<4.0.0 - - requests >=2.24.0 - - networkx >=2.5 - - rich >=10 - jinja2 >=3.0.0 - license-expression >=30.0.0 + - networkx >=2.5 - py-rattler >=0.28.1,<0.29 + - py-rattler-build >=0.73,<0.74 + - pydantic >=2.4 + - python * + - python >=3.11 + - requests >=2.24.0 + - rich >=10 + - rosdistro >=0.8.0 + - ruamel.yaml >=0.19.1,<0.20.0 - typer >=0.12 license: MIT host_packages: - - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda - conda: https://prefix.dev/conda-forge/noarch/editables-0.6-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/hatchling-1.32.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda @@ -3587,19 +4185,20 @@ packages: - conda: https://prefix.dev/conda-forge/noarch/tomlkit-0.15.1-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/trove-classifiers-2026.6.1.19-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - conda: https://prefix.dev/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda - - conda: https://prefix.dev/conda-forge/osx-64/libcxx-23.1.0-h19cb2f5_0.conda - - conda: https://prefix.dev/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda - - conda: https://prefix.dev/conda-forge/osx-64/libffi-3.7.0-h6b3a05b_1.conda - - conda: https://prefix.dev/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_1.conda - - conda: https://prefix.dev/conda-forge/osx-64/libmpdec-4.0.0-ha1e9b39_2.conda - - conda: https://prefix.dev/conda-forge/osx-64/libpython-3.14.7-hfe304d0_104_cp314.conda - - conda: https://prefix.dev/conda-forge/osx-64/libsqlite-3.53.4-ha5db789_1.conda - - conda: https://prefix.dev/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda - - conda: https://prefix.dev/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_1.conda - - conda: https://prefix.dev/conda-forge/osx-64/openssl-3.6.4-h332eb6d_0.conda - - conda: https://prefix.dev/conda-forge/osx-64/python-3.14.7-hafa0b4b_104_cp314.conda - - conda: https://prefix.dev/conda-forge/osx-64/readline-8.3-h000c2f7_1.conda - - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - - conda: https://prefix.dev/conda-forge/osx-64/uv-0.12.7-h6c1caad_0.conda - - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda + - conda: https://prefix.dev/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda + - conda: https://prefix.dev/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda + - conda: https://prefix.dev/conda-forge/win-64/libffi-3.7.0-h3d046cb_1.conda + - conda: https://prefix.dev/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda + - conda: https://prefix.dev/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda + - conda: https://prefix.dev/conda-forge/win-64/libpython-3.14.7-h4f90d01_104_cp314.conda + - conda: https://prefix.dev/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_1.conda + - conda: https://prefix.dev/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + - conda: https://prefix.dev/conda-forge/win-64/openssl-3.6.4-hf411b9b_0.conda + - conda: https://prefix.dev/conda-forge/win-64/python-3.14.7-h53f6dd8_104_cp314.conda + - conda: https://prefix.dev/conda-forge/win-64/tk-8.6.13-h967ab96_4.conda + - conda: https://prefix.dev/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://prefix.dev/conda-forge/win-64/uv-0.12.8-hc31ddbc_0.conda + - conda: https://prefix.dev/conda-forge/win-64/vc-14.5-ha367084_41.conda + - conda: https://prefix.dev/conda-forge/win-64/vc14_runtime-14.51.36247-habf1de7_41.conda + - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda + - conda: https://prefix.dev/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda diff --git a/pyproject.toml b/pyproject.toml index 34e2c44..c41fe9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ maintainers = [ { name = "Silvio Traversaro" }, { name = "Tobias Fischer" }, ] -requires-python = ">=3.9" +requires-python = ">=3.11" keywords = ["ros"] classifiers = [ "Development Status :: 3 - Alpha", @@ -39,6 +39,9 @@ dependencies = [ "jinja2 >=3.0.0", "license-expression >=30.0.0", "py-rattler >=0.28.1,<0.29", + "py-rattler-build >=0.73,<0.74", + "conda-forge-metadata >=2026.9.10", + "pydantic >=2.4", "typer >=0.12", ] diff --git a/vinca/configuration.py b/vinca/configuration.py index c9806ca..e54b16c 100644 --- a/vinca/configuration.py +++ b/vinca/configuration.py @@ -5,9 +5,10 @@ files that live alongside the config: patches, test definitions, dependency overrides, per-package metadata and rosdistro snapshots. -Derived values are written back into the returned mapping under underscore-prefixed -keys (``_patches``, ``_tests``, ``_conda_indexes``, ``_snapshot`` and friends). Those -keys are internal to vinca and are never read from the user's file. +The result is a :class:`~vinca.schemas.VincaConfig`: the validated ``vinca.yaml`` +together with what is derived from those files (``patches``, ``tests``, +``conda_indexes``, ``snapshot`` and friends), which are never read from the user's +file. """ from __future__ import annotations @@ -15,23 +16,33 @@ import re import warnings from pathlib import Path -from typing import Any +from typing import Any, Optional, TypeVar +from pydantic import BaseModel from ruamel.yaml import YAML from vinca import config -from vinca.naming import get_package_name_mode from vinca.resolve import get_conda_index -from vinca.utils import add_package_name_variants -from vinca.v1_selectors import evaluate_selectors -from vinca.variants import ( +from vinca.schemas import ( + CondaIndex, + DepMods, + PkgAdditionalInfo, + RecipeTests, + Snapshot, VariantsMode, - convert_v0_variant_selectors, - get_variants_mode, + VincaConfig, + VincaYaml, + VincaYamlLayer, + validate, ) +from vinca.utils import add_package_name_variants +from vinca.v1_selectors import evaluate_distro_selectors, evaluate_selectors +from vinca.variants import convert_v0_variant_selectors _PATCH_PLATFORMS = ("osx", "linux", "win", "emscripten") +_Entry = TypeVar("_Entry", bound=BaseModel) + def _load_yaml(path: Path) -> Any: yaml = YAML() @@ -90,17 +101,30 @@ def _discover_tests( return tests, test_folders +def read_recipe_tests(path: Path, ros_distro: Optional[str]) -> RecipeTests: + """A package's test definitions, with its ``ros_distro`` selectors resolved.""" + data = YAML(typ="safe").load(path.read_text(encoding="utf-8")) + return validate( + RecipeTests, + evaluate_distro_selectors(data, ros_distro=ros_distro) or {}, + path, + ) + + +def _load_snapshot(path: Path) -> Snapshot: + return validate(Snapshot, _load_yaml(path) or {}, path) + + def read_snapshot( - vinca_conf: dict[str, Any], -) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + vinca_conf: VincaYaml, +) -> tuple[Optional[Snapshot], Optional[Snapshot]]: """Load the primary and optional additional package snapshots.""" - snapshot_path = vinca_conf.get("rosdistro_snapshot") - if not snapshot_path: + if not vinca_conf.rosdistro_snapshot: return None, None - snapshot = _load_yaml(Path(snapshot_path)) or {} - additional_path = vinca_conf.get("rosdistro_additional_recipes") - additional = _load_yaml(Path(additional_path)) or {} if additional_path else None + snapshot = _load_snapshot(Path(vinca_conf.rosdistro_snapshot)) + additional_path = vinca_conf.rosdistro_additional_recipes + additional = _load_snapshot(Path(additional_path)) if additional_path else None if additional: snapshot.update(additional) return snapshot, additional @@ -111,7 +135,9 @@ def _names(items: Any) -> list[str]: return [str(item) for item in items or [] if item is not None] -def _apply_package_exclusions(vinca_conf: dict[str, Any]) -> None: +def _apply_package_exclusions( + vinca_conf: dict[str, Any], +) -> tuple[list[str], list[str]]: """Normalize ``packages_exclude`` / ``packages_skip``. ``packages_exclude``: the package is not built, and is dropped from the host and @@ -122,6 +148,9 @@ def _apply_package_exclusions(vinca_conf: dict[str, Any]) -> None: Both also remove the package from ``packages_select_by_deps``. Selectors (``- if: ... then: ...``) have already been resolved for the target platform. + + Returns what the dependency traversal ignores, and what is dropped from + dependencies. """ exclude = list(dict.fromkeys(_names(vinca_conf.get("packages_exclude")))) skip = list(dict.fromkeys(_names(vinca_conf.get("packages_skip")))) @@ -140,15 +169,16 @@ def _apply_package_exclusions(vinca_conf: dict[str, Any]) -> None: ) vinca_conf["packages_exclude"] = exclude vinca_conf["packages_skip"] = skip - # what the dependency traversal ignores, and what is dropped from dependencies - vinca_conf["_skip_by_deps"] = list(dict.fromkeys(exclude + skip + legacy_skip)) - vinca_conf["_remove_from_deps"] = list(dict.fromkeys(exclude + legacy_remove)) dropped = {name.replace("-", "_") for name in exclude + skip} vinca_conf["packages_select_by_deps"] = [ name for name in _names(vinca_conf.get("packages_select_by_deps")) if name.replace("-", "_") not in dropped ] + return ( + list(dict.fromkeys(exclude + skip + legacy_skip)), + list(dict.fromkeys(exclude + legacy_remove)), + ) # paths in a configuration, relative to the file that sets them @@ -188,12 +218,17 @@ def _merge_configs(base: dict[str, Any], own: dict[str, Any]) -> dict[str, Any]: return merged -def _merge_per_package(base: dict[str, Any], own: dict[str, Any]) -> dict[str, Any]: - """Per-package settings: own keys of a package's entry win over the base's.""" - merged = {k: (dict(v) if isinstance(v, dict) else v) for k, v in base.items()} +def _merge_per_package( + base: dict[str, _Entry], own: dict[str, _Entry] +) -> dict[str, _Entry]: + """Per-package settings: the keys set in own's entry of a package win over the + base's.""" + merged = dict(base) for package, entry in own.items(): - if isinstance(entry, dict) and isinstance(merged.get(package), dict): - merged[package] = {**merged[package], **entry} + if package in merged: + merged[package] = merged[package].model_copy( + update={key: getattr(entry, key) for key in entry.model_fields_set} + ) else: merged[package] = entry return merged @@ -203,7 +238,7 @@ def _load_layers( filepath: Path, target_platform: str, seen: tuple[Path, ...] = () ) -> list[tuple[Path, dict[str, Any]]]: """The configuration and the ones it ``extends`` (resolved relative to the - file), base first, each as (directory, selector-resolved content).""" + file), base first, each as (file, selector-resolved content).""" filepath = filepath.resolve() if filepath in seen: chain = " -> ".join(str(p) for p in (*seen, filepath)) @@ -218,90 +253,103 @@ def _load_layers( if base else [] ) - return [*layers, (filepath.parent, conf)] + return [*layers, (filepath, conf)] -def read_vinca_yaml(filepath: str | Path, target_platform: str) -> dict[str, Any]: +def read_vinca_yaml(filepath: str | Path, target_platform: str) -> VincaConfig: """Read a vinca configuration and populate its derived internal fields.""" filepath = Path(filepath) config_dir = filepath.parent layers = _load_layers(filepath, target_platform) - vinca_conf: dict[str, Any] = {} + merged: dict[str, Any] = {} for _, layer in layers: - vinca_conf = _merge_configs(vinca_conf, layer) - _apply_package_exclusions(vinca_conf) - vinca_conf["package_name_mode"] = get_package_name_mode(vinca_conf).value - vinca_conf["variants_mode"] = get_variants_mode(vinca_conf).value + merged = _merge_configs(merged, layer) + skip_by_deps, remove_from_deps = _apply_package_exclusions(merged) + settings = validate(VincaYaml, merged, filepath) + ros_distro = settings.ros_distro # Files of each layer: patches and dependencies.yaml (patch_dir), tests, # pkg_additional_info.yaml and conda_index files, relative to that layer's file. - patches: dict[str, Any] = {} + patches: dict[str, dict[str, list[str]]] = {} tests: dict[str, Path] = {} test_folders: dict[str, Path] = {} - depmods: dict[str, Any] = {} - additional_info: dict[str, Any] = {} - conda_indexes: list[Any] = [] + depmods: dict[str, DepMods] = {} + additional_info: dict[str, PkgAdditionalInfo] = {} + conda_indexes: list[CondaIndex] = [] patch_dir: Path | None = None - for layer_dir, layer in layers: - if layer.get("patch_dir"): - layer_patch_dir = Path(layer["patch_dir"]) - patches.update(_discover_patches(layer_patch_dir, vinca_conf["ros_distro"])) + for layer_file, layer_content in layers: + layer_dir = layer_file.parent + layer = validate(VincaYamlLayer, layer_content, layer_file) + if layer.patch_dir: + layer_patch_dir = Path(layer.patch_dir) + patches.update(_discover_patches(layer_patch_dir, ros_distro)) dependencies_path = layer_patch_dir / "dependencies.yaml" if dependencies_path.exists(): depmods = _merge_per_package( depmods, - _load_selected_yaml(dependencies_path, target_platform) or {}, + validate( + dict[str, DepMods], + _load_selected_yaml(dependencies_path, target_platform) or {}, + dependencies_path, + ), ) patch_dir = layer_patch_dir - layer_tests, layer_test_folders = _discover_tests( - layer_dir, vinca_conf["ros_distro"] - ) + layer_tests, layer_test_folders = _discover_tests(layer_dir, ros_distro) tests.update(layer_tests) test_folders.update(layer_test_folders) additional_info_path = layer_dir / "pkg_additional_info.yaml" if additional_info_path.exists(): additional_info = _merge_per_package( additional_info, - _load_selected_yaml(additional_info_path, target_platform) or {}, + validate( + dict[str, PkgAdditionalInfo], + _load_selected_yaml(additional_info_path, target_platform) or {}, + additional_info_path, + ), ) - if layer.get("conda_index"): + if layer.conda_index: layer_indexes = [ str(layer_dir / i) if (layer_dir / i).is_file() else i - for i in layer["conda_index"] + for i in layer.conda_index ] # the extending configuration's mappings are looked up first conda_indexes = ( - get_conda_index({"conda_index": layer_indexes}, str(layer_dir)) - + conda_indexes + get_conda_index(layer_indexes, str(layer_dir)) + conda_indexes ) - vinca_conf["conda_index"] = _normalize_conda_indexes( - vinca_conf.get("conda_index") or [] - ) - vinca_conf["_patch_dir"] = patch_dir or Path(vinca_conf["patch_dir"]).absolute() - vinca_conf["_patches"] = patches - vinca_conf["_tests"], vinca_conf["_test_folders"] = tests, test_folders - vinca_conf["depmods"] = depmods or vinca_conf.get("depmods") or {} - - config.ros_distro = vinca_conf["ros_distro"] - config.skip_testing = vinca_conf.get("skip_testing", True) - config.setup_pixi_version = vinca_conf.get("setup_pixi_version") - config.pixi_version = vinca_conf.get("pixi_version") - vinca_conf["_conda_indexes"] = conda_indexes - vinca_conf["trigger_new_versions"] = vinca_conf.get("trigger_new_versions", False) - vinca_conf["_pkg_additional_info"] = additional_info - - vinca_conf["_variant_config"] = {} - if get_variants_mode(vinca_conf) is VariantsMode.LOCAL: + + config.ros_distro = ros_distro + config.skip_testing = settings.skip_testing + config.setup_pixi_version = settings.setup_pixi_version + config.pixi_version = settings.pixi_version + + variant_config: dict[str, Any] = {} + if settings.variants_mode is VariantsMode.LOCAL: variant_config_path = config_dir / "conda_build_config.yaml" if not variant_config_path.is_file(): raise FileNotFoundError( "variants_mode 'local' requires conda_build_config.yaml next to vinca.yaml" ) - vinca_conf["_variant_config"] = convert_v0_variant_selectors( + variant_config = convert_v0_variant_selectors( _load_yaml(variant_config_path) or {} ) - snapshot, additional = read_snapshot(vinca_conf) - vinca_conf["_snapshot"] = snapshot or {} - vinca_conf["_additional_packages_snapshot"] = additional or {} - return vinca_conf + snapshot, additional = read_snapshot(settings) + fields = dict(settings) + fields.update( + conda_index=_normalize_conda_indexes(settings.conda_index), + depmods=depmods or settings.depmods, + ) + return VincaConfig( + **fields, + skip_by_deps=skip_by_deps, + remove_from_deps=remove_from_deps, + patch_dir_path=patch_dir, + patches=patches, + tests=tests, + test_folders=test_folders, + conda_indexes=conda_indexes, + pkg_additional_info=additional_info, + variant_config=variant_config, + snapshot=snapshot or {}, + additional_packages_snapshot=additional or {}, + ) diff --git a/vinca/distro.py b/vinca/distro.py index 98ec0ed..12a64b5 100644 --- a/vinca/distro.py +++ b/vinca/distro.py @@ -15,6 +15,8 @@ from rosdistro.dependency_walker import DependencyWalker from rosdistro.manifest_provider import get_release_tag +from vinca.schemas import Snapshot, SnapshotEntry + # Retries for fetching package.xml files (raw.githubusercontent.com is flaky in CI). _FETCH_ATTEMPTS = 5 _RETRY_STATUS = {429, 500, 502, 503, 504} @@ -36,7 +38,7 @@ ) -def is_archive_url(url): +def is_archive_url(url: str): """Return True when url points at a source archive rather than a git repository.""" if not url: return False @@ -51,7 +53,7 @@ def is_bloom_release_repository_url(url): return repository_name.endswith(("-release", "_release")) -def _strip_git_suffix(url): +def _strip_git_suffix(url: str): """Return a repository URL without its optional ``.git`` suffix.""" return url[:-4] if url.lower().endswith(".git") else url @@ -118,10 +120,10 @@ def _read_archive_member(*, payload, url, member) -> str: class Distro(object): def __init__( self, - distro_name, - python_version=None, - snapshot=None, - additional_packages_snapshot=None, + distro_name: str, + python_version: Optional[str] = None, + snapshot: Optional[Snapshot] = None, + additional_packages_snapshot: Optional[Snapshot] = None, distribution_cache=None, ) -> None: index = get_index(get_index_url()) @@ -160,7 +162,7 @@ def __init__( os.environ["ROS_VERSION"] = "1" if self.check_ros1() else "2" @property - def name(self): + def name(self) -> str: return self.distro_name def add_packages(self, packages) -> None: @@ -213,8 +215,8 @@ def _get_direct_depends(self, pkg: str) -> set[str]: additional_packages_snapshot = self.additional_packages_snapshot or {} is_additional_package = pkg in additional_packages_snapshot if snapshot_info is not None and not is_additional_package: - if "dependencies" in snapshot_info: - direct = set(snapshot_info["dependencies"] or []) + if snapshot_info.dependencies is not None: + direct = set(snapshot_info.dependencies) self._direct_depends_cache[pkg] = set(direct) return direct # Snapshots written before dependencies were recorded: resolve them @@ -275,7 +277,7 @@ def _get_direct_depends(self, pkg: str) -> set[str]: self._direct_depends_cache[pkg] = set(direct) return direct - def _get_snapshot_package_info(self, pkg_name: str): + def _get_snapshot_package_info(self, pkg_name: str) -> Optional[SnapshotEntry]: if not self.snapshot: return None for name in (pkg_name, pkg_name.replace("_", "-")): @@ -289,23 +291,18 @@ def get_released_repo(self, pkg_name): # In the case of snapshot, for rosdistro_additional_recipes # we also support a 'rev' field, so depending on what is available # we return either the tag or the rev, and the third argument is either 'rev' or 'tag' - url = pkg_info.get("url", None) + url = pkg_info.url # An archive URL is not a git repository: the reference is its sha256 checksum. if is_archive_url(url): - sha256 = pkg_info.get("sha256", None) + sha256 = pkg_info.sha256 if not sha256: raise RuntimeError( f"The archive source of '{pkg_name}' has no sha256 checksum: {url}" ) return url, sha256, "sha256" - if "tag" in pkg_info: - tag_or_rev = pkg_info.get("tag", None) - ref_type = "tag" - else: - tag_or_rev = pkg_info.get("rev", None) - ref_type = "rev" - - return url, tag_or_rev, ref_type + if pkg_info.tag is not None: + return url, pkg_info.tag, "tag" + return url, pkg_info.rev, "rev" pkg = self._distro.release_packages[pkg_name] repo = self._distro.repositories[pkg.repository_name].release_repository @@ -316,12 +313,12 @@ def get_repository_url(self, pkg_name, package_urls: Iterable[Url] = ()): """Return the best declared upstream repository for a package.""" pkg_info = self._get_snapshot_package_info(pkg_name) if pkg_info is not None: - if repository := pkg_info.get("repository"): + if repository := pkg_info.repository: return _strip_git_suffix(repository) additional_info = (self.additional_packages_snapshot or {}).get(pkg_name) if additional_info is not None: - if repository := additional_info.get("repository"): + if repository := additional_info.repository: return _strip_git_suffix(repository) else: package = self._distro.release_packages.get(pkg_name) @@ -344,7 +341,7 @@ def get_repository_url(self, pkg_name, package_urls: Iterable[Url] = ()): return _strip_git_suffix(package_url.url) return None - def check_package(self, pkg_name: str): + def check_package(self, pkg_name: str) -> bool: # If the package is in the additional_packages_snapshot, it is always considered valid # even if it is not in the released packages, as it is an additional # package specified in rosdistro_additional_recipes.yaml @@ -373,13 +370,13 @@ def check_package(self, pkg_name: str): def get_version(self, pkg_name): pkg_info = self._get_snapshot_package_info(pkg_name) if pkg_info is not None: - return pkg_info.get("version", None) + return pkg_info.version pkg = self._distro.release_packages[pkg_name] repo = self._distro.repositories[pkg.repository_name].release_repository return repo.version.split("-")[0] - def live_cache_matches_snapshot(self, pkg_name, snapshot_entry): + def live_cache_matches_snapshot(self, pkg_name, snapshot_entry: SnapshotEntry): """Return whether rosdistro's cached manifest is the pinned snapshot source.""" for live_name in (pkg_name, pkg_name.replace("_", "-")): @@ -388,11 +385,11 @@ def live_cache_matches_snapshot(self, pkg_name, snapshot_entry): repository = self._distro.repositories[package.repository_name] release_repository = repository.release_repository return ( - release_repository.url == snapshot_entry.get("url") + release_repository.url == snapshot_entry.url and get_release_tag(release_repository, live_name) - == snapshot_entry.get("tag") + == snapshot_entry.tag and release_repository.version.split("-")[0] - == snapshot_entry.get("version") + == snapshot_entry.version ) except (AttributeError, KeyError, TypeError): continue @@ -435,8 +432,8 @@ def get_package_names(self): return self.snapshot.keys() return self._distro.release_packages.keys() - def get_package_xml_for_additional_package(self, pkg_info): - raw_url_base = pkg_info.get("url") + def get_package_xml_for_additional_package(self, pkg_info: SnapshotEntry): + raw_url_base = pkg_info.url # An archive has no raw-file endpoint, so read package.xml out of the archive itself. if is_archive_url(raw_url_base): return self._package_xml_from_archive_or_cached(pkg_info) @@ -454,7 +451,7 @@ def prefetch_additional_package_xml(self, max_workers: int = 12) -> None: package_infos = [ package_info for package_info in (self.additional_packages_snapshot or {}).values() - if not is_archive_url(package_info.get("url")) + if not is_archive_url(package_info.url) ] with ThreadPoolExecutor(max_workers=max_workers) as executor: list( @@ -519,16 +516,15 @@ def _download_raw_pkg_xml_or_cached(self, url): self._additional_xml_cache[url] = xml_content return xml_content - def _package_xml_from_archive_or_cached(self, pkg_info): + def _package_xml_from_archive_or_cached(self, pkg_info: SnapshotEntry): """Read package.xml out of a source archive referenced by an additional recipe. The member is looked up under the archive's additional_folder, after dropping a single common top-level directory. That mirrors how the build tool unpacks the archive, so one additional_folder value describes both the recipe and this lookup. """ - url = pkg_info.get("url") - xml_name = pkg_info.get("package_xml_name", "package.xml") - member = posixpath.join(pkg_info.get("additional_folder", ""), xml_name) + url = pkg_info.url + member = posixpath.join(pkg_info.additional_folder, pkg_info.package_xml_name) cache_key = f"{url}#{member}" if cache_key in self._additional_xml_cache: return self._additional_xml_cache[cache_key] @@ -547,7 +543,7 @@ def _package_xml_from_archive_or_cached(self, pkg_info): self._additional_xml_cache[cache_key] = xml_content return xml_content - def _download_archive_or_cached(self, url): + def _download_archive_or_cached(self, url: str): """Download the archive at url, keeping only the most recent payload in memory. Several packages can share one archive, so reusing the last download avoids @@ -566,9 +562,9 @@ def _download_archive_or_cached(self, url): # Based on https://github.com/ros-infrastructure/rosdistro/blob/fad8d9f647631945847cb18bc1d1f43008d7a282/src/rosdistro/manifest_provider/github.py#L51C1-L69C29 # But with the option to specify the name of the package.xml file in case the repo uses a non-standard name - def _construct_raw_url_github(self, pkg_info) -> str: + def _construct_raw_url_github(self, pkg_info: SnapshotEntry) -> str: # Build raw GitHub URL for package.xml - raw_url_base = pkg_info.get("url") + raw_url_base = pkg_info.url if raw_url_base.endswith(".git"): raw_url_base = raw_url_base[:-4] if "github.com" not in raw_url_base: @@ -576,10 +572,10 @@ def _construct_raw_url_github(self, pkg_info) -> str: # Extract owner/repo owner_repo = raw_url_base.split("github.com/")[-1] # Use rev if available, otherwise fallback to tag - rev = pkg_info.get("rev") - tag = pkg_info.get("tag") - xml_name = pkg_info.get("package_xml_name", "package.xml") - additional_folder = pkg_info.get("additional_folder", "") + rev = pkg_info.rev + tag = pkg_info.tag + xml_name = pkg_info.package_xml_name + additional_folder = pkg_info.additional_folder if additional_folder != "": additional_folder = additional_folder + "/" if rev: @@ -599,16 +595,16 @@ def _construct_raw_url_github(self, pkg_info) -> str: return raw_url # format (checked against GitLab 19.x): https://gitlab.com//-/raw// - def _construct_raw_url_gitlab(self, pkg_info) -> str: - raw_url_base = pkg_info.get("url") + def _construct_raw_url_gitlab(self, pkg_info: SnapshotEntry) -> str: + raw_url_base = pkg_info.url if raw_url_base.endswith(".git"): raw_url_base = raw_url_base[:-4] if "gitlab.com" not in raw_url_base: raise RuntimeError(f"Cannot handle non-GitLab URL: {raw_url_base}") # Use rev if available, otherwise fallback to tag - ref = pkg_info.get("rev") or pkg_info.get("tag") - xml_name = pkg_info.get("package_xml_name", "package.xml") - additional_folder = pkg_info.get("additional_folder", "") + ref = pkg_info.rev or pkg_info.tag + xml_name = pkg_info.package_xml_name + additional_folder = pkg_info.additional_folder if additional_folder != "": additional_folder = additional_folder + "/" raw_url = f"{raw_url_base}/-/raw/{ref}/{additional_folder}{xml_name}" diff --git a/vinca/generate_gha.py b/vinca/generate_gha.py index a15aee7..bcb020b 100644 --- a/vinca/generate_gha.py +++ b/vinca/generate_gha.py @@ -22,6 +22,7 @@ get_all_ancestors, get_skip_existing, ) +from vinca.schemas import AdditionalRecipe, VincaConfig, validate from vinca.utils import ( NoAliasRepresenter, add_test_requirements, @@ -138,11 +139,12 @@ def add_additional_recipes(args: Namespace): for recipe_path in glob.glob(additional_recipes_path + "/**/recipe.yaml"): with open(recipe_path, encoding="utf-8") as recipe: additional_recipe = YAML(typ="safe").load(recipe) + recipe_info = validate(AdditionalRecipe, additional_recipe, recipe_path) name, version, bnumber = ( - additional_recipe["package"]["name"], - additional_recipe["package"]["version"], - additional_recipe["build"]["number"], + recipe_info.package.name, + recipe_info.package.version, + recipe_info.build.number, ) print("Checking if ", name, version, bnumber, " exists") skip = False @@ -151,7 +153,7 @@ def add_additional_recipes(args: Namespace): pkg["name"] == name and pkg["version"] == version and pkg["build_number"] == bnumber - and built_for_distro(pkg, vinca_conf["ros_distro"]) + and built_for_distro(pkg, vinca_conf.ros_distro) ): skip = True print(f"{name}=={version}=={bnumber} already exists. Skipping.") @@ -520,7 +522,7 @@ def build_win_pipeline( dump_for_gha(workflow, outfile) -def load_configuration(): +def load_configuration() -> VincaConfig: """Read vinca.yaml next to the recipes directory for the few settings the workflow needs (distribution, pixi versions); the recipes themselves are already generated, so the packages are not resolved again.""" diff --git a/vinca/main.py b/vinca/main.py index 4de851d..f1c8862 100644 --- a/vinca/main.py +++ b/vinca/main.py @@ -15,6 +15,7 @@ from vinca import __version__, config from vinca.utils import built_for_distro, get_channel_records, get_pkg_build_number +from .configuration import read_recipe_tests from .configuration import read_snapshot as read_snapshot from .configuration import read_vinca_yaml as load_vinca_yaml from .distro import Distro @@ -39,6 +40,7 @@ from .recipes import generate_output as build_output from .recipes import get_depmods as get_depmods from .resolve import resolve_pkgname +from .schemas import AdditionalRecipe, Provenance, VincaConfig, validate from .skip_existing import published_builds, remove_built_recipes from .sources import ( generate_fat_source as build_fat_source, @@ -53,7 +55,6 @@ source_reference, ) from .template import write_recipe, write_recipe_package -from .v1_selectors import evaluate_distro_selectors unsatisfied_deps = set() distro = None @@ -81,23 +82,23 @@ def print_version(value: bool) -> None: raise typer.Exit() -def read_vinca_yaml(filepath: str | Path): +def read_vinca_yaml(filepath: str | Path) -> VincaConfig: """Load configuration for the currently selected conda platform.""" return load_vinca_yaml(filepath, target_platform=get_conda_subdir()) -def should_skip_output(output, vinca_conf): +def should_skip_output(output, vinca_conf: VincaConfig) -> bool: """Return whether an individual output is already available.""" name = output["package"]["name"] version = output["package"]["version"] - skip_built_packages = vinca_conf.get("skip_built_packages", []) - if vinca_conf.get("trigger_new_versions"): + skip_built_packages = vinca_conf.skip_built_packages + if vinca_conf.trigger_new_versions: return (name, version) in skip_built_packages return name in skip_built_packages def append_output_with_compatibility( - outputs, output, pkg_shortname, distro: Distro, vinca_conf + outputs, output, pkg_shortname: str, distro: Distro, vinca_conf: VincaConfig ) -> None: """Append canonical and compatibility outputs, filtering each independently.""" candidate_outputs = [output] @@ -116,7 +117,7 @@ def append_output_with_compatibility( def generate_output( pkg_shortname: str, - vinca_conf: dict[str, Any], + vinca_conf: VincaConfig, distro: Distro, version: str, all_pkgs: Optional[list[Any]] = None, @@ -150,12 +151,12 @@ def get_group_dependency_packages(distro: Distro) -> list[Any]: def generate_dependency_requirements( - distro: Distro, vinca_conf: dict[str, Any], all_pkgs: list[Any] + distro: Distro, vinca_conf: VincaConfig, all_pkgs: list[Any] ) -> list[dict[str, Any]]: """Collect requirement mappings without generating complete recipe outputs.""" requirements = [] - for pkg_shortname in vinca_conf["_selected_pkgs"]: + for pkg_shortname in vinca_conf.selected_pkgs: if not distro.check_package(pkg_shortname): continue requirement = generate_output( @@ -171,13 +172,11 @@ def generate_dependency_requirements( mutex_config = parse_mutex_package_config(vinca_conf) if mutex_config is not None: - requirements.append( - {"run_constraints": mutex_config.get("run_constraints", [])} - ) + requirements.append({"run_constraints": mutex_config.run_constraints}) return requirements -def generate_outputs(distro: Distro, vinca_conf): +def generate_outputs(distro: Distro, vinca_conf: VincaConfig): outputs = [] def get_pkg(pkg_name: str): @@ -189,7 +188,7 @@ def get_pkg(pkg_name: str): all_pkgs = [get_pkg(pkg) for pkg in distro.get_depends("ros_base")] - for pkg_shortname in vinca_conf["_selected_pkgs"]: + for pkg_shortname in vinca_conf.selected_pkgs: if not distro.check_package(pkg_shortname): print(f"Could not generate output for {pkg_shortname}") continue @@ -227,9 +226,9 @@ def get_pkg(pkg_name: str): return outputs -def generate_outputs_version(distro: Distro, vinca_conf): +def generate_outputs_version(distro: Distro, vinca_conf: VincaConfig): outputs = [] - for pkg_shortname in vinca_conf["_selected_pkgs"]: + for pkg_shortname in vinca_conf.selected_pkgs: if not distro.check_package(pkg_shortname): print(f"Could not generate output for {pkg_shortname}") continue @@ -249,40 +248,40 @@ def _source_reference(*, url, ref, ref_type) -> dict[str, str]: return source_reference(url=url, ref=ref, ref_type=ref_type) -def generate_source(distro: Distro, vinca_conf): +def generate_source(distro: Distro, vinca_conf: VincaConfig): """Backward-compatible entry point that defaults to the selected platform.""" return build_source(distro, vinca_conf, get_conda_subdir()) -def generate_source_version(distro: Distro, vinca_conf): +def generate_source_version(distro: Distro, vinca_conf: VincaConfig): """Backward-compatible entry point that defaults to the selected platform.""" return build_source_version(distro, vinca_conf, get_conda_subdir()) -def generate_fat_source(distro, vinca_conf): +def generate_fat_source(distro: Distro, vinca_conf: VincaConfig): """Backward-compatible entry point for fat-recipe source generation.""" return build_fat_source(distro, vinca_conf) -def conda_index_shadowed_packages(distro: Distro, vinca_conf): +def conda_index_shadowed_packages(distro: Distro, vinca_conf: VincaConfig): """ROS packages of the distribution that a conda_index maps to conda packages.""" names = set() - for index in vinca_conf.get("_conda_indexes") or []: + for index in vinca_conf.conda_indexes: names |= {name for name in index if distro.check_package(name)} return names -def expected_build_number(default_build_number, pkg_name, vinca_conf): +def expected_build_number(default_build_number: int, pkg_name, vinca_conf: VincaConfig): """The build number a package of this configuration gets: the mutex has its own (mutex_package.build_number, e.g. a new build at the same version), the others the distribution's unless pkg_additional_info.yaml overrides it.""" mutex_config = parse_mutex_package_config(vinca_conf) - if mutex_config and pkg_name == mutex_config["name"]: - return mutex_config["build_number"] + if mutex_config and pkg_name == mutex_config.name: + return mutex_config.build_number return get_pkg_build_number(default_build_number, pkg_name, vinca_conf) -def get_selected_packages(distro: Distro, vinca_conf): +def get_selected_packages(distro: Distro, vinca_conf: VincaConfig): selected_packages = set() skipped_packages = set() @@ -294,26 +293,16 @@ def get_selected_packages(distro: Distro, vinca_conf): requested_by_config = set() required_by = {} - if vinca_conf.get("build_all", False): + if vinca_conf.build_all: selected_packages = set(distro._distro.release_packages.keys()) requested_by_config |= selected_packages # Add packages from rosdistro_additional_recipes.yaml when build_all is True - if ( - "_additional_packages_snapshot" in vinca_conf - and vinca_conf["_additional_packages_snapshot"] - ): - additional_packages = set( - vinca_conf["_additional_packages_snapshot"].keys() - ) + if vinca_conf.additional_packages_snapshot: + additional_packages = set(vinca_conf.additional_packages_snapshot.keys()) selected_packages = selected_packages.union(additional_packages) requested_by_config |= additional_packages - elif vinca_conf["packages_select_by_deps"]: - skip_by_deps = vinca_conf.get("_skip_by_deps") - if skip_by_deps is None: # configuration not read with read_vinca_yaml - skip_by_deps = (vinca_conf.get("packages_exclude") or []) + ( - vinca_conf.get("packages_skip") or [] - ) - for i in skip_by_deps: + elif vinca_conf.packages_select_by_deps: + for i in vinca_conf.skip_by_deps: skipped_packages = skipped_packages.union([i, i.replace("-", "_")]) # ROS packages whose name a conda_index maps to a conda package (e.g. # tl_expected -> cpp-expected) are not built: dependencies on them resolve to @@ -323,7 +312,7 @@ def get_selected_packages(distro: Distro, vinca_conf): print("Mapped to conda packages, not built: ", sorted(shadowed)) skipped_packages |= shadowed print("Skipped pkgs: ", skipped_packages) - for i in vinca_conf["packages_select_by_deps"]: + for i in vinca_conf.packages_select_by_deps: if i.replace("-", "_") in shadowed: continue i = i.replace("-", "_") @@ -374,16 +363,15 @@ def get_selected_packages(distro: Distro, vinca_conf): ) # expose provenance so a summary of *why* each recipe was generated can be printed - vinca_conf["_pkg_provenance"] = { - "requested_by_config": requested_by_config, - "required_by": required_by, - } + vinca_conf.pkg_provenance = Provenance( + requested_by_config=requested_by_config, required_by=required_by + ) result = sorted(list(selected_packages)) return result -def parse_package(pkg, distro: Distro, vinca_conf, path: str): +def parse_package(pkg, distro: Distro, vinca_conf: VincaConfig, path: str): name = pkg["name"].replace("_", "-") final_name = get_package_name(name, distro, vinca_conf) @@ -428,13 +416,8 @@ def parse_package(pkg, distro: Distro, vinca_conf, path: str): }, } - if test := vinca_conf.get("_tests", {}).get(final_name): - # parse as yaml - text = test.read_text(encoding="utf-8") - test_content = evaluate_distro_selectors( - ruamel.yaml.YAML(typ="safe").load(text), ros_distro=config.ros_distro - ) - recipe["test"] = test_content + if test := vinca_conf.tests.get(final_name): + recipe["test"] = read_recipe_tests(test, config.ros_distro).model_dump() for p in pkg["authors"]: name = p.name + " (" + p.email + ")" if p.email else p.name @@ -509,16 +492,15 @@ def parse_package(pkg, distro: Distro, vinca_conf, path: str): return recipe -def print_generation_summary(distro: Distro, vinca_conf, outputs) -> None: +def print_generation_summary(distro: Distro, vinca_conf: VincaConfig, outputs) -> None: """Print a summary of the generated recipes and, for each, *why* it was generated: whether it was requested directly by the config and which already-selected packages depend on it.""" from rich.console import Console from rich.table import Table - provenance = vinca_conf.get("_pkg_provenance", {}) - requested = provenance.get("requested_by_config", set()) - required_by = provenance.get("required_by", {}) + requested = vinca_conf.pkg_provenance.requested_by_config + required_by = vinca_conf.pkg_provenance.required_by # names that actually produced a recipe in this run generated_outputs = {output["package"]["name"]: output for output in outputs} @@ -526,7 +508,7 @@ def print_generation_summary(distro: Distro, vinca_conf, outputs) -> None: matched_names = set() rows = [] - for shortname in vinca_conf.get("_selected_pkgs", []): + for shortname in vinca_conf.selected_pkgs: try: pkg_names = resolve_pkgname(shortname, vinca_conf, distro) except Exception: @@ -632,24 +614,16 @@ def generate( vinca_conf = read_vinca_yaml(vinca_yaml) if trigger_new_versions: - vinca_conf["trigger_new_versions"] = True - else: - vinca_conf["trigger_new_versions"] = vinca_conf.get( - "trigger_new_versions", False - ) + vinca_conf.trigger_new_versions = True if package: pkg_files = glob.glob(package) - python_version = None - if "python_version" in vinca_conf: - python_version = vinca_conf["python_version"] - distro = Distro( - vinca_conf["ros_distro"], - python_version, - vinca_conf["_snapshot"], - vinca_conf["_additional_packages_snapshot"], + vinca_conf.ros_distro, + vinca_conf.python_version, + vinca_conf.snapshot, + vinca_conf.additional_packages_snapshot, ) additional_pkgs, parsed_pkgs = [], [] for f in pkg_files: @@ -687,11 +661,11 @@ def generate( else: # (name, version, build string) of the published builds; None: skip nothing published = None - if skip_already_built_repodata or vinca_conf.get("skip_existing"): + if skip_already_built_repodata or vinca_conf.skip_existing: published = set() - skip_built_packages = set() - vinca_conf["skip_built_packages"] = skip_built_packages - fns = list(skip_already_built_repodata or vinca_conf["skip_existing"]) + skip_built_packages: set[str | tuple[str, str]] = set() + vinca_conf.skip_built_packages = skip_built_packages + fns = list(skip_already_built_repodata or vinca_conf.skip_existing) yaml = ruamel.yaml.YAML() additional_recipe_names = set() @@ -699,11 +673,13 @@ def generate( os.path.join(base_dir, "additional_recipes", "**", "recipe.yaml") ): with open(add_rec, encoding="utf-8") as fi: - add_rec_y = yaml.load(fi) + add_rec_name = validate( + AdditionalRecipe, yaml.load(fi), add_rec + ).package.name if platform == "emscripten-wasm32": - additional_recipe_names.add(add_rec_y["package"]["name"]) + additional_recipe_names.add(add_rec_name) else: - if add_rec_y["package"]["name"] not in [ + if add_rec_name not in [ "ros2-rmw-wasm-cpp", "ros2-wasm-cpp", "ros2-dynmsg", @@ -713,7 +689,7 @@ def generate( "ros-humble-dynmsg", "ros-humble-test-wasm", ]: - additional_recipe_names.add(add_rec_y["package"]["name"]) + additional_recipe_names.add(add_rec_name) print("Found additional recipes: ", additional_recipe_names) @@ -724,16 +700,16 @@ def generate( records = get_channel_records(fn, get_conda_subdir()) if multiple_file: # compared by exact build string once the recipes are written - published |= published_builds(records, vinca_conf["ros_distro"]) + published |= published_builds(records, vinca_conf.ros_distro) continue # currently we don't check the build numbers of local repodatas, # only URLs if "://" in fn: - selected_bn = vinca_conf.get("build_number", 0) + selected_bn = vinca_conf.build_number or 0 for pkg in records: is_built = False - if not built_for_distro(pkg, vinca_conf["ros_distro"]): + if not built_for_distro(pkg, vinca_conf.ros_distro): # e.g. a channel shared by several distributions continue if selected_bn is not None: @@ -747,29 +723,20 @@ def generate( if is_built: print(f"Skipping {pkg['name']}") - if vinca_conf["trigger_new_versions"]: + if vinca_conf.trigger_new_versions: skip_built_packages.add((pkg["name"], pkg["version"])) else: skip_built_packages.add(pkg["name"]) - - vinca_conf["skip_built_packages"] = skip_built_packages - else: - vinca_conf["skip_built_packages"] = [] - print("Skip built packages!", vinca_conf["skip_built_packages"]) - python_version = None - if "python_version" in vinca_conf: - python_version = vinca_conf["python_version"] + print("Skip built packages!", vinca_conf.skip_built_packages) distro = Distro( - vinca_conf["ros_distro"], - python_version, - vinca_conf["_snapshot"], - vinca_conf["_additional_packages_snapshot"], + vinca_conf.ros_distro, + vinca_conf.python_version, + vinca_conf.snapshot, + vinca_conf.additional_packages_snapshot, ) - selected_pkgs = get_selected_packages(distro, vinca_conf) - - vinca_conf["_selected_pkgs"] = selected_pkgs + vinca_conf.selected_pkgs = get_selected_packages(distro, vinca_conf) if source_version: source = generate_source_version(distro, vinca_conf) diff --git a/vinca/migrate.py b/vinca/migrate.py index 7d6cff2..bd1d8a5 100644 --- a/vinca/migrate.py +++ b/vinca/migrate.py @@ -11,9 +11,11 @@ from rattler import Version from vinca import config +from vinca.configuration import read_vinca_yaml from vinca.distro import Distro +from vinca.schemas import Migration, validate -from .naming import PackageNameMode, get_package_name_mode, get_package_prefix +from .naming import PackageNameMode, get_package_prefix from .utils import get_channel_records distro_version = None @@ -39,19 +41,22 @@ def to_ros_name(distro: Distro, pkg_name): raise RuntimeError(f"Couldnt convert {pkg_name} to ROS pkg name") -def create_migration_instructions(arch, packages_to_migrate, trigger_branch) -> None: +def create_migration_instructions( + arch, packages_to_migrate: list[str], trigger_branch +) -> None: url = "https://conda.anaconda.org/robostack/" yaml = ruamel.yaml.YAML() with open("vinca.yaml", encoding="utf-8") as fi: vinca_conf = yaml.load(fi) + settings = read_vinca_yaml("vinca.yaml", target_platform=arch) global distro_version, ros_prefix - distro_version = vinca_conf["ros_distro"] + distro_version = settings.ros_distro distro = Distro(distro_version) - ros_prefix = get_package_prefix(distro, vinca_conf) + ros_prefix = get_package_prefix(distro, settings) legacy_prefix = distro.get_legacy_package_prefix() - package_name_mode = get_package_name_mode(vinca_conf) + package_name_mode = settings.package_name_mode packages = {record["fn"]: record for record in get_channel_records(url, arch)} to_migrate = set() @@ -200,8 +205,9 @@ def main() -> None: mfile = os.path.join(args.dir + "/migration.yaml") with open(mfile, encoding="utf-8") as fi: - migration = ruamel.yaml.YAML(typ="safe").load(fi) - print(migration) + data = ruamel.yaml.YAML(typ="safe").load(fi) + print(data) + migration = validate(Migration, data or {}, mfile) create_migration_instructions( - args.platform, migration.get("packages", []), args.trigger_branch + args.platform, migration.packages, args.trigger_branch ) diff --git a/vinca/mutex.py b/vinca/mutex.py index 8efd305..b126e36 100644 --- a/vinca/mutex.py +++ b/vinca/mutex.py @@ -13,104 +13,75 @@ from typing import Any from vinca.distro import Distro +from vinca.schemas import MutexPackage, VincaConfig, VincaYaml -# run_constraints is optional: a mutex only has to keep one distribution per environment -_REQUIRED_FIELDS = ("name", "version", "upper_bound") - -def parse_mutex_package_config(vinca_conf: dict[str, Any]) -> dict[str, Any] | None: - """Validate and normalize dictionary-style mutex configuration. +def parse_mutex_package_config(vinca_conf: VincaYaml) -> MutexPackage | None: + """The dictionary-style mutex configuration, with its build number defaulting to + the distribution's. String values are legacy dependency declarations and therefore do not produce a standalone mutex recipe. """ - mutex_package = vinca_conf.get("mutex_package") - if not mutex_package or isinstance(mutex_package, str): + mutex_package = vinca_conf.mutex_package + if not isinstance(mutex_package, MutexPackage): return None - if not isinstance(mutex_package, dict): - raise ValueError( - "mutex_package must be either a string or a dictionary, " - f"got {type(mutex_package).__name__}" - ) - - missing = [field for field in _REQUIRED_FIELDS if field not in mutex_package] - if missing: - raise ValueError( - f"mutex_package configuration is missing required fields: {missing}" - ) - - result = dict(mutex_package) - result.setdefault("build_number", vinca_conf.get("build_number", 1)) - return result + if mutex_package.build_number is not None: + return mutex_package + build_number = vinca_conf.build_number + return mutex_package.model_copy( + update={"build_number": 1 if build_number is None else build_number} + ) -def get_mutex_package_dependency( - vinca_conf: dict[str, Any], distro: Distro -) -> str | None: +def get_mutex_package_dependency(vinca_conf: VincaYaml, distro: Distro) -> str | None: """Return the dependency spec for legacy or dictionary-style configuration.""" - mutex_package = vinca_conf.get("mutex_package") + mutex_package = vinca_conf.mutex_package if not mutex_package: return None if isinstance(mutex_package, str): return mutex_package - try: - mutex_config = parse_mutex_package_config(vinca_conf) - except ValueError as error: - raise ValueError( - f"Error parsing mutex_package configuration: {error}" - ) from error - if mutex_config is None: - return None - - version_parts = mutex_config["version"].split(".") - pin_depth = len(mutex_config["upper_bound"].split(".")) + version_parts = mutex_package.version.split(".") + pin_depth = len(mutex_package.upper_bound.split(".")) pin = ".".join(version_parts[:pin_depth]) + ".*" - return f"{mutex_config['name']} {pin} {distro.name}_*" + return f"{mutex_package.name} {pin} {distro.name}_*" def should_skip_mutex_package( - vinca_conf: dict[str, Any], mutex_name: str, mutex_version: str + vinca_conf: VincaConfig, mutex_name: str, mutex_version: str ) -> bool: """Return whether the configured skip set already contains this mutex.""" - skipped = vinca_conf.get("skip_built_packages", []) - key = ( - (mutex_name, mutex_version) - if vinca_conf.get("trigger_new_versions") - else mutex_name - ) - return key in skipped + key = (mutex_name, mutex_version) if vinca_conf.trigger_new_versions else mutex_name + return key in vinca_conf.skip_built_packages def generate_mutex_package_recipe( - vinca_conf: dict[str, Any], distro: Distro + vinca_conf: VincaYaml, distro: Distro ) -> dict[str, Any] | None: """Generate a recipe for dictionary-style mutex configuration.""" - try: - mutex_config = parse_mutex_package_config(vinca_conf) - except ValueError as error: - raise ValueError(f"Cannot generate mutex package recipe: {error}") from error + mutex_config = parse_mutex_package_config(vinca_conf) if mutex_config is None: return None - name = mutex_config["name"] + name = mutex_config.name distro_name = distro.name return { - "package": {"name": name, "version": mutex_config["version"]}, + "package": {"name": name, "version": mutex_config.version}, "build": { - "number": mutex_config["build_number"], - "string": f"{distro_name}_{mutex_config['build_number']}", + "number": mutex_config.build_number, + "string": f"{distro_name}_{mutex_config.build_number}", "script": "", }, "requirements": { **( - {"run_constraints": mutex_config["run_constraints"]} - if mutex_config.get("run_constraints") + {"run_constraints": mutex_config.run_constraints} + if mutex_config.run_constraints else {} ), "run_exports": { "weak": [ - f"${{{{ pin_subpackage('{name}', upper_bound='{mutex_config['upper_bound']}') }}}}" + f"${{{{ pin_subpackage('{name}', upper_bound='{mutex_config.upper_bound}') }}}}" ] }, }, diff --git a/vinca/naming.py b/vinca/naming.py index e6dcaa8..b0cc01d 100644 --- a/vinca/naming.py +++ b/vinca/naming.py @@ -1,55 +1,42 @@ """Helpers for selecting and generating ROS package names.""" -from enum import Enum +from __future__ import annotations -from vinca.mutex import get_mutex_package_dependency - - -class PackageNameMode(str, Enum): - """Supported ROS package naming transition modes.""" +from typing import Any, Optional - LEGACY = "legacy" - BOTH = "both" - NEW = "new" - - -def get_package_name_mode(vinca_conf) -> PackageNameMode: - """Return the configured package name mode, defaulting to legacy names.""" - value = vinca_conf.get("package_name_mode", PackageNameMode.LEGACY.value) - try: - return PackageNameMode(value) - except ValueError as error: - choices = ", ".join(mode.value for mode in PackageNameMode) - raise ValueError( - f"Invalid package_name_mode {value!r}; expected one of: {choices}" - ) from error +from vinca.distro import Distro +from vinca.mutex import get_mutex_package_dependency +from vinca.schemas import PackageNameMode as PackageNameMode +from vinca.schemas import VincaYaml -def get_package_prefix(distro, vinca_conf): +def get_package_prefix(distro: Distro, vinca_conf: VincaYaml) -> str: """Return the prefix used for generated packages and their dependencies.""" - if get_package_name_mode(vinca_conf) is PackageNameMode.LEGACY: + if vinca_conf.package_name_mode is PackageNameMode.LEGACY: return distro.get_legacy_package_prefix() return distro.get_package_prefix() -def get_new_package_name(pkg_shortname, distro) -> str: +def get_new_package_name(pkg_shortname: str, distro: Distro) -> str: """Return the ROS-major-version package name.""" return f"{distro.get_package_prefix()}-{pkg_shortname.replace('_', '-')}" -def get_legacy_package_name(pkg_shortname, distro) -> str: +def get_legacy_package_name(pkg_shortname: str, distro: Distro) -> str: """Return the distro-qualified package name.""" return f"{distro.get_legacy_package_prefix()}-{pkg_shortname.replace('_', '-')}" -def get_package_name(pkg_shortname, distro, vinca_conf) -> str: +def get_package_name(pkg_shortname: str, distro: Distro, vinca_conf: VincaYaml) -> str: """Return the primary generated name for a ROS package.""" - if get_package_name_mode(vinca_conf) is PackageNameMode.LEGACY: + if vinca_conf.package_name_mode is PackageNameMode.LEGACY: return get_legacy_package_name(pkg_shortname, distro) return get_new_package_name(pkg_shortname, distro) -def normalize_package_dependency(dependency, distro, vinca_conf): +def normalize_package_dependency( + dependency: Any, distro: Distro, vinca_conf: VincaYaml +) -> Any: """Rewrite current-distro legacy dependency names for the selected mode.""" if isinstance(dependency, dict): return { @@ -72,9 +59,11 @@ def normalize_package_dependency(dependency, distro, vinca_conf): return package_prefix + dependency[len(legacy_prefix) :] -def generate_legacy_compatibility_output(output, pkg_shortname, distro, vinca_conf): +def generate_legacy_compatibility_output( + output: dict[str, Any], pkg_shortname: str, distro: Distro, vinca_conf: VincaYaml +) -> Optional[dict[str, Any]]: """Create an old-name package that depends on the renamed package, if requested.""" - if get_package_name_mode(vinca_conf) is not PackageNameMode.BOTH: + if vinca_conf.package_name_mode is not PackageNameMode.BOTH: return None canonical_name = get_new_package_name(pkg_shortname, distro) @@ -98,9 +87,11 @@ def generate_legacy_compatibility_output(output, pkg_shortname, distro, vinca_co } -def is_legacy_compatibility_output(output, distro, vinca_conf): +def is_legacy_compatibility_output( + output: dict[str, Any], distro: Distro, vinca_conf: VincaYaml +) -> bool: """Return whether an output is one of the generated compatibility packages.""" - if get_package_name_mode(vinca_conf) is not PackageNameMode.BOTH: + if vinca_conf.package_name_mode is not PackageNameMode.BOTH: return False legacy_prefix_getter = getattr(distro, "get_legacy_package_prefix", None) diff --git a/vinca/pinning.py b/vinca/pinning.py index e1d689e..bc28c76 100644 --- a/vinca/pinning.py +++ b/vinca/pinning.py @@ -18,36 +18,45 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from contextlib import contextmanager from pathlib import Path -from typing import Any, Iterator, Mapping, Optional, Sequence, Union, cast +from typing import ( + TYPE_CHECKING, + Any, + Iterator, + Mapping, + Optional, + Sequence, + Union, + cast, +) from urllib.parse import quote import rattler import requests import ruamel.yaml +from conda_forge_metadata.feedstock_outputs import package_to_feedstock +from pydantic import ValidationError from rattler.exceptions import ExtractError, GatewayError from rattler.package_streaming import download_and_extract +from vinca.schemas import ( + MigrationFile, + MigrationStatus, + PinningSpec, + VincaConfig, + validate, +) from vinca.v1_selectors import _eval_condition, _platform_flags from vinca.variant_algebra import variant_add +if TYPE_CHECKING: + from vinca.distro import Distro + PINNING_PACKAGE = "conda-forge-pinning" MIGRATION_STATUS_URL = ( "https://raw.githubusercontent.com/conda-forge/conda-forge-bot-data/main/" "status/migration_json/{migration}.json" ) -FEEDSTOCK_OUTPUT_URL = ( - "https://raw.githubusercontent.com/conda-forge/feedstock-outputs/main/" - "outputs/{shards}/{package}.json" -) DEFAULT_PLATFORMS = ("linux-64", "osx-64", "win-64") -STATUS_CATEGORIES = ( - "done", - "in-pr", - "awaiting-pr", - "awaiting-parents", - "not-solvable", - "bot-error", -) class PinningError(RuntimeError): @@ -112,23 +121,17 @@ def download_pinning_package( def _pinning_spec_fields( - data: Mapping[str, Any], + data: Mapping[str, Any], source: Union[str, Path] ) -> tuple[str, list[str], Mapping[str, Any]]: """Validate and normalize version, migration, and override fields from a pinning spec.""" - version = data.get("conda_forge_pinning_version") - migrations = data.get("migrations", []) - overrides = data.get("pinning_overrides") + try: + spec = validate(PinningSpec, data, source) + except ValidationError as exc: + raise PinningError(str(exc)) from exc + overrides = spec.pinning_overrides if overrides is None: - overrides = data.get("overrides", {}) - if version is None: - raise PinningError("Missing 'conda_forge_pinning_version'") - if not isinstance(migrations, list) or not all( - isinstance(item, str) for item in migrations - ): - raise PinningError("'migrations' must be a list of migration names") - if not isinstance(overrides, dict): - raise PinningError("'pinning_overrides' must be a mapping") - return str(version), migrations, overrides + overrides = spec.overrides or {} + return spec.conda_forge_pinning_version, spec.migrations, overrides def _migration_name(name: str) -> str: @@ -158,9 +161,17 @@ def _overlay(target: Any, source: Any) -> None: target.pop("zip_keys") -def _migration_timestamp(payload: bytes) -> float: +def _migration(payload: bytes, name: str = "migration") -> tuple[Any, MigrationFile]: + """The ruamel data of a migration file, and its validated ``__migrator`` fields.""" data = _yaml().load(payload.decode("utf-8")) or {} - return float(data.get("migrator_ts", -1.0)) + try: + return data, validate(MigrationFile, data, name) + except ValidationError as exc: + raise PinningError(str(exc)) from exc + + +def _migration_timestamp(payload: bytes) -> float: + return _migration(payload)[1].migrator_ts def _validate_zipped_overrides(rendered: Any, overrides: Any) -> None: @@ -225,7 +236,7 @@ def render_pinning( spec = yaml.load(stream) or {} except OSError as exc: raise PinningError(f"Could not read {config_path}: {exc}") from exc - version, selected_migrations, overrides = _pinning_spec_fields(spec) + version, selected_migrations, overrides = _pinning_spec_fields(spec, config_path) if package is None: base_payload, migration_payloads = download_pinning_package(version) else: @@ -313,7 +324,7 @@ def _working_directory(path: Any) -> Iterator[None]: @contextmanager def _platform_configurations( base_dir: Union[str, Path], platforms: Sequence[str] -) -> Iterator[Iterator[tuple[str, Any, dict[str, Any], Any]]]: +) -> Iterator[Iterator[tuple[str, Distro, VincaConfig, Any]]]: """Yield ``(platform, distro, vinca_config, group_packages)`` for each platform. The global vinca config state is restored afterwards. Raises ``PinningError`` if @@ -331,35 +342,34 @@ def _platform_configurations( previous_platform = config.selected_platform previous_args = config.parsed_args - def configurations() -> Iterator[tuple[str, Any, dict[str, Any], Any]]: + def configurations() -> Iterator[tuple[str, Distro, VincaConfig, Any]]: distro = None group_packages = None for platform in platforms: config.selected_platform = platform config.parsed_args = argparse.Namespace(platform=platform) vinca_config = read_vinca_yaml(base_dir / "vinca.yaml") - vinca_config["skip_built_packages"] = [] if distro is None: distro = Distro( - vinca_config["ros_distro"], - vinca_config.get("python_version"), - vinca_config["_snapshot"], - vinca_config["_additional_packages_snapshot"], + vinca_config.ros_distro, + vinca_config.python_version, + vinca_config.snapshot, + vinca_config.additional_packages_snapshot, ) distro.prefetch_additional_package_xml() group_packages = get_group_dependency_packages(distro) elif ( - distro.name != vinca_config["ros_distro"] - or distro.snapshot != vinca_config["_snapshot"] + distro.name != vinca_config.ros_distro + or distro.snapshot != vinca_config.snapshot or distro.additional_packages_snapshot - != vinca_config["_additional_packages_snapshot"] + != vinca_config.additional_packages_snapshot ): raise PinningError( "Platform selectors must not change the ROS distro snapshots" ) if group_packages is None: raise PinningError("No group packages were generated") - vinca_config["_selected_pkgs"] = get_selected_packages(distro, vinca_config) + vinca_config.selected_pkgs = get_selected_packages(distro, vinca_config) yield platform, distro, vinca_config, group_packages try: @@ -395,30 +405,16 @@ def _normalized(name: str) -> str: return name.lower().replace("_", "-") -def _feedstock_output_url(package: str) -> str: - # feedstock-outputs shards by the first three alphanumeric characters - prefix = ("".join(c for c in package.lower() if c.isalnum()) + "zzz")[:3] - shards = "/".join(prefix) - return FEEDSTOCK_OUTPUT_URL.format( - shards=shards, package=quote(package.lower(), safe="") - ) - - def package_feedstocks(package: str) -> set[str]: """Look up package-producing feedstocks, falling back to the package name on lookup failure.""" - url = _feedstock_output_url(package) try: - response = requests.get(url, timeout=30) - if response.status_code == 404: - return {package} - response.raise_for_status() - feedstocks = response.json().get("feedstocks", []) - except (requests.RequestException, ValueError): + feedstocks = package_to_feedstock(package, timeout=30) + except (requests.RequestException, ValueError, KeyError): return {package} return set(feedstocks) or {package} -def get_migration_status(migration: str) -> Optional[dict[str, Any]]: +def get_migration_status(migration: str) -> Optional[MigrationStatus]: """Fetch migration status, returning ``None`` only when no status record exists.""" url = MIGRATION_STATUS_URL.format(migration=quote(migration.lower(), safe="")) try: @@ -426,7 +422,7 @@ def get_migration_status(migration: str) -> Optional[dict[str, Any]]: if response.status_code == 404: return None response.raise_for_status() - return response.json() + return validate(MigrationStatus, response.json(), url) except (requests.RequestException, ValueError) as exc: raise PinningError( f"Could not read conda-forge status for migration {migration}: {exc}" @@ -443,8 +439,7 @@ def _migration_pin_keys(payload: bytes) -> set[str]: def _is_paused_migration(payload: bytes) -> bool: - data = _yaml().load(payload.decode("utf-8")) or {} - return bool((data.get("__migrator") or {}).get("paused", False)) + return _migration(payload)[1].migrator.paused def _comment_selector(comment: Any) -> Optional[str]: @@ -476,9 +471,8 @@ def _migration_selectors(data: Any) -> Iterator[Optional[str]]: def _migration_applies_to_platforms(payload: bytes, platforms: Sequence[str]) -> bool: """Return whether migration allowlists or selectors affect any requested platform.""" - data = _yaml().load(payload.decode("utf-8")) or {} - migrator = data.get("__migrator") or {} - allowlist = set(migrator.get("platform_allowlist", [])) + data, migration = _migration(payload) + allowlist = set(migration.migrator.platform_allowlist) if allowlist and not allowlist.intersection(platforms): return False selectors = list(_migration_selectors(data)) @@ -492,13 +486,10 @@ def _migration_applies_to_platforms(payload: bytes, platforms: Sequence[str]) -> ) -def _status_sets(status: Any) -> tuple[set[str], set[str]]: - values = { - category: {_normalized(name) for name in status.get(category, [])} - for category in STATUS_CATEGORIES - } - participants = set().union(*values.values()) - return values["done"], participants +def _status_sets(status: MigrationStatus) -> tuple[set[str], set[str]]: + done = {_normalized(name) for name in status.done} + participants = {_normalized(name) for name in status.participants} + return done, participants def select_completed_migrations( @@ -600,7 +591,7 @@ def update_pinning( if config_path.exists(): with config_path.open(encoding="utf-8") as stream: spec = yaml.load(stream) or {} - _, existing_migrations, _ = _pinning_spec_fields(spec) + _, existing_migrations, _ = _pinning_spec_fields(spec, config_path) else: spec = ruamel.yaml.comments.CommentedMap() spec["conda_forge_pinning_version"] = "" diff --git a/vinca/pipeline.py b/vinca/pipeline.py index b209cde..c1bb9c3 100644 --- a/vinca/pipeline.py +++ b/vinca/pipeline.py @@ -17,6 +17,7 @@ from ruamel.yaml import YAML +from vinca.schemas import BuildTimingsFile, StageSettings, VincaYaml, validate from vinca.utils import get_channel_records # a stage this short is one job, shared with the short stages next to it @@ -54,18 +55,18 @@ def from_file( ) -> BuildTimings: """Read a JSON file ``{"recipes": {name: seconds}, "default": s, "job_overhead": s}``.""" with Path(path).open(encoding="utf-8") as stream: - data = json.load(stream) - recipes = { - name: float(seconds) - for name, seconds in (data.get("recipes") or {}).items() - } - known = sorted(recipes.values()) - default = data.get("default", known[len(known) // 2] if known else cls.default) + data = validate(BuildTimingsFile, json.load(stream), path) + known = sorted(data.recipes.values()) + default = data.default + if default is None: + default = known[len(known) // 2] if known else cls.default return cls( - recipes=recipes, - default=float(default), - job_overhead=float(data.get("job_overhead", cls.job_overhead)), - runners=runners or int(data.get("runners", default_runners(platform))), + recipes=data.recipes, + default=default, + job_overhead=( + cls.job_overhead if data.job_overhead is None else data.job_overhead + ), + runners=runners or data.runners or default_runners(platform), ) def seconds(self, package: str) -> float: @@ -152,12 +153,13 @@ def batch_stages( timings = replace(timings, recipes=recipes) with Path(config_path).open(encoding="utf-8") as stream: - vinca_conf = YAML(typ="safe").load(stream) or {} + settings = validate( + StageSettings, YAML(typ="safe").load(stream) or {}, config_path + ) build_individually = set( - vinca_conf.get("build_in_own_stage") + settings.build_in_own_stage # Accepted for compatibility with configurations written for the Azure generator. - or vinca_conf.get("build_in_own_azure_stage") - or [] + or settings.build_in_own_azure_stage ) # units in order: (packages, splittable); a unit that isn't splittable is one job @@ -250,11 +252,10 @@ def more(index: int) -> tuple[float, list[list[str]]] | None: return jobs -def get_skip_existing(vinca_conf: Mapping[str, Any], platform: str) -> list[dict]: +def get_skip_existing(vinca_conf: VincaYaml, platform: str) -> list[dict[str, Any]]: """Fetch the package records of the channels configured for skip-existing checks.""" - repositories = vinca_conf.get("skip_existing") or [] records = [] - for repository in repositories: + for repository in vinca_conf.skip_existing: print(f"Fetching repodata: {repository}") records += get_channel_records(repository, platform) return records diff --git a/vinca/rebuild.py b/vinca/rebuild.py index 8ef3564..6ceba56 100644 --- a/vinca/rebuild.py +++ b/vinca/rebuild.py @@ -125,7 +125,7 @@ def requirements_from_vinca( with _platform_configurations(base_dir, platforms) as configurations: for _, distro, vinca_config, group_packages in configurations: prefix = get_package_prefix(distro, vinca_config) - for name in vinca_config["_selected_pkgs"]: + for name in vinca_config.selected_pkgs: if not distro.check_package(name): continue reqs = generate_output( diff --git a/vinca/recipes.py b/vinca/recipes.py index 112dca5..598100f 100644 --- a/vinca/recipes.py +++ b/vinca/recipes.py @@ -27,6 +27,7 @@ from vinca.mutex import get_mutex_package_dependency from vinca.naming import get_package_prefix, normalize_package_dependency from vinca.resolve import resolve_pkgname +from vinca.schemas import DepMods, DummyPackage, Requirement, VincaConfig from vinca.utils import ( get_pkg_additional_info, get_pkg_build_number, @@ -92,8 +93,8 @@ _HOST_PYTHON = ("python", "numpy", "pip") -def _conditional_python(vinca_conf: dict[str, Any]) -> bool: - return bool(vinca_conf.get("conditional_python_dependency", False)) +def _conditional_python(vinca_conf: VincaConfig) -> bool: + return vinca_conf.conditional_python_dependency # Installed into every Python-free output's tests (conditional_python_dependency): @@ -207,53 +208,53 @@ def _package_needs_python(package: catkin_pkg.package.Package, build_type: str) def get_depmods( - vinca_conf: dict[str, Any], package_name: str, distro: Distro + vinca_conf: VincaConfig, package_name: str, distro: Distro ) -> tuple[Requirements, Requirements]: """Return normalized dependency removals and additions by requirement section.""" - configured = vinca_conf.get("depmods", {}).get(package_name, {}) - removed = {section: [] for section in ("build", "host", "run")} - added = {section: [] for section in ("build", "host", "run")} - for section in removed: - removed[section] = [ - normalize_package_dependency(dependency, distro, vinca_conf) - for dependency in configured.get(f"remove_{section}", []) - ] - added[section] = [ + configured = vinca_conf.depmods.get(package_name, DepMods()) + + def normalized(dependencies: list[Requirement]) -> list[Any]: + return [ normalize_package_dependency(dependency, distro, vinca_conf) - for dependency in configured.get(f"add_{section}", []) + for dependency in dependencies ] - return removed, added + removed = { + "build": normalized(configured.remove_build), + "host": normalized(configured.remove_host), + "run": normalized(configured.remove_run), + } + added = { + "build": normalized(configured.add_build), + "host": normalized(configured.add_host), + "run": normalized(configured.add_run), + } + return removed, added -def _dummy_constraint( - config: dict[str, Any], version: str, shortname: str -) -> tuple[str, str]: - dependency = config.get("dep_name") - if not dependency: - raise RuntimeError(f"Missing 'dep_name' for dummy recipe of {shortname}") - upper_bound = config.get("upper_bound") or config.get("max_pin") - if not upper_bound: - raise RuntimeError( - f"Missing 'upper_bound' or 'max_pin' for dummy recipe of {shortname}" - ) - package_version = config.get("override_version", version) +def _dummy_constraint(config: DummyPackage, version: str) -> tuple[str, str]: + package_version = ( + version if config.override_version is None else config.override_version + ) parts = [int(part) for part in package_version.split(".")] - pin_depth = len(upper_bound.split(".")) + pin_depth = len(config.pin.split(".")) upper_parts = parts[:pin_depth] upper_parts[-1] += 1 upper_parts += [0] * (len(parts) - pin_depth) upper = ".".join(map(str, upper_parts)) + "a0" - return package_version, f"{dependency} >={package_version}, <{upper}" + return package_version, f"{config.dep_name} >={package_version}, <{upper}" def _initial_output( shortname: str, package_name: str, version: str, - vinca_conf: dict[str, Any], + vinca_conf: VincaConfig, ) -> dict[str, Any]: - if not is_dummy_metapackage(shortname, vinca_conf): + dummy_config = get_pkg_additional_info( + shortname, vinca_conf + ).generate_dummy_package_with_run_deps + if dummy_config is None: requirements = copy.deepcopy(_BASE_REQUIREMENTS) if _conditional_python(vinca_conf): requirements["host"] = [ @@ -265,15 +266,12 @@ def _initial_output( "build": {"script": ""}, } - dummy_config = get_pkg_additional_info(shortname, vinca_conf)[ - "generate_dummy_package_with_run_deps" - ] - package_version, constraint = _dummy_constraint(dummy_config, version, shortname) + package_version, constraint = _dummy_constraint(dummy_config, version) return { "package": {"name": package_name, "version": package_version}, "build": { "number": get_pkg_build_number( - vinca_conf.get("build_number", 0), package_name, vinca_conf + vinca_conf.build_number or 0, package_name, vinca_conf ), "script": "", }, @@ -291,7 +289,7 @@ def _evaluated_dependency_names(dependencies) -> list[str]: def _resolve_into( dependencies: list[str], destination: list[Any], - vinca_conf: dict[str, Any], + vinca_conf: VincaConfig, distro: Distro, unsatisfied: set[str], *, @@ -456,7 +454,7 @@ def _add_metadata( def generate_output( shortname: str, - vinca_conf: dict[str, Any], + vinca_conf: VincaConfig, distro: Distro, version: str, all_packages: list[Any] | None = None, @@ -478,7 +476,7 @@ def generate_output( """ all_packages = all_packages or [] unsatisfied = unsatisfied if unsatisfied is not None else set() - if shortname not in vinca_conf["_selected_pkgs"]: + if shortname not in vinca_conf.selected_pkgs: return None package_names = resolve_pkgname(shortname, vinca_conf, distro) diff --git a/vinca/resolve.py b/vinca/resolve.py index 6b0b6f7..e062c97 100644 --- a/vinca/resolve.py +++ b/vinca/resolve.py @@ -1,9 +1,13 @@ +from __future__ import annotations + import os from typing import Optional from urllib.request import urlopen from vinca import config +from vinca.distro import Distro from vinca.naming import get_package_name +from vinca.schemas import CondaIndex, VincaConfig, validate map_platform_python_to_conda = { "linux-64": "linux", @@ -15,22 +19,25 @@ } -def get_conda_index(vinca_conf, base_dir): +def get_conda_index(indexes: list[str], base_dir: str) -> list[CondaIndex]: import ruamel.yaml yaml = ruamel.yaml.YAML() conda_index = [] - for i in vinca_conf["conda_index"]: + for i in indexes: ip = os.path.join(base_dir, i) if os.path.isfile(ip): - rawdata = yaml.load(open(ip, "r")) + with open(ip, encoding="utf-8") as stream: + rawdata = yaml.load(stream) else: rawdata = yaml.load(urlopen(i)) - conda_index.append(rawdata) + conda_index.append(validate(CondaIndex, rawdata or {}, i)) return conda_index -def resolve_pkgname_from_indexes(pkg_shortname, conda_index): +def resolve_pkgname_from_indexes( + pkg_shortname: str, conda_index: list[CondaIndex] +) -> Optional[list[str]]: for i in conda_index: if pkg_shortname in i: selected_platform = config.selected_platform @@ -39,13 +46,15 @@ def resolve_pkgname_from_indexes(pkg_shortname, conda_index): "A target platform is required to resolve package names" ) sys_platform = map_platform_python_to_conda[selected_platform] - if "robostack" in i[pkg_shortname].keys(): - if selected_platform in i[pkg_shortname]["robostack"]: - return i[pkg_shortname]["robostack"][selected_platform] - elif sys_platform in i[pkg_shortname]["robostack"]: - return i[pkg_shortname]["robostack"][sys_platform] - else: - return i[pkg_shortname]["robostack"] + packages = i[pkg_shortname].robostack + if isinstance(packages, list): + return packages + if packages is not None: + if selected_platform in packages: + return packages[selected_platform] + if sys_platform in packages: + return packages[sys_platform] + return list(packages) raise KeyError( "Missing package for platform {}: {}\nCheck your conda metadata!".format( sys_platform, pkg_shortname @@ -55,21 +64,18 @@ def resolve_pkgname_from_indexes(pkg_shortname, conda_index): return None -def should_skip_pkg(pkg_shortname, vinca_conf) -> Optional[bool]: - skip = vinca_conf.get("_remove_from_deps", vinca_conf.get("packages_exclude")) - if not skip: - return False - - if pkg_shortname in skip: - return True - if pkg_shortname.replace("_", "-") in skip: - return True +def should_skip_pkg(pkg_shortname: str, vinca_conf: VincaConfig) -> bool: + skip = vinca_conf.remove_from_deps + return pkg_shortname in skip or pkg_shortname.replace("_", "-") in skip -def resolve_pkgname(pkg_shortname, vinca_conf, distro, is_rundep: bool = False): - pkg_names = resolve_pkgname_from_indexes( - pkg_shortname, vinca_conf["_conda_indexes"] - ) +def resolve_pkgname( + pkg_shortname: str, + vinca_conf: VincaConfig, + distro: Distro, + is_rundep: bool = False, +) -> list[str]: + pkg_names = resolve_pkgname_from_indexes(pkg_shortname, vinca_conf.conda_indexes) if pkg_names is None: if not distro.check_package(pkg_shortname) or should_skip_pkg( pkg_shortname, vinca_conf diff --git a/vinca/schemas.py b/vinca/schemas.py new file mode 100644 index 0000000..7397b7c --- /dev/null +++ b/vinca/schemas.py @@ -0,0 +1,405 @@ +"""Schemas of the files vinca reads. + +Every input file is validated into one of these models where it is read, so the rest +of vinca works with typed attributes instead of untyped mappings. The models of +vinca's own files reject unknown keys: a misspelled setting is an error rather than +silently ignored. Formats owned by other tools (conda-forge pinning, rosdep-style +conda_index files) only declare the keys vinca reads. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from functools import lru_cache +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Any, Optional, TypeVar, Union, cast + +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + TypeAdapter, + ValidationError, + model_validator, +) + +if TYPE_CHECKING: + from pydantic_core import InitErrorDetails + +_T = TypeVar("_T") + +# A recipe requirement: a match spec, or a conditional ``{if: ..., then: [...]}``. +Requirement = Union[str, dict[str, Any]] + + +class PackageNameMode(str, Enum): + """Supported ROS package naming transition modes.""" + + LEGACY = "legacy" + BOTH = "both" + NEW = "new" + + +class VariantsMode(Enum): + """How generated recipes consume the repository-wide pinning configuration.""" + + GLOBAL = "global" + LOCAL = "local" + + +def _none_as_empty_list(value: Any) -> Any: + return [] if value is None else value + + +def _none_as_empty_dict(value: Any) -> Any: + return {} if value is None else value + + +def _package_names(value: Any) -> list[str]: + """Package names of a (selector-resolved) package list, as written.""" + return [str(item) for item in value or [] if item is not None] + + +StrList = Annotated[list[str], BeforeValidator(_none_as_empty_list)] +PackageList = Annotated[list[str], BeforeValidator(_package_names)] +RequirementList = Annotated[list[Requirement], BeforeValidator(_none_as_empty_list)] + + +class _Schema(BaseModel): + model_config = ConfigDict(extra="forbid", coerce_numbers_to_str=True) + + +class MutexPackage(_Schema): + """``mutex_package`` as a mapping: vinca generates the distribution mutex.""" + + name: str + version: str + upper_bound: str + build_number: Optional[int] = None + run_constraints: StrList = [] + + +class DepMods(_Schema): + """A package's entry in ``patch/dependencies.yaml``.""" + + add_build: RequirementList = [] + add_host: RequirementList = [] + add_run: RequirementList = [] + remove_build: RequirementList = [] + remove_host: RequirementList = [] + remove_run: RequirementList = [] + + +class DummyPackage(_Schema): + """``generate_dummy_package_with_run_deps``: a recipe that only depends on the + conda-forge package providing the ROS package.""" + + dep_name: str + upper_bound: Optional[str] = None + max_pin: Optional[str] = None + override_version: Optional[str] = None + + @model_validator(mode="after") + def _has_upper_bound(self) -> DummyPackage: + if not (self.upper_bound or self.max_pin): + raise ValueError("Missing 'upper_bound' or 'max_pin'") + return self + + @property + def pin(self) -> str: + return self.upper_bound or self.max_pin or "" + + +class PkgAdditionalInfo(_Schema): + """A package's entry in ``pkg_additional_info.yaml``.""" + + additional_cmake_args: Annotated[ + str, BeforeValidator(lambda value: "" if value is None else value) + ] = "" + build_number: Optional[int] = None + generate_dummy_package_with_run_deps: Optional[DummyPackage] = None + variant_overrides: Annotated[ + dict[str, Any], BeforeValidator(_none_as_empty_dict) + ] = {} + + +class SnapshotEntry(_Schema): + """A package of ``rosdistro_snapshot.yaml`` or ``rosdistro_additional_recipes.yaml``. + + ``dependencies`` is ``None`` for snapshots written before vinca-snapshot recorded + them; the dependencies are then resolved from the rosdistro cache. + """ + + url: str + version: str + tag: Optional[str] = None + rev: Optional[str] = None + sha256: Optional[str] = None + dependencies: Annotated[ + Optional[list[str]], BeforeValidator(_none_as_empty_list) + ] = None + repository: Optional[str] = None + additional_folder: str = "" + package_xml_name: str = "package.xml" + + +Snapshot = dict[str, SnapshotEntry] + + +class CondaIndexEntry(BaseModel): + """A key of a conda_index file: the conda packages it maps to, for every platform + or per platform (``linux``, ``osx``, ``win64``, ``emscripten`` or a conda subdir). + + conda_index files can be rosdep files with entries for other systems, so other + keys are ignored. + """ + + model_config = ConfigDict(extra="ignore") + + robostack: Union[ + Annotated[list[str], BeforeValidator(_none_as_empty_list)], + dict[str, Annotated[list[str], BeforeValidator(_none_as_empty_list)]], + None, + ] = None + + +CondaIndex = dict[str, CondaIndexEntry] + + +class RecipeTests(_Schema): + """A package's ``tests/.yaml``: tests added to its recipe.""" + + tests: list[dict[str, Any]] = [] + + +class VincaYaml(_Schema): + """``vinca.yaml``, combined with the configurations it extends and with its + selectors resolved for the target platform.""" + + ros_distro: str + python_version: Optional[str] = None + build_number: Optional[int] = None + package_name_mode: PackageNameMode = PackageNameMode.LEGACY + variants_mode: VariantsMode = VariantsMode.GLOBAL + conda_index: StrList = [] + patch_dir: Optional[str] = None + rosdistro_snapshot: Optional[str] = None + rosdistro_additional_recipes: Optional[str] = None + mutex_package: Union[str, MutexPackage, None] = None + build_all: bool = False + packages_select_by_deps: PackageList = [] + packages_exclude: PackageList = [] + packages_skip: PackageList = [] + skip_existing: StrList = [] + trigger_new_versions: bool = False + skip_testing: bool = True + conditional_python_dependency: bool = False + setup_pixi_version: Optional[str] = None + pixi_version: Optional[str] = None + build_in_own_stage: StrList = [] + build_in_own_azure_stage: StrList = [] + is_migration: bool = False + # dependency fixes inline, for a configuration without patch/dependencies.yaml + depmods: Annotated[dict[str, DepMods], BeforeValidator(_none_as_empty_dict)] = {} + + +class VincaYamlLayer(BaseModel): + """The keys of one ``vinca.yaml``, before it is combined with the configurations + it extends, that name files relative to it.""" + + model_config = ConfigDict(extra="ignore") + + patch_dir: Optional[str] = None + conda_index: StrList = [] + + +class Provenance(BaseModel): + """Why each package was selected: requested by the configuration, or required by + the requested packages that (transitively) depend on it.""" + + requested_by_config: set[str] = set() + required_by: dict[str, set[str]] = {} + + +class VincaConfig(VincaYaml): + """The configuration vinca works with: ``vinca.yaml`` and what + :func:`vinca.configuration.read_vinca_yaml` derives from the files next to it. + + The fields below are never read from the user's file. The last group is filled in + while recipes are generated. + """ + + skip_by_deps: list[str] = [] + remove_from_deps: list[str] = [] + patch_dir_path: Optional[Path] = None + patches: dict[str, dict[str, list[str]]] = {} + tests: dict[str, Path] = {} + test_folders: dict[str, Path] = {} + conda_indexes: list[CondaIndex] = [] + pkg_additional_info: dict[str, PkgAdditionalInfo] = {} + variant_config: dict[str, Any] = {} + snapshot: Snapshot = {} + additional_packages_snapshot: Snapshot = {} + + selected_pkgs: list[str] = [] + skip_built_packages: set[Union[str, tuple[str, str]]] = set() + pkg_provenance: Provenance = Provenance() + + @model_validator(mode="after") + def _derive_exclusions(self) -> VincaConfig: + """Without read_vinca_yaml's derived lists, derive them from the package + lists (read_vinca_yaml also adds the deprecated keys' packages).""" + if "skip_by_deps" not in self.model_fields_set: + self.skip_by_deps = list( + dict.fromkeys(self.packages_exclude + self.packages_skip) + ) + if "remove_from_deps" not in self.model_fields_set: + self.remove_from_deps = list(self.packages_exclude) + return self + + +class PinningSpec(_Schema): + """``vinca_pinning.yaml``: a conda-forge-pinning version, the migrations applied + on top of it, and local overrides (``overrides`` is the old name).""" + + conda_forge_pinning_version: str + migrations: list[str] = [] + pinning_overrides: Optional[dict[str, Any]] = None + overrides: Optional[dict[str, Any]] = None + + +class BuildTimingsFile(_Schema): + """Measured build times: ``{"recipes": {name: seconds}, "default": seconds, + "job_overhead": seconds, "runners": jobs}``.""" + + recipes: Annotated[dict[str, float], BeforeValidator(_none_as_empty_dict)] = {} + default: Optional[float] = None + job_overhead: Optional[float] = None + runners: Optional[int] = None + + +class StageSettings(BaseModel): + """The settings of ``vinca.yaml`` that CI pipeline batching reads.""" + + model_config = ConfigDict(extra="ignore") + + build_in_own_stage: StrList = [] + build_in_own_azure_stage: StrList = [] + + +class _RecipePackage(BaseModel): + model_config = ConfigDict(coerce_numbers_to_str=True) + + name: str + version: str + + +class _RecipeBuild(BaseModel): + number: int = 0 + + +class AdditionalRecipe(BaseModel): + """A recipe of ``additional_recipes/``: the package it builds. The rest of the + recipe is rattler-build's.""" + + model_config = ConfigDict(extra="ignore") + + package: _RecipePackage + build: _RecipeBuild = _RecipeBuild() + + +class Migration(BaseModel): + """``migration.yaml`` of vinca-migrate: the packages being migrated.""" + + model_config = ConfigDict(extra="ignore") + + packages: StrList = [] + + +def _as_posix_timestamp(value: Any) -> Any: + return value.timestamp() if isinstance(value, datetime) else value + + +class Migrator(BaseModel): + """The ``__migrator`` block of a conda-forge migration (CFEP-9).""" + + model_config = ConfigDict(extra="ignore") + + operation: Optional[str] = None + primary_key: Union[str, list[str], None] = None + additional_zip_keys: StrList = [] + ordering: Annotated[dict[str, list[Any]], BeforeValidator(_none_as_empty_dict)] = {} + paused: bool = False + platform_allowlist: StrList = [] + + +class MigrationFile(BaseModel): + """A conda-forge migration file: ``__migrator``, ``migrator_ts``, and the + variant keys it changes (read with ruamel, to keep their selector comments).""" + + model_config = ConfigDict(extra="ignore") + + migrator: Annotated[Migrator, BeforeValidator(_none_as_empty_dict)] = Field( + Migrator(), validation_alias="__migrator" + ) + migrator_ts: Annotated[float, BeforeValidator(_as_posix_timestamp)] = -1.0 + + +class FeedstockMigrationStatus(BaseModel): + """The migration PR of one feedstock.""" + + model_config = ConfigDict(extra="ignore") + + pr_status: str = "" + pr_url: str = "" + + +class MigrationStatus(BaseModel): + """conda-forge-bot-data's ``status/migration_json/.json``: the + feedstocks of a migration by state.""" + + model_config = ConfigDict(extra="ignore") + + feedstock_status: dict[str, FeedstockMigrationStatus] = Field( + {}, validation_alias="_feedstock_status" + ) + done: StrList = [] + in_pr: StrList = Field([], validation_alias="in-pr") + awaiting_pr: StrList = Field([], validation_alias="awaiting-pr") + awaiting_parents: StrList = Field([], validation_alias="awaiting-parents") + not_solvable: StrList = Field([], validation_alias="not-solvable") + bot_error: StrList = Field([], validation_alias="bot-error") + + @property + def states(self) -> dict[str, list[str]]: + """The feedstocks by state, keyed as in the status file.""" + return { + "done": self.done, + "in-pr": self.in_pr, + "awaiting-pr": self.awaiting_pr, + "awaiting-parents": self.awaiting_parents, + "not-solvable": self.not_solvable, + "bot-error": self.bot_error, + } + + @property + def participants(self) -> list[str]: + """Every feedstock the migration covers, whatever its state.""" + return [name for names in self.states.values() for name in names] + + +@lru_cache(maxsize=None) +def _adapter(schema: Any) -> TypeAdapter[Any]: + return TypeAdapter(schema) + + +def validate(schema: type[_T], data: Any, source: Union[str, Path]) -> _T: + """Validate parsed *data* of the file *source* (named in validation errors).""" + try: + return _adapter(schema).validate_python(data) + except ValidationError as error: + raise ValidationError.from_exception_data( + str(source), cast("list[InitErrorDetails]", error.errors()) + ) from None diff --git a/vinca/skip_existing.py b/vinca/skip_existing.py index cf897a7..333a53c 100644 --- a/vinca/skip_existing.py +++ b/vinca/skip_existing.py @@ -11,13 +11,20 @@ from __future__ import annotations -import json import shutil -import subprocess from collections.abc import Iterable, Mapping from pathlib import Path from typing import Any, Optional +from rattler_build import ( + JinjaConfig, + PlatformConfig, + RattlerBuildError, + RenderConfig, + Stage0Recipe, + VariantConfig, +) + from vinca.utils import built_for_distro Build = tuple[str, str, str] @@ -42,33 +49,32 @@ def render_outputs( are: the build platform is part of the variant hash, so rendering on another machine (e.g. one linux-64 runner generating every platform's recipes) gives build strings that never match the published ones.""" - cmd = [ - "rattler-build", - "build", - "--render-only", - "--recipe-dir", - str(recipes_dir), - "--target-platform", - target_platform, - "--build-platform", - target_platform, - ] - if variant_config is not None: - cmd += ["-m", str(variant_config)] - proc = subprocess.run(cmd, capture_output=True, text=True) - if proc.returncode != 0: - raise RuntimeError( - f"rendering the recipes in {recipes_dir} failed:\n{proc.stderr}" + platform = PlatformConfig( + target_platform=target_platform, + build_platform=target_platform, + host_platform=target_platform, + ) + variants = ( + VariantConfig.from_files_with_context( + [variant_config], JinjaConfig(platform=platform) ) + if variant_config is not None + else VariantConfig() + ) + render_config = RenderConfig(platform=platform) rendered: dict[str, list[Build]] = {} - for output in json.loads(proc.stdout): - package = output["recipe"]["package"] - build = ( - package["name"], - package["version"], - output["recipe"]["build"]["string"], - ) - rendered.setdefault(package["name"], []).append(build) + for recipe_path in sorted(recipes_dir.glob("**/recipe.yaml")): + try: + variants_of_recipe = Stage0Recipe.from_file(recipe_path).render( + variants, render_config + ) + except RattlerBuildError as error: + raise RuntimeError(f"rendering {recipe_path} failed: {error}") from error + for variant in variants_of_recipe: + recipe = variant.recipe + name = str(recipe.package.name) + build = (name, str(recipe.package.version), str(recipe.build.string)) + rendered.setdefault(name, []).append(build) return rendered diff --git a/vinca/sources.py b/vinca/sources.py index 17c3c15..9406255 100644 --- a/vinca/sources.py +++ b/vinca/sources.py @@ -7,7 +7,7 @@ name, while :func:`generate_fat_source` emits a flat list for a single recipe containing every package. -``vinca_conf`` is treated as read-only. Patch paths come from ``_patches``, which +``vinca_conf`` is treated as read-only. Patch paths come from ``patches``, which :mod:`vinca.configuration` populates. """ @@ -19,6 +19,7 @@ from vinca.distro import Distro from vinca.mutex import generate_mutex_package_recipe, should_skip_mutex_package from vinca.resolve import resolve_pkgname +from vinca.schemas import VincaConfig from vinca.utils import is_dummy_metapackage @@ -29,22 +30,17 @@ def source_reference(*, url: str, ref: str, ref_type: str) -> dict[str, str]: return {"git": url, ref_type: ref} -def _is_built(package_name: str, version: str, vinca_conf: dict[str, Any]) -> bool: +def _is_built(package_name: str, version: str, vinca_conf: VincaConfig) -> bool: """Return whether the package is already listed as built and should be skipped.""" - skipped = vinca_conf.get("skip_built_packages", []) - key = ( - (package_name, version) - if vinca_conf.get("trigger_new_versions") - else package_name - ) - return key in skipped + key = (package_name, version) if vinca_conf.trigger_new_versions else package_name + return key in vinca_conf.skip_built_packages def package_patches( - package_name: str, vinca_conf: dict[str, Any], platform: str + package_name: str, vinca_conf: VincaConfig, platform: str ) -> list[str]: """Return the generic patches for a package followed by its platform-specific ones.""" - configured = vinca_conf.get("_patches", {}).get(package_name) + configured = vinca_conf.patches.get(package_name) if not configured: return [] return [*configured.get("any", []), *configured.get(platform.split("-")[0], [])] @@ -61,7 +57,7 @@ def _recipe_patch_paths(patches: list[str]) -> list[str]: def generate_source( - distro: Distro, vinca_conf: dict[str, Any], platform: str + distro: Distro, vinca_conf: VincaConfig, platform: str ) -> dict[str, dict[str, Any]]: """Generate per-package release sources for a multi-recipe build. @@ -69,7 +65,7 @@ def generate_source( rattler-build resolves them relative to the recipe; the template copies them. """ sources: dict[str, dict[str, Any]] = {} - for shortname in vinca_conf["_selected_pkgs"]: + for shortname in vinca_conf.selected_pkgs: if not distro.check_package(shortname): print(f"Could not generate source for {shortname}") continue @@ -102,7 +98,7 @@ def generate_source( def generate_source_version( - distro: Distro, vinca_conf: dict[str, Any], platform: str + distro: Distro, vinca_conf: VincaConfig, platform: str ) -> dict[str, dict[str, Any]]: """Generate release sources while retaining absolute patch paths. @@ -110,7 +106,7 @@ def generate_source_version( it to inspect versions rather than to drive a build. """ sources: dict[str, dict[str, Any]] = {} - for shortname in vinca_conf["_selected_pkgs"]: + for shortname in vinca_conf.selected_pkgs: if not distro.check_package(shortname): print(f"Could not generate source for {shortname}") continue @@ -131,11 +127,11 @@ def generate_source_version( def generate_fat_source( - distro: Distro, vinca_conf: dict[str, Any] + distro: Distro, vinca_conf: VincaConfig ) -> list[dict[str, Any]]: """Generate sources for a single fat recipe containing all packages.""" sources: list[dict[str, Any]] = [] - for shortname in vinca_conf["_selected_pkgs"]: + for shortname in vinca_conf.selected_pkgs: if not distro.check_package(shortname): print(f"Could not generate source for {shortname}") continue @@ -148,8 +144,8 @@ def generate_fat_source( entry: dict[str, Any] = source_reference(url=url, ref=ref, ref_type=ref_type) entry["target_directory"] = f"src/{package_name}" - patch_path = Path(vinca_conf["_patch_dir"]) / f"{package_name}.patch" - if patch_path.exists(): - entry["patches"] = [f"{vinca_conf['patch_dir']}/{package_name}.patch"] + patch_dir = vinca_conf.patch_dir_path + if patch_dir is not None and (patch_dir / f"{package_name}.patch").exists(): + entry["patches"] = [f"{vinca_conf.patch_dir}/{package_name}.patch"] sources.append(entry) return sources diff --git a/vinca/template.py b/vinca/template.py index 82375c7..47b6cea 100644 --- a/vinca/template.py +++ b/vinca/template.py @@ -10,15 +10,17 @@ from ruamel import yaml from vinca import config +from vinca.configuration import read_recipe_tests +from vinca.distro import Distro from vinca.naming import get_package_prefix, is_legacy_compatibility_output +from vinca.schemas import VariantsMode, VincaConfig from vinca.sources import package_patches from vinca.utils import ( ensure_name_is_without_distro_prefix_and_with_underscores, get_pkg_additional_info, get_pkg_build_number, ) -from vinca.v1_selectors import evaluate_distro_selectors -from vinca.variants import VariantsMode, get_recipe_variants, get_variants_mode +from vinca.variants import get_recipe_variants if TYPE_CHECKING: # importlib.resources.abc is new in Python 3.11 @@ -92,7 +94,9 @@ def copyfile_with_exec_permissions(source_file, destination_file) -> None: ) -def write_recipe(source, outputs, vinca_conf, distro, single_file: bool = True) -> None: +def write_recipe( + source, outputs, vinca_conf: VincaConfig, distro: Distro, single_file: bool = True +) -> None: # single_file = False if single_file: file = yaml.YAML() @@ -105,7 +109,7 @@ def write_recipe(source, outputs, vinca_conf, distro, single_file: bool = True) meta["package"]["version"] = f"{datetime.datetime.now():%Y.%m.%d}" meta["recipe"] = meta["package"] del meta["package"] - meta["build"]["number"] = vinca_conf.get("build_number", 0) + meta["build"]["number"] = vinca_conf.build_number or 0 meta["build"]["post_process"] = post_process_items with open("recipe.yaml", "w", encoding="utf-8") as stream: file.dump(meta, stream) @@ -142,29 +146,24 @@ def write_recipe(source, outputs, vinca_conf, distro, single_file: bool = True) # an output's own build number (e.g. mutex_package.build_number) wins # over the distribution's; pkg_additional_info.yaml overrides both meta["build"]["number"] = get_pkg_build_number( - o.get("build", {}).get("number", vinca_conf.get("build_number", 0)), + o.get("build", {}).get("number", vinca_conf.build_number or 0), package_name, vinca_conf, ) meta["build"]["post_process"] = post_process_items if not is_compatibility_output and ( - test := vinca_conf["_tests"].get(package_name) + test := vinca_conf.tests.get(package_name) ): print("Using test: ", test) - text = test.read_text(encoding="utf-8") - test_content = evaluate_distro_selectors( - yaml.YAML(typ="safe").load(text), ros_distro=config.ros_distro - ) - meta["tests"] = list(meta.get("tests") or []) + test_content["tests"] + test_content = read_recipe_tests(test, config.ros_distro) + meta["tests"] = list(meta.get("tests") or []) + test_content.tests recipe_dir = (Path("recipes") / package_name).absolute() os.makedirs(recipe_dir, exist_ok=True) if not is_compatibility_output and ( - test_folder_path := vinca_conf.get("_test_folders", {}).get( - package_name - ) + test_folder_path := vinca_conf.test_folders.get(package_name) ): # Copy all contents of the test folder to the recipe directory for item in test_folder_path.iterdir(): @@ -180,7 +179,7 @@ def write_recipe(source, outputs, vinca_conf, distro, single_file: bool = True) with open(recipe_dir / "recipe.yaml", "w", encoding="utf-8") as stream: file.dump(meta, stream) - if get_variants_mode(vinca_conf) is VariantsMode.LOCAL: + if vinca_conf.variants_mode is VariantsMode.LOCAL: variants = get_recipe_variants(meta, package_shortname, vinca_conf) with open( recipe_dir / "variants.yaml", "w", encoding="utf-8" @@ -212,22 +211,18 @@ def write_recipe(source, outputs, vinca_conf, distro, single_file: bool = True) additional_cmake_args = "" additional_folder = "" if package_shortname: - pkg_additional_info = get_pkg_additional_info( + additional_cmake_args = get_pkg_additional_info( package_shortname, vinca_conf - ) - additional_cmake_args = pkg_additional_info.get( - "additional_cmake_args", "" - ) + ).additional_cmake_args # Check if this package has folder info from additional_packages_snapshot if ( - vinca_conf.get("_additional_packages_snapshot") - and package_shortname - in vinca_conf["_additional_packages_snapshot"] - ): - additional_folder = vinca_conf["_additional_packages_snapshot"][ + additional_package + := vinca_conf.additional_packages_snapshot.get( package_shortname - ].get("additional_folder", "") + ) + ): + additional_folder = additional_package.additional_folder generate_build_script_for_recipe( script_filename, @@ -278,9 +273,9 @@ def generate_template( def generate_build_script_for_recipe( script_name, output_path, - ros_package_prefix, - additional_cmake_args="", - additional_folder="", + ros_package_prefix: str, + additional_cmake_args: str = "", + additional_folder: str = "", ) -> None: """Generate a specific build script directly in the recipe directory.""" diff --git a/vinca/test_archive_sources.py b/vinca/test_archive_sources.py index b938945..1ea3204 100644 --- a/vinca/test_archive_sources.py +++ b/vinca/test_archive_sources.py @@ -13,6 +13,7 @@ is_archive_url, ) from vinca.main import _source_reference +from vinca.schemas import SnapshotEntry PACKAGE_XML = "demo_pkg" @@ -142,11 +143,11 @@ def test_read_archive_member_rejects_a_directory_member(): def test_get_released_repo_returns_the_checksum_of_an_archive(): distro = Distro.__new__(Distro) distro.snapshot = { - "demo_pkg": { - "url": "https://example.com/demo-1.0.zip", - "sha256": "abc123", - "version": "1.0", - } + "demo_pkg": SnapshotEntry( + url="https://example.com/demo-1.0.zip", + sha256="abc123", + version="1.0", + ) } assert distro.get_released_repo("demo_pkg") == ( "https://example.com/demo-1.0.zip", @@ -157,7 +158,9 @@ def test_get_released_repo_returns_the_checksum_of_an_archive(): def test_get_released_repo_rejects_an_archive_without_a_checksum(): distro = Distro.__new__(Distro) - distro.snapshot = {"demo_pkg": {"url": "https://example.com/demo-1.0.zip"}} + distro.snapshot = { + "demo_pkg": SnapshotEntry(url="https://example.com/demo-1.0.zip", version="1.0") + } with pytest.raises(RuntimeError, match="no sha256 checksum"): distro.get_released_repo("demo_pkg") @@ -165,7 +168,9 @@ def test_get_released_repo_rejects_an_archive_without_a_checksum(): def test_get_released_repo_still_returns_a_git_reference(): distro = Distro.__new__(Distro) distro.snapshot = { - "demo_pkg": {"url": "https://github.com/org/repo.git", "rev": "deadbeef"} + "demo_pkg": SnapshotEntry( + url="https://github.com/org/repo.git", version="1.0", rev="deadbeef" + ) } assert distro.get_released_repo("demo_pkg") == ( "https://github.com/org/repo.git", @@ -188,11 +193,12 @@ def fake_download(url): return payload monkeypatch.setattr(distro, "_download_archive_or_cached", fake_download) - pkg_info = { - "url": "https://example.com/demo-1.0.zip", - "sha256": "abc123", - "additional_folder": "src/demo_pkg", - } + pkg_info = SnapshotEntry( + url="https://example.com/demo-1.0.zip", + version="1.0", + sha256="abc123", + additional_folder="src/demo_pkg", + ) assert distro.get_package_xml_for_additional_package(pkg_info) == PACKAGE_XML # A second lookup is served from the cache. assert distro.get_package_xml_for_additional_package(pkg_info) == PACKAGE_XML diff --git a/vinca/test_config_layering.py b/vinca/test_config_layering.py index 320a9df..fc253f1 100644 --- a/vinca/test_config_layering.py +++ b/vinca/test_config_layering.py @@ -3,6 +3,7 @@ import pytest from vinca.configuration import read_vinca_yaml +from vinca.schemas import DepMods, MutexPackage, PkgAdditionalInfo def _write(path, text): @@ -82,27 +83,28 @@ def layered(tmp_path, monkeypatch): def test_lists_and_scalars_are_layered(layered): conf = read_vinca_yaml(layered[1] / "vinca.yaml", "linux-64") - assert conf["packages_select_by_deps"] == ["extra"] # desktop is skipped - assert conf["packages_skip"] == ["desktop"] - assert conf["packages_exclude"] == ["broken"] - assert conf["package_name_mode"] == "new" - assert conf["skip_testing"] is True - assert "extends" not in conf + assert conf.packages_select_by_deps == ["extra"] # desktop is skipped + assert conf.packages_skip == ["desktop"] + assert conf.packages_exclude == ["broken"] + assert conf.package_name_mode == "new" + assert conf.skip_testing is True + assert "extends" not in conf.model_dump() def test_selectors_of_the_base_are_resolved(layered): conf = read_vinca_yaml(layered[1] / "vinca.yaml", "win-64") - assert conf["packages_select_by_deps"] == ["win_only", "extra"] + assert conf.packages_select_by_deps == ["win_only", "extra"] def test_mutex_constraints_are_overridden_by_package(layered): - mutex = read_vinca_yaml(layered[1] / "vinca.yaml", "linux-64")["mutex_package"] + mutex = read_vinca_yaml(layered[1] / "vinca.yaml", "linux-64").mutex_package - assert mutex["name"] == "ros2-distro-mutex" - assert mutex["version"] == "0.10.0" - assert mutex["upper_bound"] == "x.x" - assert mutex["run_constraints"] == ["libboost 1.90.*", "pcl 1.16.*", "krb5 1.22.*"] + assert isinstance(mutex, MutexPackage) + assert mutex.name == "ros2-distro-mutex" + assert mutex.version == "0.10.0" + assert mutex.upper_bound == "x.x" + assert mutex.run_constraints == ["libboost 1.90.*", "pcl 1.16.*", "krb5 1.22.*"] def test_files_of_each_layer(layered): @@ -110,25 +112,21 @@ def test_files_of_each_layer(layered): conf = read_vinca_yaml(distro / "vinca.yaml", "linux-64") # per-package settings: the distribution's keys win - assert conf["depmods"]["demo"] == { - "add_host": ["shared-dep"], - "add_run": ["own-dep"], - } - assert conf["_pkg_additional_info"]["demo"] == { - "additional_cmake_args": "-DFOO=ON", - "build_number": 3, - } + assert conf.depmods["demo"] == DepMods(add_host=["shared-dep"], add_run=["own-dep"]) + assert conf.pkg_additional_info["demo"] == PkgAdditionalInfo( + additional_cmake_args="-DFOO=ON", build_number=3 + ) # patches and tests: the distribution's file replaces the shared one - assert conf["_patches"]["ros2-demo"]["any"] == [ + assert conf.patches["ros2-demo"]["any"] == [ str(distro / "patch" / "ros2-demo.patch") ] - assert conf["_patches"]["ros2-other"]["any"] == [ + assert conf.patches["ros2-other"]["any"] == [ str(shared / "patch" / "ros2-other.patch") ] - assert conf["_tests"]["ros2-demo"] == distro / "tests" / "ros2-demo.yaml" - assert conf["_tests"]["ros2-other"] == shared / "tests" / "ros2-other.yaml" + assert conf.tests["ros2-demo"] == distro / "tests" / "ros2-demo.yaml" + assert conf.tests["ros2-other"] == shared / "tests" / "ros2-other.yaml" # conda_index: the distribution's mappings are looked up first - assert [list(index) for index in conf["_conda_indexes"]] == [ + assert [list(index) for index in conf.conda_indexes] == [ ["foo"], ["eigen", "foo"], ] @@ -145,7 +143,10 @@ def test_extends_cycle_is_rejected(tmp_path, monkeypatch): def test_paths_are_relative_to_the_file_that_sets_them(layered, tmp_path, monkeypatch): shared, distro = layered - _write(distro / "rosdistro_snapshot.yaml", "demo:\n version: 1.0.0\n") + _write( + distro / "rosdistro_snapshot.yaml", + "demo:\n url: https://github.com/x/demo.git\n version: 1.0.0\n", + ) text = (distro / "vinca.yaml").read_text() (distro / "vinca.yaml").write_text( text + "rosdistro_snapshot: rosdistro_snapshot.yaml\n" @@ -154,8 +155,8 @@ def test_paths_are_relative_to_the_file_that_sets_them(layered, tmp_path, monkey conf = read_vinca_yaml(distro / "vinca.yaml", "linux-64") - assert conf["_snapshot"]["demo"]["version"] == "1.0.0" - assert conf["_patch_dir"] == distro / "patch" - assert conf["_patches"]["ros2-demo"]["any"] == [ + assert conf.snapshot["demo"].version == "1.0.0" + assert conf.patch_dir_path == distro / "patch" + assert conf.patches["ros2-demo"]["any"] == [ str(distro / "patch" / "ros2-demo.patch") ] diff --git a/vinca/test_configuration.py b/vinca/test_configuration.py index ccf423b..2f111b3 100644 --- a/vinca/test_configuration.py +++ b/vinca/test_configuration.py @@ -1,6 +1,8 @@ import pytest +from pydantic import ValidationError from vinca.configuration import read_snapshot, read_vinca_yaml +from vinca.schemas import DepMods, SnapshotEntry, VariantsMode, VincaYaml def test_read_vinca_yaml_discovers_companion_files(tmp_path, monkeypatch): @@ -24,17 +26,17 @@ def test_read_vinca_yaml_discovers_companion_files(tmp_path, monkeypatch): config = read_vinca_yaml(tmp_path / "vinca.yaml", "linux-64") - assert config["_patches"]["demo"]["any"] == [str(patches / "demo.patch")] - assert config["_patches"]["demo"]["linux"] == [str(patches / "demo.unix.patch")] - assert config["_patches"]["demo"]["osx"] == [str(patches / "demo.unix.patch")] - assert config["_patches"]["demo"]["win"] == [str(patches / "demo.win.patch")] - assert config["_tests"]["demo"] == tests / "demo.yaml" - assert config["_tests"]["dotted"] == tests / "dotted.name.yaml" - assert config["_test_folders"]["demo"] == tests / "demo" - assert config["_pkg_additional_info"]["demo"]["build_number"] == 2 - assert config["depmods"] == {"demo": {}} - assert config["variants_mode"] == "global" - assert config["_variant_config"] == {} + assert config.patches["demo"]["any"] == [str(patches / "demo.patch")] + assert config.patches["demo"]["linux"] == [str(patches / "demo.unix.patch")] + assert config.patches["demo"]["osx"] == [str(patches / "demo.unix.patch")] + assert config.patches["demo"]["win"] == [str(patches / "demo.win.patch")] + assert config.tests["demo"] == tests / "demo.yaml" + assert config.tests["dotted"] == tests / "dotted.name.yaml" + assert config.test_folders["demo"] == tests / "demo" + assert config.pkg_additional_info["demo"].build_number == 2 + assert config.depmods == {"demo": DepMods()} + assert config.variants_mode is VariantsMode.GLOBAL + assert config.variant_config == {} def test_read_vinca_yaml_loads_local_variant_config(tmp_path, monkeypatch): @@ -54,8 +56,8 @@ def test_read_vinca_yaml_loads_local_variant_config(tmp_path, monkeypatch): config = read_vinca_yaml(tmp_path / "vinca.yaml", "linux-64") - assert config["variants_mode"] == "local" - assert config["_variant_config"] == { + assert config.variants_mode is VariantsMode.LOCAL + assert config.variant_config == { "c_compiler": [ {"if": "linux", "then": "gcc"}, {"if": "osx", "then": "clang"}, @@ -73,7 +75,7 @@ def test_read_vinca_yaml_rejects_invalid_variants_mode(tmp_path, monkeypatch): "ros_distro: humble\nconda_index: []\npatch_dir: patches\nvariants_mode: other\n" ) - with pytest.raises(ValueError, match="Invalid variants_mode 'other'"): + with pytest.raises(ValidationError, match=r"(?s)vinca\.yaml.*variants_mode"): read_vinca_yaml(tmp_path / "vinca.yaml", "linux-64") @@ -91,22 +93,33 @@ def test_read_vinca_yaml_requires_config_for_local_pinning(tmp_path, monkeypatch def test_read_snapshot_merges_additional_packages(tmp_path): snapshot = tmp_path / "snapshot.yaml" additional = tmp_path / "additional.yaml" - snapshot.write_text("existing:\n version: 1\noverridden:\n version: 1\n") - additional.write_text("overridden:\n version: 2\nnew:\n version: 1\n") + snapshot.write_text( + "existing:\n url: https://github.com/x/existing.git\n version: 1\n" + "overridden:\n url: https://github.com/x/overridden.git\n version: 1\n" + ) + additional.write_text( + "overridden:\n url: https://github.com/x/overridden.git\n version: 2\n" + "new:\n url: https://github.com/x/new.git\n version: 1\n" + ) merged, loaded_additional = read_snapshot( - { - "rosdistro_snapshot": str(snapshot), - "rosdistro_additional_recipes": str(additional), - } + VincaYaml( + ros_distro="humble", + rosdistro_snapshot=str(snapshot), + rosdistro_additional_recipes=str(additional), + ) ) assert merged == { - "existing": {"version": 1}, - "overridden": {"version": 2}, - "new": {"version": 1}, + "existing": SnapshotEntry(url="https://github.com/x/existing.git", version="1"), + "overridden": SnapshotEntry( + url="https://github.com/x/overridden.git", version="2" + ), + "new": SnapshotEntry(url="https://github.com/x/new.git", version="1"), } assert loaded_additional == { - "overridden": {"version": 2}, - "new": {"version": 1}, + "overridden": SnapshotEntry( + url="https://github.com/x/overridden.git", version="2" + ), + "new": SnapshotEntry(url="https://github.com/x/new.git", version="1"), } diff --git a/vinca/test_distro_dependencies.py b/vinca/test_distro_dependencies.py index 7961f7d..2d75b72 100644 --- a/vinca/test_distro_dependencies.py +++ b/vinca/test_distro_dependencies.py @@ -2,6 +2,7 @@ from unittest.mock import Mock from vinca.distro import Distro +from vinca.schemas import SnapshotEntry def test_dependency_walk_reuses_direct_dependencies_across_roots(): @@ -97,16 +98,22 @@ def test_dependency_walk_excludes_root_from_longer_cycle(): def test_prefetch_additional_manifests_skips_archives(): distro = Distro.__new__(Distro) distro.additional_packages_snapshot = { - "one": {"url": "https://github.com/example/one.git", "rev": "abc"}, - "two": {"url": "https://gitlab.com/example/two", "tag": "1.0"}, - "archive": {"url": "https://example.com/source.tar.gz", "sha256": "abc"}, + "one": SnapshotEntry( + url="https://github.com/example/one.git", version="1.0", rev="abc" + ), + "two": SnapshotEntry( + url="https://gitlab.com/example/two", version="1.0", tag="1.0" + ), + "archive": SnapshotEntry( + url="https://example.com/source.tar.gz", version="1.0", sha256="abc" + ), } distro.get_package_xml_for_additional_package = Mock(return_value="") distro.prefetch_additional_package_xml(max_workers=2) requested = { - call.args[0]["url"] + call.args[0].url for call in distro.get_package_xml_for_additional_package.call_args_list } assert requested == { diff --git a/vinca/test_github_raw_url.py b/vinca/test_github_raw_url.py index b4474aa..f19f81a 100644 --- a/vinca/test_github_raw_url.py +++ b/vinca/test_github_raw_url.py @@ -1,6 +1,7 @@ from typing import Any from vinca.distro import Distro +from vinca.schemas import SnapshotEntry def _distro() -> Any: @@ -14,10 +15,11 @@ def test_tag_ref_uses_explicit_refs_tags_prefix(): # guess is inconsistently cached across CDN edges (the same URL 404s from # some vantage points, including GitHub Actions runners, while resolving # fine from others). The explicit refs/tags/ form is unambiguous. - pkg_info = { - "url": "https://github.com/ros2-gbp/ros2_control-release.git", - "tag": "release/jazzy/controller_interface/4.47.0-1", - } + pkg_info = SnapshotEntry( + url="https://github.com/ros2-gbp/ros2_control-release.git", + version="4.47.0", + tag="release/jazzy/controller_interface/4.47.0-1", + ) url = _distro()._construct_raw_url_github(pkg_info) @@ -30,10 +32,11 @@ def test_tag_ref_uses_explicit_refs_tags_prefix(): def test_rev_ref_is_used_as_is(): # A commit hash is already unambiguous -- it must not get the refs/tags/ # prefix, since it isn't a tag name. - pkg_info = { - "url": "https://github.com/ros2-gbp/ros2_control-release.git", - "rev": "abc123def456", - } + pkg_info = SnapshotEntry( + url="https://github.com/ros2-gbp/ros2_control-release.git", + version="4.47.0", + rev="abc123def456", + ) url = _distro()._construct_raw_url_github(pkg_info) @@ -44,12 +47,13 @@ def test_rev_ref_is_used_as_is(): def test_tag_ref_with_additional_folder_and_custom_xml_name(): - pkg_info = { - "url": "https://github.com/example/some-release.git", - "tag": "release/rolling/some_pkg/1.0.0-1", - "additional_folder": "some_pkg", - "package_xml_name": "package.xml", - } + pkg_info = SnapshotEntry( + url="https://github.com/example/some-release.git", + version="1.0.0", + tag="release/rolling/some_pkg/1.0.0-1", + additional_folder="some_pkg", + package_xml_name="package.xml", + ) url = _distro()._construct_raw_url_github(pkg_info) diff --git a/vinca/test_mutex.py b/vinca/test_mutex.py index 043c8c6..efdd3be 100644 --- a/vinca/test_mutex.py +++ b/vinca/test_mutex.py @@ -1,4 +1,5 @@ import pytest +from pydantic import ValidationError from vinca.mutex import ( generate_mutex_package_recipe, @@ -6,6 +7,7 @@ parse_mutex_package_config, should_skip_mutex_package, ) +from vinca.schemas import VincaConfig class Distro: @@ -14,6 +16,7 @@ class Distro: def mutex_config(**overrides): config = { + "ros_distro": "jazzy", "mutex_package": { "name": "ros2-distro-mutex", "version": "1.2.0", @@ -21,25 +24,25 @@ def mutex_config(**overrides): "run_constraints": ["ros2-distro-mutex 1.2.*"], }, "build_number": 3, - "skip_built_packages": [], + "skip_built_packages": set(), } config.update(overrides) - return config + return VincaConfig(**config) def test_parse_mutex_config_adds_default_build_number(): parsed = parse_mutex_package_config(mutex_config()) - assert parsed["build_number"] == 3 + assert parsed.build_number == 3 def test_parse_mutex_config_reports_missing_fields(): - with pytest.raises(ValueError, match="upper_bound"): - parse_mutex_package_config({"mutex_package": {"name": "mutex"}}) + with pytest.raises(ValidationError, match="upper_bound"): + VincaConfig(ros_distro="jazzy", mutex_package={"name": "mutex"}) def test_legacy_mutex_is_used_as_dependency_without_generating_recipe(): - config = {"mutex_package": "ros2-distro-mutex 1.*"} + config = VincaConfig(ros_distro="jazzy", mutex_package="ros2-distro-mutex 1.*") assert get_mutex_package_dependency(config, Distro()) == "ros2-distro-mutex 1.*" assert generate_mutex_package_recipe(config, Distro()) is None @@ -61,12 +64,17 @@ def test_dictionary_mutex_dependency_and_recipe(): def test_skip_mutex_uses_name_or_name_and_version(): - assert should_skip_mutex_package({"skip_built_packages": ["mutex"]}, "mutex", "1.0") assert should_skip_mutex_package( - { - "trigger_new_versions": True, - "skip_built_packages": [("mutex", "1.0")], - }, + VincaConfig(ros_distro="jazzy", skip_built_packages={"mutex"}), + "mutex", + "1.0", + ) + assert should_skip_mutex_package( + VincaConfig( + ros_distro="jazzy", + trigger_new_versions=True, + skip_built_packages={("mutex", "1.0")}, + ), "mutex", "1.0", ) diff --git a/vinca/test_mutex_build_number.py b/vinca/test_mutex_build_number.py index f24f654..cafc2ab 100644 --- a/vinca/test_mutex_build_number.py +++ b/vinca/test_mutex_build_number.py @@ -6,26 +6,25 @@ from vinca.main import expected_build_number from vinca.mutex import generate_mutex_package_recipe +from vinca.schemas import PkgAdditionalInfo, VincaConfig from vinca.template import write_recipe yaml = YAML(typ="safe") -def _conf(**extra): - conf = { - "ros_distro": "lyrical", - "build_number": 27, - "mutex_package": { +def _conf(**mutex_package): + return VincaConfig( + ros_distro="lyrical", + build_number=27, + mutex_package={ "name": "ros2-distro-mutex", "version": "0.21.0", "upper_bound": "x.x", "build_number": 28, + **mutex_package, }, - "_pkg_additional_info": {"rclcpp": {"build_number": 30}}, - "_tests": {}, - } - conf.update(extra) - return conf + pkg_additional_info={"rclcpp": PkgAdditionalInfo(build_number=30)}, + ) def test_expected_build_number_of_the_mutex_is_its_own(): @@ -39,8 +38,7 @@ def test_expected_build_number_of_other_packages(): def test_mutex_without_own_build_number_uses_the_distribution_one(): - conf = _conf() - del conf["mutex_package"]["build_number"] + conf = _conf(build_number=None) assert expected_build_number(27, "ros2-distro-mutex", conf) == 27 @@ -61,8 +59,7 @@ def test_mutex_recipe_keeps_its_build_number(tmp_path, monkeypatch): def test_mutex_with_run_constraints_keeps_them(): - conf = _conf() - conf["mutex_package"]["run_constraints"] = ["libfoo 1.*"] + conf = _conf(run_constraints=["libfoo 1.*"]) distro = Mock() distro.name = "lyrical" recipe = generate_mutex_package_recipe(conf, distro) diff --git a/vinca/test_naming_scheme.py b/vinca/test_naming_scheme.py index 32c939d..d480b15 100644 --- a/vinca/test_naming_scheme.py +++ b/vinca/test_naming_scheme.py @@ -3,17 +3,19 @@ from typing import Any import pytest +from pydantic import ValidationError +from vinca import config from vinca.distro import Distro from vinca.main import append_output_with_compatibility, get_depmods from vinca.naming import ( PackageNameMode, generate_legacy_compatibility_output, get_package_name, - get_package_name_mode, is_legacy_compatibility_output, ) -from vinca.resolve import resolve_pkgname +from vinca.resolve import resolve_pkgname, resolve_pkgname_from_indexes +from vinca.schemas import CondaIndex, VincaConfig, validate from vinca.utils import ( add_package_name_variants, ensure_name_is_without_distro_prefix_and_with_underscores, @@ -62,7 +64,7 @@ def make_distro(distribution_type="ros2", name="humble"): ) def test_package_normalization(input_name, expected_output): result = ensure_name_is_without_distro_prefix_and_with_underscores( - input_name, {"ros_distro": "humble"} + input_name, VincaConfig(ros_distro="humble") ) assert result == expected_output @@ -79,7 +81,7 @@ def test_package_normalization(input_name, expected_output): ) def test_no_hyphens_in_normalized_output(package_name): result = ensure_name_is_without_distro_prefix_and_with_underscores( - package_name, {"ros_distro": "humble"} + package_name, VincaConfig(ros_distro="humble") ) assert "-" not in result @@ -116,7 +118,11 @@ def test_distro_prefixes_and_version( def test_package_name_mode_selects_primary_name(mode, expected_name): distro = make_distro() assert ( - get_package_name("my_package", distro, {"package_name_mode": mode}) + get_package_name( + "my_package", + distro, + VincaConfig(ros_distro="humble", package_name_mode=mode), + ) == expected_name ) @@ -124,8 +130,10 @@ def test_package_name_mode_selects_primary_name(mode, expected_name): def test_package_name_mode_defaults_to_legacy(): distro = make_distro() - assert get_package_name_mode({}) is PackageNameMode.LEGACY - assert get_package_name("my_package", distro, {}) == "ros-humble-my-package" + vinca_conf = VincaConfig(ros_distro="humble") + + assert vinca_conf.package_name_mode is PackageNameMode.LEGACY + assert get_package_name("my_package", distro, vinca_conf) == "ros-humble-my-package" @pytest.mark.parametrize( @@ -139,11 +147,37 @@ def test_package_name_mode_defaults_to_legacy(): def test_dependency_resolution_uses_selected_name_mode(mode, expected_name): distro: Any = make_distro() distro.check_package = lambda _name: True - vinca_conf = {"_conda_indexes": [], "package_name_mode": mode} + vinca_conf = VincaConfig(ros_distro="humble", package_name_mode=mode) assert resolve_pkgname("my_package", vinca_conf, distro) == [expected_name] +@pytest.mark.parametrize( + "platform,expected_names", + [ + ("linux-64", ["uvloop"]), + ("win-64", []), + # as before the schemas: a platform without an entry gets the mapping's keys + ("emscripten-wasm32", ["linux", "osx", "win64"]), + ], +) +def test_conda_index_per_platform_mapping(monkeypatch, platform, expected_names): + monkeypatch.setattr(config, "selected_platform", platform) + conda_index = validate( + CondaIndex, + { + "python3-uvloop": { + "robostack": {"linux": ["uvloop"], "osx": ["uvloop"], "win64": []} + } + }, + "robostack.yaml", + ) + + assert ( + resolve_pkgname_from_indexes("python3-uvloop", [conda_index]) == expected_names + ) + + @pytest.mark.parametrize( "skip_built_packages,expected_names", [ @@ -159,10 +193,11 @@ def test_both_mode_filters_new_and_legacy_outputs_independently( distro = make_distro() output = {"package": {"name": "ros2-my-package", "version": "1.2.3"}} outputs = [] - vinca_conf = { - "package_name_mode": "both", - "skip_built_packages": skip_built_packages, - } + vinca_conf = VincaConfig( + ros_distro="humble", + package_name_mode="both", + skip_built_packages=skip_built_packages, + ) append_output_with_compatibility(outputs, output, "my_package", distro, vinca_conf) @@ -173,11 +208,12 @@ def test_version_aware_skip_filters_compatibility_output_independently(): distro = make_distro() output = {"package": {"name": "ros2-my-package", "version": "1.2.3"}} outputs = [] - vinca_conf = { - "package_name_mode": "both", - "trigger_new_versions": True, - "skip_built_packages": {("ros-humble-my-package", "1.2.3")}, - } + vinca_conf = VincaConfig( + ros_distro="humble", + package_name_mode="both", + trigger_new_versions=True, + skip_built_packages={("ros-humble-my-package", "1.2.3")}, + ) append_output_with_compatibility(outputs, output, "my_package", distro, vinca_conf) @@ -188,9 +224,10 @@ def test_version_aware_skip_filters_compatibility_output_independently(): def test_depmods_use_selected_name_mode_recursively(): distro = make_distro() - vinca_conf = { - "package_name_mode": "new", - "depmods": { + vinca_conf = VincaConfig( + ros_distro="humble", + package_name_mode="new", + depmods={ "my_package": { "add_host": [ "ros-humble-rclcpp >=1", @@ -203,7 +240,7 @@ def test_depmods_use_selected_name_mode_recursively(): "remove_run": ["ros-humble-tl-expected"], } }, - } + ) removed, added = get_depmods(vinca_conf, "my_package", distro) @@ -230,8 +267,8 @@ def test_package_config_entries_are_available_under_old_and_new_names(): def test_invalid_package_name_mode_is_rejected(): - with pytest.raises(ValueError, match="Invalid package_name_mode 'invalid'"): - get_package_name_mode({"package_name_mode": "invalid"}) + with pytest.raises(ValidationError, match="package_name_mode"): + VincaConfig(ros_distro="humble", package_name_mode="invalid") def test_both_mode_generates_legacy_compatibility_output(): @@ -239,7 +276,10 @@ def test_both_mode_generates_legacy_compatibility_output(): output = {"package": {"name": "ros2-my-package", "version": "1.2.3"}} compatibility_output = generate_legacy_compatibility_output( - output, "my_package", distro, {"package_name_mode": "both"} + output, + "my_package", + distro, + VincaConfig(ros_distro="humble", package_name_mode="both"), ) assert compatibility_output == { @@ -270,7 +310,9 @@ def test_compatibility_output_requires_the_distribution_mutex( mutex_package, expected_mutex ): distro = make_distro() - vinca_conf = {"package_name_mode": "both", "mutex_package": mutex_package} + vinca_conf = VincaConfig( + ros_distro="humble", package_name_mode="both", mutex_package=mutex_package + ) output = {"package": {"name": "ros2-my-package", "version": "1.2.3"}} compatibility_output = generate_legacy_compatibility_output( @@ -287,12 +329,11 @@ def test_compatibility_output_requires_the_distribution_mutex( @pytest.mark.parametrize("mode", ["legacy", "new"]) def test_single_name_modes_do_not_generate_compatibility_output(mode): distro = make_distro() - output_name = get_package_name("my_package", distro, {"package_name_mode": mode}) + vinca_conf = VincaConfig(ros_distro="humble", package_name_mode=mode) + output_name = get_package_name("my_package", distro, vinca_conf) output = {"package": {"name": output_name, "version": "1.2.3"}} assert ( - generate_legacy_compatibility_output( - output, "my_package", distro, {"package_name_mode": mode} - ) + generate_legacy_compatibility_output(output, "my_package", distro, vinca_conf) is None ) diff --git a/vinca/test_package_exclusions.py b/vinca/test_package_exclusions.py index 775b31f..3600cef 100644 --- a/vinca/test_package_exclusions.py +++ b/vinca/test_package_exclusions.py @@ -1,12 +1,11 @@ """Tests for packages_exclude / packages_skip and conda_index-shadowed packages.""" -from typing import Any - import pytest import vinca.main as m from vinca.configuration import read_vinca_yaml from vinca.resolve import should_skip_pkg +from vinca.schemas import VincaConfig class FakeDistro: @@ -55,16 +54,16 @@ def _config(tmp_path, monkeypatch, body: str, platform: str = "linux-64"): def test_exclude_and_skip_drop_selected_packages(tmp_path, monkeypatch): conf = _config(tmp_path, monkeypatch, BODY) - assert conf["packages_select_by_deps"] == ["app", "viewer"] - assert conf["packages_exclude"] == ["tool"] - assert conf["packages_skip"] == ["helper"] + assert conf.packages_select_by_deps == ["app", "viewer"] + assert conf.packages_exclude == ["tool"] + assert conf.packages_skip == ["helper"] def test_exclude_under_selector_applies_on_that_platform_only(tmp_path, monkeypatch): conf = _config(tmp_path, monkeypatch, BODY, platform="win-64") - assert conf["packages_select_by_deps"] == ["app"] - assert conf["packages_exclude"] == ["tool", "viewer"] + assert conf.packages_select_by_deps == ["app"] + assert conf.packages_exclude == ["tool", "viewer"] def test_excluded_packages_are_dropped_from_dependencies(tmp_path, monkeypatch): @@ -90,12 +89,12 @@ def test_replaced_keys_still_work_with_a_warning(tmp_path, monkeypatch): conf = _config(tmp_path, monkeypatch, body) # as before: the lists don't change the selection, only traversal and dependencies - assert conf["packages_select_by_deps"] == ["app", "tool"] - assert conf["_skip_by_deps"] == ["tool", "helper"] + assert conf.packages_select_by_deps == ["app", "tool"] + assert conf.skip_by_deps == ["tool", "helper"] assert should_skip_pkg("tool", conf) assert should_skip_pkg("removed", conf) assert not should_skip_pkg("helper", conf) - assert "packages_skip_by_deps" not in conf + assert "packages_skip_by_deps" not in conf.model_dump() def test_conda_index_shadowed_ros_packages_are_not_built(): @@ -103,13 +102,12 @@ def test_conda_index_shadowed_ros_packages_are_not_built(): {"app": {"tl_expected", "libA"}}, packages={"app", "libA", "tl_expected"}, ) - conf: dict[str, Any] = { - "packages_select_by_deps": ["app", "tl_expected"], - "packages_skip": None, - "_conda_indexes": [ - {"tl_expected": {"robostack": ["cpp-expected"]}, "eigen": {}} - ], - } + conf = VincaConfig( + ros_distro="humble", + packages_select_by_deps=["app", "tl_expected"], + packages_skip=None, + conda_indexes=[{"tl_expected": {"robostack": ["cpp-expected"]}, "eigen": {}}], + ) selected = m.get_selected_packages(distro, conf) diff --git a/vinca/test_pinning.py b/vinca/test_pinning.py index 5b7658c..cb2820b 100644 --- a/vinca/test_pinning.py +++ b/vinca/test_pinning.py @@ -1,22 +1,27 @@ import io import re import tarfile -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest +import requests import ruamel.yaml from vinca.pinning import ( PinningError, - _feedstock_output_url, _migration_applies_to_platforms, + _migration_timestamp, + _status_sets, dependencies_from_recipes, dependencies_from_vinca, download_pinning_package, + get_migration_status, + package_feedstocks, render_pinning, select_completed_migrations, update_pinning, ) +from vinca.schemas import MigrationStatus, VincaConfig from vinca.variant_algebra import _selector_platforms, variant_add BASE_CONFIG = b"""\ @@ -434,11 +439,7 @@ def test_dependencies_from_vinca_restores_global_platform_configuration( patch("vinca.distro.Distro", return_value=distro), patch( "vinca.main.read_vinca_yaml", - return_value={ - "ros_distro": "rolling", - "_snapshot": {}, - "_additional_packages_snapshot": {}, - }, + return_value=VincaConfig(ros_distro="rolling"), ), patch("vinca.main.get_group_dependency_packages", return_value=[]), patch("vinca.main.get_selected_packages", return_value=[]), @@ -462,11 +463,7 @@ def test_dependencies_from_vinca_restores_global_configuration_on_error( with ( patch( "vinca.main.read_vinca_yaml", - return_value={ - "ros_distro": "rolling", - "_snapshot": {}, - "_additional_packages_snapshot": {}, - }, + return_value=VincaConfig(ros_distro="rolling"), ), patch("vinca.distro.Distro", side_effect=RuntimeError("network failure")), pytest.raises(RuntimeError, match="network failure"), @@ -488,11 +485,7 @@ def test_dependencies_from_vinca_reuses_distro_and_group_packages(tmp_path): "prefetch_additional_package_xml": lambda self: None, }, )() - config = { - "ros_distro": "rolling", - "_snapshot": {}, - "_additional_packages_snapshot": {}, - } + config = VincaConfig(ros_distro="rolling") requirements = iter( [ [{"host": ["python", "ros2-rclcpp"]}], @@ -503,7 +496,9 @@ def test_dependencies_from_vinca_reuses_distro_and_group_packages(tmp_path): with ( patch("vinca.distro.Distro", return_value=distro) as distro_class, - patch("vinca.main.read_vinca_yaml", side_effect=lambda path: dict(config)), + patch( + "vinca.main.read_vinca_yaml", side_effect=lambda path: config.model_copy() + ), patch( "vinca.main.get_group_dependency_packages", return_value=["group-package"] ) as get_groups, @@ -532,8 +527,8 @@ def test_selects_only_migrations_done_for_all_relevant_dependencies(): "paused": b"__migrator:\n paused: true\nignored:\n - 2\n", } statuses = { - "boost": {"done": ["pcl"], "in-pr": ["vtk"]}, - "urdfdom": {"done": ["pinocchio"]}, + "boost": MigrationStatus.model_validate({"done": ["pcl"], "in-pr": ["vtk"]}), + "urdfdom": MigrationStatus.model_validate({"done": ["pinocchio"]}), } with ( @@ -555,6 +550,46 @@ def test_selects_only_migrations_done_for_all_relevant_dependencies(): assert "paused" not in dict(reports) +def test_migration_status_reads_every_state(): + response = Mock(status_code=200) + response.json.return_value = { + "done": ["pcl"], + "in-pr": ["vtk"], + "awaiting-parents": ["Gazebo"], + "_feedstock_status": { + "vtk": {"pr_status": "open", "pr_url": "https://example.invalid/1"} + }, + } + + with patch("vinca.pinning.requests.get", return_value=response): + status = get_migration_status("boost") + + assert status is not None + assert _status_sets(status) == ({"pcl"}, {"pcl", "vtk", "gazebo"}) + assert status.feedstock_status["vtk"].pr_url == "https://example.invalid/1" + + +def test_migration_status_with_an_unexpected_shape_is_an_error(): + response = Mock(status_code=200) + response.json.return_value = {"done": "pcl"} + + with ( + patch("vinca.pinning.requests.get", return_value=response), + pytest.raises(PinningError, match="done"), + ): + get_migration_status("boost") + + +@pytest.mark.parametrize( + "migrator_ts,timestamp", + [("1234", 1234.0), ("2026-10-26T16:56:35+01:00", 1793030195.0), (None, -1.0)], +) +def test_migration_timestamp(migrator_ts, timestamp): + payload = b"" if migrator_ts is None else f"migrator_ts: {migrator_ts}\n".encode() + + assert _migration_timestamp(payload) == timestamp + + def test_migration_platform_selectors_are_respected(): migration = b"""\ __migrator: @@ -581,7 +616,7 @@ def test_existing_active_migration_is_kept_and_overrides_are_unchanged(tmp_path) """ ) package = (BASE_CONFIG, {"boost": BOOST_MIGRATION}) - pending_status = {"done": [], "in-pr": ["pcl"]} + pending_status = MigrationStatus.model_validate({"done": [], "in-pr": ["pcl"]}) with ( patch("vinca.pinning.download_pinning_package", return_value=package), @@ -603,14 +638,19 @@ def test_existing_active_migration_is_kept_and_overrides_are_unchanged(tmp_path) assert parsed["pinning_overrides"] == {"python": ["3.14.* *_cp314"]} +def test_package_feedstocks_are_looked_up_in_feedstock_outputs(): + with patch("vinca.pinning.package_to_feedstock", return_value=["compilers"]): + assert package_feedstocks("c-compiler") == {"compilers"} + + @pytest.mark.parametrize( - "package,shards", + "lookup", [ - ("libboost-devel", "l/i/b"), - ("r-base", "r/b/a"), - ("c-ares", "c/a/r"), - ("go", "g/o/z"), + {"side_effect": requests.HTTPError("404 Client Error")}, + {"side_effect": KeyError("feedstocks")}, + {"return_value": []}, ], ) -def test_feedstock_output_url_shards_by_alphanumeric_characters(package, shards): - assert _feedstock_output_url(package).endswith(f"/outputs/{shards}/{package}.json") +def test_package_feedstocks_fall_back_to_the_package_name(lookup): + with patch("vinca.pinning.package_to_feedstock", **lookup): + assert package_feedstocks("c-compiler") == {"c-compiler"} diff --git a/vinca/test_provenance.py b/vinca/test_provenance.py index 0433a1a..3624b1f 100644 --- a/vinca/test_provenance.py +++ b/vinca/test_provenance.py @@ -4,9 +4,8 @@ directly by the config and which already-selected packages depend on it. """ -from typing import Any - import vinca.main as m +from vinca.schemas import VincaConfig class FakeDistro: @@ -34,33 +33,35 @@ def test_provenance_requested_and_required_by(): {"app": {"libA", "libB"}, "libA": {"libcommon"}, "libB": {"libcommon"}}, ros1=True, # keep ROS2 auto-injection out of this assertion ) - conf: dict[str, Any] = { - "packages_select_by_deps": ["app", "libA"], - "packages_skip": None, - } + conf = VincaConfig( + ros_distro="humble", + packages_select_by_deps=["app", "libA"], + packages_skip=None, + ) selected = m.get_selected_packages(distro, conf) assert set(selected) == {"app", "libA", "libB", "libcommon"} - prov = conf["_pkg_provenance"] + prov = conf.pkg_provenance # both seeds are recorded as requested by config - assert prov["requested_by_config"] == {"app", "libA"} + assert prov.requested_by_config == {"app", "libA"} # libA is requested *and* depended on by app - assert prov["required_by"]["libA"] == {"app"} + assert prov.required_by["libA"] == {"app"} # transitive dep pulled in by the requested seeds - assert prov["required_by"]["libcommon"] == {"libA"} - assert prov["required_by"]["libB"] == {"app"} + assert prov.required_by["libcommon"] == {"libA"} + assert prov.required_by["libB"] == {"app"} # purely-requested package has no reverse-dep entry - assert "app" not in prov["required_by"] + assert "app" not in prov.required_by def test_provenance_skip_by_deps_excludes_package(): distro = FakeDistro({"app": {"libA", "skipme"}}, ros1=True) - conf: dict[str, Any] = { - "packages_select_by_deps": ["app"], - "packages_skip": ["skipme"], - } + conf = VincaConfig( + ros_distro="humble", + packages_select_by_deps=["app"], + packages_skip=["skipme"], + ) selected = m.get_selected_packages(distro, conf) @@ -70,17 +71,18 @@ def test_provenance_skip_by_deps_excludes_package(): def test_ros2_workspace_auto_injected_with_reason(): distro = FakeDistro({"app": set()}, ros1=False) - conf: dict[str, Any] = { - "packages_select_by_deps": ["app"], - "packages_skip": None, - } + conf = VincaConfig( + ros_distro="humble", + packages_select_by_deps=["app"], + packages_skip=None, + ) selected = m.get_selected_packages(distro, conf) assert "ros_workspace" in selected assert "ros_environment" in selected - prov = conf["_pkg_provenance"] - assert prov["required_by"]["ros_workspace"] == {"(automatic ROS2 dependency)"} + prov = conf.pkg_provenance + assert prov.required_by["ros_workspace"] == {"(automatic ROS2 dependency)"} def test_generation_summary_output(monkeypatch, capsys): @@ -88,11 +90,12 @@ def test_generation_summary_output(monkeypatch, capsys): {"app": {"libA", "libB"}, "libA": {"libcommon"}, "libB": {"libcommon"}}, ros1=True, ) - conf: dict[str, Any] = { - "packages_select_by_deps": ["app", "libA"], - "packages_skip": None, - } - conf["_selected_pkgs"] = m.get_selected_packages(distro, conf) + conf = VincaConfig( + ros_distro="humble", + packages_select_by_deps=["app", "libA"], + packages_skip=None, + ) + conf.selected_pkgs = m.get_selected_packages(distro, conf) monkeypatch.setattr( m, @@ -108,7 +111,7 @@ def test_generation_summary_output(monkeypatch, capsys): # pretend a recipe was generated for everything except libB, plus a mutex outputs = [ {"package": {"name": "ros-humble-" + s.replace("_", "-")}} - for s in conf["_selected_pkgs"] + for s in conf.selected_pkgs if s != "libB" ] outputs.append({"package": {"name": "ros-humble-ros2-mutex"}}) diff --git a/vinca/test_recipes.py b/vinca/test_recipes.py index d1bbfa5..5716279 100644 --- a/vinca/test_recipes.py +++ b/vinca/test_recipes.py @@ -1,7 +1,9 @@ import pytest +from pydantic import ValidationError from vinca import recipes from vinca.recipes import _dummy_constraint, generate_output +from vinca.schemas import DummyPackage, VincaConfig PACKAGE_XML = """ @@ -92,14 +94,13 @@ def stub_resolve(monkeypatch): def make_config(name, **overrides): config = { - "_selected_pkgs": {name}, - "_pkg_additional_info": {}, - "depmods": {}, + "ros_distro": "rolling", + "selected_pkgs": [name], "package_name_mode": "new", "build_number": 0, } config.update(overrides) - return config + return VincaConfig(**config) def build( @@ -541,7 +542,7 @@ def test_dependencies_only_returns_requirements_without_metadata(): def test_unselected_package_produces_no_output(): distro = FakeDistro({"demo": package_xml("demo")}) - config = make_config("demo", _selected_pkgs=set()) + config = make_config("demo", selected_pkgs=[]) assert generate_output("demo", config, distro, "1.2.3") is None @@ -555,7 +556,7 @@ def test_missing_release_manifest_produces_no_output(): def test_dummy_metapackage_pins_its_dependency(): output = build( "demo", - _pkg_additional_info={ + pkg_additional_info={ "demo": { "generate_dummy_package_with_run_deps": { "dep_name": "vendor-library", @@ -572,13 +573,12 @@ def test_dummy_metapackage_pins_its_dependency(): def test_dummy_constraint_uses_pin_depth_and_override_version(): version, constraint = _dummy_constraint( - { - "dep_name": "vendor-library", - "upper_bound": "x.x", - "override_version": "2.4.1", - }, + DummyPackage( + dep_name="vendor-library", + upper_bound="x.x", + override_version="2.4.1", + ), "1.0.0", - "demo_vendor", ) assert version == "2.4.1" @@ -593,8 +593,8 @@ def test_dummy_constraint_uses_pin_depth_and_override_version(): ], ) def test_dummy_constraint_validates_required_settings(config, message): - with pytest.raises(RuntimeError, match=message): - _dummy_constraint(config, "1.0.0", "demo_vendor") + with pytest.raises(ValidationError, match=message): + DummyPackage(**config) def test_python_free_output_gets_contents_guard(): diff --git a/vinca/test_skip_existing_builds.py b/vinca/test_skip_existing_builds.py index 07f3ad1..71e0fee 100644 --- a/vinca/test_skip_existing_builds.py +++ b/vinca/test_skip_existing_builds.py @@ -1,7 +1,5 @@ """Tests for skipping recipes whose exact builds are already published.""" -import shutil -import subprocess from pathlib import Path import pytest @@ -105,7 +103,6 @@ def test_remove_built_recipes_deletes_only_complete_ones(tmp_path, monkeypatch): """ -@pytest.mark.skipif(shutil.which("rattler-build") is None, reason="needs rattler-build") def test_render_outputs_gives_one_build_string_per_variant(tmp_path: Path): recipes = tmp_path / "recipes" for name, host in (("ros2-py", " - python"), ("ros2-cpp", " - zlib")): @@ -121,18 +118,28 @@ def test_render_outputs_gives_one_build_string_per_variant(tmp_path: Path): assert "py" not in rendered["ros2-cpp"][0][2] -def test_render_outputs_renders_as_a_native_build(tmp_path: Path, monkeypatch): +def test_render_outputs_renders_as_a_native_build(tmp_path: Path): # the build platform is part of the variant hash: rendered for another platform # than the one it runs on, the build strings didn't match the published ones - calls = [] + (tmp_path / "probe").mkdir() + (tmp_path / "probe" / "recipe.yaml").write_text( + """\ +package: + name: probe + version: "1.0.0" +build: + string: ${{ build_platform | replace("-", "_") }}_${{ target_platform | replace("-", "_") }} +""" + ) + + rendered = render_outputs(tmp_path, "linux-aarch64", None) + + assert rendered == {"probe": [("probe", "1.0.0", "linux_aarch64_linux_aarch64")]} - def fake_run(cmd, **_kwargs): - calls.append(cmd) - return subprocess.CompletedProcess(cmd, 0, stdout="[]", stderr="") - monkeypatch.setattr(skip_existing.subprocess, "run", fake_run) - render_outputs(tmp_path, "linux-aarch64", None) +def test_render_outputs_names_the_failing_recipe(tmp_path: Path): + (tmp_path / "broken").mkdir() + (tmp_path / "broken" / "recipe.yaml").write_text("package: [\n") - (cmd,) = calls - assert cmd[cmd.index("--target-platform") + 1] == "linux-aarch64" - assert cmd[cmd.index("--build-platform") + 1] == "linux-aarch64" + with pytest.raises(RuntimeError, match="broken"): + render_outputs(tmp_path, "linux-64", None) diff --git a/vinca/test_snapshot_metadata.py b/vinca/test_snapshot_metadata.py index 4df7d08..0c2a53f 100644 --- a/vinca/test_snapshot_metadata.py +++ b/vinca/test_snapshot_metadata.py @@ -4,6 +4,7 @@ import vinca.main as main import vinca.recipes as recipes from vinca.distro import Distro +from vinca.schemas import Snapshot, SnapshotEntry, VincaConfig, VincaYaml, validate def package_xml(name, version, build_dependency=None): @@ -31,7 +32,7 @@ def package_xml(name, version, build_dependency=None): def make_snapshot_distro(monkeypatch): - snapshot = { + raw_snapshot = { "snapshot_package": { "url": "https://github.com/example/snapshot-package-release.git", "repository": "https://github.com/example/snapshot-package.git", @@ -49,7 +50,7 @@ def make_snapshot_distro(monkeypatch): } distro = Distro.__new__(Distro) distro.distro_name = "rolling" - distro.snapshot = snapshot + distro.snapshot = validate(Snapshot, raw_snapshot, "rosdistro_snapshot.yaml") distro.additional_packages_snapshot = None distro.build_packages = set() distro._distribution_type = "ros2" @@ -156,7 +157,7 @@ def test_snapshot_without_dependencies_falls_back_to_rosdistro_cache( monkeypatch, capsys ): distro = make_snapshot_distro(monkeypatch) - del distro.snapshot["snapshot_package"]["dependencies"] + distro.snapshot["snapshot_package"].dependencies = None distro._walker = Mock() distro._walker.get_depends.side_effect = ( lambda pkg, dependency_type, ros_packages_only: ( @@ -183,12 +184,11 @@ def test_snapshot_metadata_generates_dependency_required_by_pinned_source( "resolve_pkgname", lambda name, *_args, **_kwargs: [dependency_names[name]], ) - config = { - "_selected_pkgs": {"snapshot_package"}, - "_pkg_additional_info": {}, - "depmods": {}, - "package_name_mode": "new", - } + config = VincaConfig( + ros_distro="rolling", + selected_pkgs=["snapshot_package"], + package_name_mode="new", + ) output = main.generate_output( "snapshot_package", @@ -273,11 +273,14 @@ def test_additional_package_repository_must_be_explicit(): distro = Distro.__new__(Distro) distro.snapshot = None distro.additional_packages_snapshot = { - "explicit": { - "url": "https://github.com/example/explicit-release.git", - "repository": "https://github.com/example/explicit.git", - }, - "source_only": {"url": "https://github.com/example/source-only.git"}, + "explicit": SnapshotEntry( + url="https://github.com/example/explicit-release.git", + version="1.0.0", + repository="https://github.com/example/explicit.git", + ), + "source_only": SnapshotEntry( + url="https://github.com/example/source-only.git", version="1.0.0" + ), } assert ( @@ -334,23 +337,31 @@ def test_read_snapshot_merges_additional_packages(tmp_path, monkeypatch): snapshot_path.write_text( """\ snapshot_package: + url: https://github.com/example/snapshot-package-release.git version: 1.0.0 """ ) additional_path.write_text( """\ additional_package: + url: https://github.com/example/additional-package.git version: 2.0.0 """ ) monkeypatch.chdir(tmp_path) snapshot, additional = main.read_snapshot( - { - "rosdistro_snapshot": snapshot_path.name, - "rosdistro_additional_recipes": additional_path.name, - } + VincaYaml( + ros_distro="rolling", + rosdistro_snapshot=snapshot_path.name, + rosdistro_additional_recipes=additional_path.name, + ) ) + assert snapshot is not None assert set(snapshot) == {"snapshot_package", "additional_package"} - assert additional == {"additional_package": {"version": "2.0.0"}} + assert additional == { + "additional_package": SnapshotEntry( + url="https://github.com/example/additional-package.git", version="2.0.0" + ) + } diff --git a/vinca/test_sources.py b/vinca/test_sources.py index e61d73a..72211b0 100644 --- a/vinca/test_sources.py +++ b/vinca/test_sources.py @@ -1,3 +1,4 @@ +from vinca.schemas import VincaConfig from vinca.sources import _recipe_patch_paths, generate_source, source_reference @@ -35,20 +36,17 @@ def test_generate_source_combines_generic_and_platform_patches(tmp_path, monkeyp generic.parent.mkdir() generic.touch() platform_patch.touch() - config = { - "_selected_pkgs": ["demo"], - "_conda_indexes": [], - "_pkg_additional_info": {}, - "_patches": { + config = VincaConfig( + selected_pkgs=["demo"], + patches={ "ros-humble-demo": { "any": [str(generic)], "linux": [str(platform_patch)], } }, - "ros_distro": "humble", - "package_name_mode": "legacy", - "skip_built_packages": [], - } + ros_distro="humble", + package_name_mode="legacy", + ) sources = generate_source(FakeDistro(), config, "linux-64") @@ -72,14 +70,11 @@ def test_recipe_refers_to_patches_next_to_it(): def test_generate_source_skips_already_built_package(): - config = { - "_selected_pkgs": ["demo"], - "_conda_indexes": [], - "_pkg_additional_info": {}, - "_patches": {}, - "ros_distro": "humble", - "package_name_mode": "legacy", - "skip_built_packages": ["ros-humble-demo"], - } + config = VincaConfig( + selected_pkgs=["demo"], + ros_distro="humble", + package_name_mode="legacy", + skip_built_packages={"ros-humble-demo"}, + ) assert generate_source(FakeDistro(), config, "linux-64") == {} diff --git a/vinca/test_template.py b/vinca/test_template.py index 7925529..d86ce54 100644 --- a/vinca/test_template.py +++ b/vinca/test_template.py @@ -2,6 +2,7 @@ from ruamel.yaml import YAML +from vinca.schemas import VincaConfig from vinca.template import write_recipe @@ -14,17 +15,13 @@ def get_legacy_package_prefix(self): def config(variants_mode): - return { - "ros_distro": "jazzy", - "package_name_mode": "legacy", - "variants_mode": variants_mode, - "build_number": 0, - "_tests": {}, - "_test_folders": {}, - "_additional_packages_snapshot": {}, - "_pkg_additional_info": {}, - "_variant_config": {"python": ["3.12"], "unused": ["1"]}, - } + return VincaConfig( + ros_distro="jazzy", + package_name_mode="legacy", + variants_mode=variants_mode, + build_number=0, + variant_config={"python": ["3.12"], "unused": ["1"]}, + ) def output(): @@ -49,7 +46,7 @@ def test_write_recipe_appends_test_file_to_generated_tests(tmp_path, monkeypatch test_file = tmp_path / "ros-jazzy-demo.yaml" test_file.write_text("tests:\n - script:\n - demo --help\n") conf = config("global") - conf["_tests"] = {"ros-jazzy-demo": test_file} + conf.tests = {"ros-jazzy-demo": test_file} generated = {"package_contents": {"files": {"not_exists": ["**/*.pyd"]}}} write_recipe( diff --git a/vinca/test_utils.py b/vinca/test_utils.py index 2cf2900..0a46273 100644 --- a/vinca/test_utils.py +++ b/vinca/test_utils.py @@ -8,6 +8,7 @@ from rattler.exceptions import FetchRepoDataError from vinca import utils +from vinca.schemas import PkgAdditionalInfo, VincaConfig from vinca.utils import ( CyclicTestRequirement, add_test_requirements, @@ -175,13 +176,13 @@ def test_get_channel_records_raises_when_the_channel_is_unreachable(channel): get_channel_records(f"http://127.0.0.1:{port}/", "linux-64") -ADDITIONAL_INFO_CONF = { - "ros_distro": "humble", - "_pkg_additional_info": { +ADDITIONAL_INFO_CONF = VincaConfig( + ros_distro="humble", + pkg_additional_info={ "ros2_pulse": {"build_number": 7, "additional_cmake_args": "-DX=1"}, "rtabmap_odom": {"build_number": 5}, }, -} +) @pytest.mark.parametrize( @@ -189,10 +190,9 @@ def test_get_channel_records_raises_when_the_channel_is_unreachable(channel): ) def test_additional_info_of_a_ros2_prefixed_package(name): # a ROS name starting with ros2_/ros_ was normalized twice (ros2_pulse -> pulse) - assert get_pkg_additional_info(name, ADDITIONAL_INFO_CONF) == { - "build_number": 7, - "additional_cmake_args": "-DX=1", - } + assert get_pkg_additional_info(name, ADDITIONAL_INFO_CONF) == PkgAdditionalInfo( + build_number=7, additional_cmake_args="-DX=1" + ) assert get_pkg_build_number(0, name, ADDITIONAL_INFO_CONF) == 7 @@ -204,5 +204,11 @@ def test_additional_info_by_ros_or_conda_name(name): def test_additional_info_of_a_package_without_an_entry(): - assert get_pkg_additional_info("ros2-rclcpp", ADDITIONAL_INFO_CONF) == {} - assert get_pkg_additional_info("rclcpp", {"_pkg_additional_info": None}) == {} + assert ( + get_pkg_additional_info("ros2-rclcpp", ADDITIONAL_INFO_CONF) + == PkgAdditionalInfo() + ) + assert ( + get_pkg_additional_info("rclcpp", VincaConfig(ros_distro="humble")) + == PkgAdditionalInfo() + ) diff --git a/vinca/test_variants.py b/vinca/test_variants.py index 4a47f10..74d4756 100644 --- a/vinca/test_variants.py +++ b/vinca/test_variants.py @@ -1,3 +1,4 @@ +from vinca.schemas import VincaConfig from vinca.variants import get_recipe_variants @@ -12,8 +13,9 @@ def test_get_recipe_variants_prunes_to_recipe_dependencies_and_expressions(): "script": "echo ${{ cxx_compiler_version }}", }, } - config = { - "_variant_config": { + config = VincaConfig( + ros_distro="humble", + variant_config={ "python": ["3.12.* *_cpython"], "python_impl": ["cpython"], "libfoo": ["2"], @@ -30,8 +32,7 @@ def test_get_recipe_variants_prunes_to_recipe_dependencies_and_expressions(): "unused": {"max_pin": "x"}, }, }, - "_pkg_additional_info": {}, - } + ) assert get_recipe_variants(recipe, "demo", config) == { "python": ["3.12.* *_cpython"], @@ -45,12 +46,13 @@ def test_get_recipe_variants_prunes_to_recipe_dependencies_and_expressions(): def test_get_recipe_variants_merges_and_keeps_package_overrides(): - config = { - "_variant_config": { + config = VincaConfig( + ros_distro="humble", + variant_config={ "python": ["3.12.* *_cpython"], "unused": ["1"], }, - "_pkg_additional_info": { + pkg_additional_info={ "demo": { "variant_overrides": { "python": ["3.13.* *_cpython"], @@ -58,7 +60,7 @@ def test_get_recipe_variants_merges_and_keeps_package_overrides(): } } }, - } + ) assert get_recipe_variants( {"requirements": {"host": ["python"]}}, "demo", config @@ -69,15 +71,15 @@ def test_get_recipe_variants_merges_and_keeps_package_overrides(): def test_get_recipe_variants_trims_zip_keys_to_used_members(): - config = { - "_variant_config": { + config = VincaConfig( + ros_distro="humble", + variant_config={ "python": ["3.12", "3.13"], "numpy": ["1", "2"], "python_impl": ["cpython", "cpython"], "zip_keys": [["python", "numpy", "python_impl"]], }, - "_pkg_additional_info": {}, - } + ) assert get_recipe_variants( {"requirements": {"host": ["python", "numpy"]}}, "demo", config @@ -89,13 +91,13 @@ def test_get_recipe_variants_trims_zip_keys_to_used_members(): def test_get_recipe_variants_uses_test_requirements_not_test_commands(): - config = { - "_variant_config": { + config = VincaConfig( + ros_distro="humble", + variant_config={ "python": ["3.12"], "pytest": ["8"], }, - "_pkg_additional_info": {}, - } + ) assert get_recipe_variants( { diff --git a/vinca/utils.py b/vinca/utils.py index 57b6f04..914ccb0 100644 --- a/vinca/utils.py +++ b/vinca/utils.py @@ -9,6 +9,8 @@ from rattler.exceptions import FetchRepoDataError from ruamel.yaml.representer import SafeRepresenter +from vinca.schemas import PkgAdditionalInfo, VincaConfig, VincaYaml + REPODATA_CACHE = Path(tempfile.gettempdir()) / "vinca-repodata" @@ -79,13 +81,12 @@ def get_channel_records(channel, platform): return [json.loads(record.to_json()) for record in records] -def ensure_name_is_without_distro_prefix_and_with_underscores(name, vinca_conf): +def ensure_name_is_without_distro_prefix_and_with_underscores( + name: str, vinca_conf: VincaYaml +) -> str: """Normalize new and legacy ROS package names to their ROS package name.""" newname = name.replace("-", "_") - prefixes = ["ros2_"] - if ros_distro := vinca_conf.get("ros_distro"): - prefixes.append(f"ros_{ros_distro}_") - prefixes.append("ros_") + prefixes = ["ros2_", f"ros_{vinca_conf.ros_distro}_", "ros_"] for prefix in prefixes: if newname.startswith(prefix): @@ -212,32 +213,31 @@ def add_package_name_variants(entries, ros_distro) -> None: entries.setdefault(variant, value) -def get_pkg_additional_info(pkg_name, vinca_conf): +def get_pkg_additional_info( + pkg_name: str, vinca_conf: VincaConfig +) -> PkgAdditionalInfo: """The pkg_additional_info.yaml entry of a package, given its ROS name (ros2_pulse) or its conda name (ros2-ros2-pulse, ros-humble-ros2-pulse). A ROS name is used as it is: normalizing it again would strip a ROS name's own ros2_/ros_ prefix.""" - pkg_additional_info_all = vinca_conf.get("_pkg_additional_info") or {} + pkg_additional_info_all = vinca_conf.pkg_additional_info name = pkg_name.replace("-", "_") if name not in pkg_additional_info_all: name = ensure_name_is_without_distro_prefix_and_with_underscores( pkg_name, vinca_conf ) - return pkg_additional_info_all.get(name, {}) + return pkg_additional_info_all.get(name, PkgAdditionalInfo()) -def get_pkg_build_number(default_build_number, pkg_name, vinca_conf): - pkg_additional_info = get_pkg_additional_info(pkg_name, vinca_conf) - return pkg_additional_info.get("build_number", default_build_number) +def get_pkg_build_number(default_build_number, pkg_name: str, vinca_conf: VincaConfig): + build_number = get_pkg_additional_info(pkg_name, vinca_conf).build_number + return default_build_number if build_number is None else build_number # Return true if the package is actually provided in conda-forge, and so we generate # only a recipe with a run dependency on the conda forge package -def is_dummy_metapackage(pkg_name, vinca_conf) -> bool: +def is_dummy_metapackage(pkg_name: str, vinca_conf: VincaConfig) -> bool: pkg_additional_info = get_pkg_additional_info(pkg_name, vinca_conf) - if pkg_additional_info.get("generate_dummy_package_with_run_deps"): - return True - else: - return False + return pkg_additional_info.generate_dummy_package_with_run_deps is not None def built_for_distro(pkg: dict, ros_distro: str) -> bool: diff --git a/vinca/variant_algebra.py b/vinca/variant_algebra.py index b02eea9..33dfa9e 100644 --- a/vinca/variant_algebra.py +++ b/vinca/variant_algebra.py @@ -16,6 +16,8 @@ from rattler.exceptions import InvalidVersionError from ruamel.yaml.comments import CommentedSeq +from vinca.schemas import MigrationFile, Migrator, validate + _SELECTOR_RE = re.compile(r"#\s*\[(.+)]") _PLATFORMS = { "linux-64", @@ -226,16 +228,22 @@ def _set_union(left: Any, right: Any, ordering: Any = None) -> list[Any]: return sorted(values, key=lambda value: _version_order(value, ordering)) +def _single_primary_key(migrator: Migrator) -> str: + if not isinstance(migrator.primary_key, str): + raise ValueError(f"{migrator.operation} migrations need one primary_key") + return migrator.primary_key + + def _key_add_operation( - left: MutableMapping[str, Any], right: MutableMapping[str, Any] + left: MutableMapping[str, Any], right: MutableMapping[str, Any], migrator: Migrator ) -> MutableMapping[str, Any]: """Apply CFEP-9 ``key_add``, preserving aligned zip-key value tuples. Raises when the declared primary or required zip keys cannot be merged safely. """ - primary_key = right["__migrator"]["primary_key"] - additional_zip_keys = right["__migrator"].get("additional_zip_keys", []) - ordering = right["__migrator"].get("ordering", {}) + primary_key = _single_primary_key(migrator) + additional_zip_keys = migrator.additional_zip_keys + ordering = migrator.ordering if primary_key not in right: return left if primary_key not in left: @@ -299,14 +307,14 @@ def _key_add_operation( def _key_remove_operation( - left: MutableMapping[str, Any], right: MutableMapping[str, Any] + left: MutableMapping[str, Any], right: MutableMapping[str, Any], migrator: Migrator ) -> MutableMapping[str, Any]: """Apply CFEP-9 ``key_remove`` and remove corresponding zipped values. Raises if the migration supplies anything other than one primary value. """ - primary_key = right["__migrator"]["primary_key"] - ordering = right["__migrator"].get("ordering", {}) + primary_key = _single_primary_key(migrator) + ordering = migrator.ordering if primary_key not in right or primary_key not in left: return left if len(right[primary_key]) != 1: @@ -354,16 +362,19 @@ def variant_add( left: MutableMapping[str, Any], right: MutableMapping[str, Any] ) -> MutableMapping[str, Any]: """Combine variants using CFEP-9 operations, zip alignment, ordering, and selectors.""" - operation = (right.get("__migrator") or {}).get("operation") + migrator = validate( + MigrationFile, {"__migrator": right.get("__migrator")}, "__migrator" + ).migrator + operation = migrator.operation if operation == "key_add": - return _key_add_operation(left, right) + return _key_add_operation(left, right, migrator) if operation == "key_remove": - return _key_remove_operation(left, right) + return _key_remove_operation(left, right, migrator) if operation: raise ValueError(f"Unknown migration operation: {operation}") - ordering = (right.get("__migrator") or {}).get("ordering", {}) - primary_keys = _ensure_list((right.get("__migrator") or {}).get("primary_key", [])) + ordering = migrator.ordering + primary_keys = _ensure_list(migrator.primary_key or []) result = deepcopy(left) right_keys = {key for key in right if key != "__migrator" and key != "migrator_ts"} diff --git a/vinca/variants.py b/vinca/variants.py index 4f98e14..ed5d7b5 100644 --- a/vinca/variants.py +++ b/vinca/variants.py @@ -4,28 +4,10 @@ import copy import re -from enum import Enum from typing import Any, Mapping, Sequence - -class VariantsMode(Enum): - """How generated recipes consume the repository-wide pinning configuration.""" - - GLOBAL = "global" - LOCAL = "local" - - -def get_variants_mode(vinca_conf: Mapping[str, Any]) -> VariantsMode: - """Return the configured variants mode, defaulting to the global mode.""" - value = vinca_conf.get("variants_mode", VariantsMode.GLOBAL.value) - try: - return VariantsMode(value) - except ValueError as exc: - choices = ", ".join(mode.value for mode in VariantsMode) - raise ValueError( - f"Invalid variants_mode {value!r}; expected one of: {choices}" - ) from exc - +from vinca.schemas import VariantsMode as VariantsMode +from vinca.schemas import VincaConfig _SELECTOR_RE = re.compile(r"#\s*\[(?P[^]]+)]") _COMPILER_RE = re.compile(r"\bcompiler\(\s*['\"](?P[^'\"]+)['\"]\s*\)") @@ -180,15 +162,12 @@ def _prune_zip_keys(groups: Any, used: set[str]) -> list[list[str]]: def get_recipe_variants( recipe: Mapping[str, Any], package_name: str, - vinca_conf: Mapping[str, Any], + vinca_conf: VincaConfig, ) -> dict[str, Any]: """Return a v1, package-local subset of the repository variant config.""" - variants = copy.deepcopy(vinca_conf.get("_variant_config") or {}) - overrides = copy.deepcopy( - vinca_conf.get("_pkg_additional_info", {}) - .get(package_name, {}) - .get("variant_overrides", {}) - ) + variants = copy.deepcopy(vinca_conf.variant_config) + package_info = vinca_conf.pkg_additional_info.get(package_name) + overrides = copy.deepcopy(package_info.variant_overrides if package_info else {}) variants.update(overrides) used = _used_variant_keys(recipe, variants) | set(overrides)