diff --git a/.github/workflows/testpr.yaml b/.github/workflows/testpr.yaml index c93483fdf..c7c2f6122 100644 --- a/.github/workflows/testpr.yaml +++ b/.github/workflows/testpr.yaml @@ -154,8 +154,9 @@ jobs: PLATFORM: ${{ matrix.target.platform }} # while uploads go to a test channel, packages already there aren't built again ROBOSTACK_UPLOAD_CHANNEL: ${{ vars.ROBOSTACK_UPLOAD_CHANNEL }} - # rattler-build writes to distros//work/output, or to CONDA_BLD_PATH (win-64) - CACHE_DIR: ${{ matrix.target.platform == 'win-64' && 'D:/bld/win-64' || format('distros/{0}/work/output/{1}', matrix.distro, matrix.target.platform) }} + # rattler-build writes to distros//work/output, or to CONDA_BLD_PATH (win-64); its + # / and noarch/ (e.g. the compatibility packages) are the build cache + OUTPUT_DIR: ${{ matrix.target.platform == 'win-64' && 'D:/bld' || format('distros/{0}/work/output', matrix.distro) }} steps: - uses: actions/checkout@v6 with: @@ -226,7 +227,9 @@ jobs: - name: Restore build cache uses: actions/cache/restore@v5 with: - path: ${{ env.CACHE_DIR }} + path: | + ${{ env.OUTPUT_DIR }}/${{ matrix.target.platform }} + ${{ env.OUTPUT_DIR }}/noarch key: ${{ runner.os }}-${{ matrix.distro }}-${{ matrix.target.platform }}-pr-${{ github.event.pull_request.number }} restore-keys: | ${{ runner.os }}-${{ matrix.distro }}-${{ matrix.target.platform }}-pr-${{ github.event.pull_request.number }}- @@ -235,21 +238,21 @@ jobs: - name: Apply ci.yaml cache controls shell: bash -l {0} run: | - pixi run python tools/ci_config.py --cache-dir "$CACHE_DIR" --config "distros/$DISTRO/ci.yaml" --vinca "distros/$DISTRO/vinca.yaml" - mkdir -p "$CACHE_DIR" - pixi run rattler-index fs "$CACHE_DIR/.." --force + pixi run python tools/ci_config.py --output-dir "$OUTPUT_DIR" --platform "$PLATFORM" --config "distros/$DISTRO/ci.yaml" --vinca "distros/$DISTRO/vinca.yaml" + mkdir -p "$OUTPUT_DIR/$PLATFORM" + pixi run rattler-index fs "$OUTPUT_DIR" --force - name: See packages restored by cache shell: bash -l {0} run: | shopt -s nullglob - packages=("$CACHE_DIR"/*.conda) + packages=("$OUTPUT_DIR/$PLATFORM"/*.conda "$OUTPUT_DIR"/noarch/*.conda) echo "${#packages[@]} packages restored from the cache" for package in "${packages[@]}"; do basename "$package"; done - name: Estimate the build time shell: bash -l {0} - run: pixi run python tools/ci_estimate.py --recipes "distros/$DISTRO/work/recipes" --cache-dir "$CACHE_DIR" --platform "$PLATFORM" + run: pixi run python tools/ci_estimate.py --recipes "distros/$DISTRO/work/recipes" --output-dir "$OUTPUT_DIR" --platform "$PLATFORM" - name: Build recipes id: build-recipes @@ -266,7 +269,7 @@ jobs: [ "$PLATFORM" != "win-64" ] && extra+=(--parallel-builds "3:$(getconf _NPROCESSORS_ONLN)") build=(python tools/robostack.py "$DISTRO" build --target-platform "$PLATFORM" "${extra[@]}") if [ "$PLATFORM" != "win-64" ]; then - build=(python tools/ci_build.py --cache-dir "$CACHE_DIR" --timeout 19800 -- "${build[@]}") + build=(python tools/ci_build.py --output-dir "$OUTPUT_DIR" --platform "$PLATFORM" --timeout 19800 -- "${build[@]}") fi pixi run "${build[@]}" 2>&1 | tee rattler-build.log status=${PIPESTATUS[0]} @@ -288,7 +291,7 @@ jobs: continue-on-error: true shell: bash -l {0} run: | - pixi run abi-check --distro "$DISTRO" --platform "$PLATFORM" --pr-builds "$CACHE_DIR" --summary "abi-check-$DISTRO.md" + pixi run abi-check --distro "$DISTRO" --platform "$PLATFORM" --pr-builds "$OUTPUT_DIR/$PLATFORM" --summary "abi-check-$DISTRO.md" cat "abi-check-$DISTRO.md" >> "$GITHUB_STEP_SUMMARY" - name: Upload the ABI check @@ -313,7 +316,7 @@ jobs: if: ${{ always() && runner.os == 'Windows' && steps.build-recipes.outcome == 'cancelled' }} shell: bash -l {0} run: | - newest=$(ls -t "$CACHE_DIR"/*.conda 2>/dev/null | head -1) + newest=$(ls -t "$OUTPUT_DIR/$PLATFORM"/*.conda "$OUTPUT_DIR"/noarch/*.conda 2>/dev/null | head -1) if [ -n "$newest" ]; then echo "Removing $newest"; rm -f "$newest"; fi - name: Save build cache @@ -321,7 +324,9 @@ jobs: if: ${{ always() && contains(fromJSON('["success","failure","cancelled"]'), steps.build-recipes.outcome) && (runner.os == 'Windows' || steps.build-recipes.outputs.cache-ready == 'true') }} uses: actions/cache/save@v5 with: - path: ${{ env.CACHE_DIR }} + path: | + ${{ env.OUTPUT_DIR }}/${{ matrix.target.platform }} + ${{ env.OUTPUT_DIR }}/noarch key: ${{ runner.os }}-${{ matrix.distro }}-${{ matrix.target.platform }}-pr-${{ github.event.pull_request.number }}-${{ github.run_id }}-${{ github.run_attempt }} # Only the newest cache of a pull request/distribution/platform is restored; the ones diff --git a/.scripts/build_unix.sh b/.scripts/build_unix.sh index 12c548dac..9f559906d 100755 --- a/.scripts/build_unix.sh +++ b/.scripts/build_unix.sh @@ -40,7 +40,13 @@ pixi run -v rs "${ROBOSTACK_DISTRO}" build-ci "${recipe_args[@]}" --output-dir $ # Check if it build something, this is a hotfix for the skips inside additional_recipes shopt -s nullglob -conda_packages=( "${CONDA_BLD_PATH}/${target}"*/*.conda ) +if [[ -n "$target" ]]; then + # noarch packages (e.g. the compatibility packages) are uploaded from every platform; + # the first upload wins and the others skip + conda_packages=( "${CONDA_BLD_PATH}/${target}"*/*.conda "${CONDA_BLD_PATH}/noarch/"*.conda ) +else + conda_packages=( "${CONDA_BLD_PATH}/"*/*.conda ) +fi if (( ${#conda_packages[@]} > 0 )); then # Upload packages one-by-one; the upload task (tools/robostack.py) uploads them to # prefix.dev and anaconda.org and skips packages that are already there. diff --git a/.scripts/build_win.bat b/.scripts/build_win.bat index 97b29739d..e34056132 100644 --- a/.scripts/build_win.bat +++ b/.scripts/build_win.bat @@ -37,17 +37,19 @@ for %%X in (%CURRENT_RECIPES%) do set "RECIPE_ARGS=!RECIPE_ARGS! --recipe %FEEDS pixi run -v rs %ROBOSTACK_DISTRO% build-ci !RECIPE_ARGS! --output-dir %CONDA_BLD_PATH% if errorlevel 1 exit 1 -:: Check if .conda files exist in the win-64 directory -if exist "%CONDA_BLD_PATH%\win-64\*.conda" ( - echo Found .conda files, starting upload... - rem Upload packages one-by-one; the upload task (tools/robostack.py) uploads them to - rem prefix.dev and anaconda.org and skips packages that are already there. - for %%F in ("%CONDA_BLD_PATH%\win-64\*.conda") do ( +:: Upload the win-64 and noarch packages one-by-one; the upload task (tools/robostack.py) +:: uploads them to prefix.dev and anaconda.org and skips packages that are already there +:: (noarch ones, e.g. the compatibility packages, are uploaded from every platform). +set FOUND=0 +for %%D in (win-64 noarch) do ( + for %%F in ("%CONDA_BLD_PATH%\%%D\*.conda") do ( + set FOUND=1 echo Uploading %%~fF pixi run rs %ROBOSTACK_DISTRO% upload "%%~fF" if errorlevel 1 exit 1 ) -) else ( - echo Warning: No .conda files found in %CONDA_BLD_PATH%\win-64 +) +if "!FOUND!" == "0" ( + echo Warning: No .conda files found in %CONDA_BLD_PATH%\win-64 or %CONDA_BLD_PATH%\noarch echo This might be due to all the packages being skipped ) diff --git a/AGENTS.md b/AGENTS.md index 8e51076a9..cb1111f06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,10 @@ to read without Claude): into `distros//`. - Run everything per distribution with `pixi run rs `, from the repository root. Package names are `ros2-` (dashes), e.g. `ros2-rsl`. +- lyrical builds packages with Python content for Python 3.12-3.14 and the others once + (`conditional_python_dependency`, see README "Python versions"). A built-once package + that fails its `package_contents` test ships Python after all: add `python` to its + `add_host` in `patch/dependencies.yaml`. - Never edit `conda_build_config.yaml` by hand: change `shared/pinning/` or the distribution's `distro.yaml`, then `pixi run rs render-pinning` (CI checks it). - A change outside `distros/` rebuilds every distribution in CI; keep such PRs focused. @@ -51,6 +55,7 @@ pixi run rs build --continue-on-failure # everything missing on the chan pixi run rs check-patches [--recipe ros2-] pixi run rs check-deps # pin conflicts, before building anything pixi run rs gap-report # recipes without artifacts and vice versa +pixi run rs python-audit [--elf] # per-Python vs built-once recipes against the published packages pixi run rs add-package ... [...] # select packages (as the bot does) pixi run rs check # sanity checks (CI) ``` diff --git a/README.md b/README.md index c3825a728..a77afcf7a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ distribution comes from the channel. The older names keep working: for every pac there is also a small `ros--` package that just depends on `ros2-` of the same version (vinca's `package_name_mode: both` in `shared/vinca.yaml`), so existing environments and instructions such as -`pixi add ros-jazzy-desktop` don't break. +`pixi add ros-jazzy-desktop` don't break. These are `noarch` packages, built once for +all platforms: each platform's build uploads them, and whichever comes first wins. **A package is missing?** Open a *Package request* issue. robostack-bot opens a pull request that adds it to the build, and a maintainer reviews it. @@ -106,6 +107,16 @@ pixi run rs check # sanity checks (also run in CI) of a distribution uses `full_rebuild: true` there. A new build number goes into its `pkg_additional_info.yaml` (one package) or `vinca.yaml` (everything). +**Python versions:** a distribution's `pinning_overrides` can list several Pythons, as +lyrical does for 3.12-3.14. With `conditional_python_dependency: true` in its +`vinca.yaml`, only packages with Python content (Python modules, rosidl Python bindings, +extensions) get python in host/run and are built once per Python; the others are built +once, without a `python_abi` dependency, and fail their build if they ship +`site-packages` content or a CPython extension anyway. When vinca's guess from +`package.xml` is wrong, add `python` to the package's `add_host` (or `remove_host`) in +`patch/dependencies.yaml`. `pixi run rs python-audit [--elf]` compares the +guess with the published packages and lists the misses. + **Building only some distributions in a PR:** a PR builds the distributions it changes, and all of them as soon as it touches anything outside `distros//`. To limit that (e.g. a humble rebuild that also changes `shared/` or `tools/`), add the label diff --git a/distros/lyrical/conda_build_config.yaml b/distros/lyrical/conda_build_config.yaml index f95422a34..65159c500 100644 --- a/distros/lyrical/conda_build_config.yaml +++ b/distros/lyrical/conda_build_config.yaml @@ -1000,8 +1000,9 @@ pythia8: python: # conda-forge supports only 3.14+ for win-arm64 and linux-riscv64 # part of a zip_keys: python, is_python_min + - 3.12.* *_cpython # [not ((win and arm64) or riscv64)] + - 3.13.* *_cp313 # [not ((win and arm64) or riscv64)] - 3.14.* *_cp314 - - 3.12.* *_cpython python_impl: - cpython @@ -1009,14 +1010,16 @@ python_impl: python_min: # minimum supported python version per CFEP-25 # bump to next minor version when we drop python versions - - '3.11' # [not ((win and arm64) or riscv64)] + - '3.12' # [not ((win and arm64) or riscv64)] - '3.14' # [(win and arm64) or riscv64] is_freethreading: - false is_python_min: # part of a zip_keys: python, is_python_min - - false - - false + - true # [not ((win and arm64) or riscv64)] + - false # [not ((win and arm64) or riscv64)] + - false # [not ((win and arm64) or riscv64)] + - true # [(win and arm64) or riscv64] is_abi3: - true pytorch: diff --git a/distros/lyrical/distro.yaml b/distros/lyrical/distro.yaml index 33b9e0ddf..b9f2ed47d 100644 --- a/distros/lyrical/distro.yaml +++ b/distros/lyrical/distro.yaml @@ -7,10 +7,21 @@ upload_to: [prefix, anaconda] read_from: prefix rosdistro_sync: lyrical/2026-10-05 pinning_overrides: - # build every python package for 3.14 (the shared default) and 3.12; - # is_python_min is zipped with python, so it needs one entry per version - python: [3.14.* *_cp314, 3.12.* *_cpython] - is_python_min: [false, false] + # Lyrical supports Python 3.12-3.14 (REP 2000). Packages with Python content are + # built for each (vinca.yaml: conditional_python_dependency), the others once. + # conda-forge has only 3.14+ for win-arm64 and linux-riscv64, so those get one variant. + python: + - 3.12.* *_cpython # [not ((win and arm64) or riscv64)] + - 3.13.* *_cp313 # [not ((win and arm64) or riscv64)] + - 3.14.* *_cp314 + is_python_min: + - true # [not ((win and arm64) or riscv64)] + - false # [not ((win and arm64) or riscv64)] + - false # [not ((win and arm64) or riscv64)] + - true # [(win and arm64) or riscv64] + python_min: + - '3.12' # [not ((win and arm64) or riscv64)] + - '3.14' # [(win and arm64) or riscv64] # every pyqt6 6.11.0 build on conda-forge needs qt6-main <=6.11.2, but the shared # `6.11` pin resolves to 6.11.3, whose run export (>=6.11.3) rules pyqt6 out: the # rqt packages then can't be installed with python_qt_binding. The published diff --git a/distros/lyrical/vinca.yaml b/distros/lyrical/vinca.yaml index 15070a041..2306e66a9 100644 --- a/distros/lyrical/vinca.yaml +++ b/distros/lyrical/vinca.yaml @@ -3,7 +3,12 @@ ros_distro: lyrical patch_dir: patch rosdistro_snapshot: rosdistro_snapshot.yaml rosdistro_additional_recipes: rosdistro_additional_recipes.yaml -build_number: 30 +build_number: 31 +# Only packages with Python content get python in host/run, and with it the Python +# variants of distro.yaml (cp312-cp314); the others are built once, without a +# python_abi dependency. Override the classification per package with python in +# add_host (or remove_host) in patch/dependencies.yaml. +conditional_python_dependency: true mutex_package: version: 0.22.0 diff --git a/pixi.lock b/pixi.lock index ab0edb4f3..c8f90e8ec 100644 --- a/pixi.lock +++ b/pixi.lock @@ -68,6 +68,7 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/ncurses-6.6-hdb14827_1.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/python-3.14.7-hcd007b5_106_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.80.0-hf01adef_0.conda @@ -77,28 +78,29 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda - conda: https://prefix.dev/conda-forge/linux-64/yaml-0.2.5-hebe6cf0_3.conda - conda: https://prefix.dev/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda + - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://prefix.dev/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/pytest-9.1.1-pyhc364b38_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-9_cp314.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-81.0.0-pyh332efcf_0.conda - conda: https://prefix.dev/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: git+https://github.com/RoboStack/vinca.git?rev=74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5#74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5 + - pypi: git+https://github.com/EzraBrooks/vinca.git?rev=ec69ef82c330e52496bb11a60004a9d06a80ae0b#ec69ef82c330e52496bb11a60004a9d06a80ae0b - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/63/327d25d91af32209b394abe5626706c4b7ad5a4812cc464a4e3a2be6916f/rospkg-1.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/35/79/5e2cffa1c77432f11cd93a5351f30732c997a239d3a3090856a72d6d8ba7/ruamel.yaml-0.17.40-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/95/88ed47cb7da88569a78b7d6fb9420298df7e99997810c844a924d96d3c08/empy-3.3.4.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/53/b5e80bac66d0db921ddbbe3c8d37198455c19302a29da1a434708c35c79d/catkin_pkg-1.1.1-py3-none-any.whl @@ -113,6 +115,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl linux-aarch64-glibc-2-17: @@ -148,6 +151,7 @@ environments: - conda: https://prefix.dev/conda-forge/linux-aarch64/ncurses-6.6-h2b6f883_1.conda - conda: https://prefix.dev/conda-forge/linux-aarch64/openssl-3.6.5-he6ad1d5_0.conda - conda: https://prefix.dev/conda-forge/linux-aarch64/patchelf-0.19.2-hfb11796_0.conda + - conda: https://prefix.dev/conda-forge/linux-aarch64/py-rattler-0.28.1-py311h40f29f8_0.conda - conda: https://prefix.dev/conda-forge/linux-aarch64/python-3.14.7-hbec3b18_106_cp314.conda - conda: https://prefix.dev/conda-forge/linux-aarch64/pyyaml-6.0.3-py314h807365f_1.conda - conda: https://prefix.dev/conda-forge/linux-aarch64/rattler-build-0.80.0-hc342849_0.conda @@ -157,26 +161,28 @@ environments: - conda: https://prefix.dev/conda-forge/linux-aarch64/tk-8.6.13-noxft_hf03c496_4.conda - conda: https://prefix.dev/conda-forge/linux-aarch64/yaml-0.2.5-h29ee22c_3.conda - conda: https://prefix.dev/conda-forge/linux-aarch64/zstd-1.5.7-h9d15635_7.conda + - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://prefix.dev/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/pytest-9.1.1-pyhc364b38_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-9_cp314.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-81.0.0-pyh332efcf_0.conda - conda: https://prefix.dev/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - conda: https://prefix.dev/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: git+https://github.com/RoboStack/vinca.git?rev=74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5#74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5 + - pypi: git+https://github.com/EzraBrooks/vinca.git?rev=ec69ef82c330e52496bb11a60004a9d06a80ae0b#ec69ef82c330e52496bb11a60004a9d06a80ae0b - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/63/327d25d91af32209b394abe5626706c4b7ad5a4812cc464a4e3a2be6916f/rospkg-1.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/35/79/5e2cffa1c77432f11cd93a5351f30732c997a239d3a3090856a72d6d8ba7/ruamel.yaml-0.17.40-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/95/88ed47cb7da88569a78b7d6fb9420298df7e99997810c844a924d96d3c08/empy-3.3.4.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/53/b5e80bac66d0db921ddbbe3c8d37198455c19302a29da1a434708c35c79d/catkin_pkg-1.1.1-py3-none-any.whl @@ -188,22 +194,25 @@ environments: - pypi: https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl osx-64: + - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://prefix.dev/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/pytest-9.1.1-pyhc364b38_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-9_cp314.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-81.0.0-pyh332efcf_0.conda - conda: https://prefix.dev/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda @@ -234,6 +243,7 @@ environments: - 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.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/python-3.14.7-hafa0b4b_106_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.80.0-h9a0f642_0.conda @@ -243,16 +253,14 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://prefix.dev/conda-forge/osx-64/yaml-0.2.5-had63097_3.conda - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - - pypi: git+https://github.com/RoboStack/vinca.git?rev=74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5#74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5 + - pypi: git+https://github.com/EzraBrooks/vinca.git?rev=ec69ef82c330e52496bb11a60004a9d06a80ae0b#ec69ef82c330e52496bb11a60004a9d06a80ae0b - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/63/327d25d91af32209b394abe5626706c4b7ad5a4812cc464a4e3a2be6916f/rospkg-1.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/35/79/5e2cffa1c77432f11cd93a5351f30732c997a239d3a3090856a72d6d8ba7/ruamel.yaml-0.17.40-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/95/88ed47cb7da88569a78b7d6fb9420298df7e99997810c844a924d96d3c08/empy-3.3.4.tar.gz - - pypi: https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3f/53/b5e80bac66d0db921ddbbe3c8d37198455c19302a29da1a434708c35c79d/catkin_pkg-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl @@ -266,17 +274,21 @@ environments: - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl osx-arm64: + - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://prefix.dev/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/pytest-9.1.1-pyhc364b38_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-9_cp314.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-81.0.0-pyh332efcf_0.conda - conda: https://prefix.dev/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda @@ -307,6 +319,7 @@ environments: - 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.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/python-3.14.7-h0ae1c2c_106_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.80.0-h2ace698_0.conda @@ -316,13 +329,12 @@ environments: - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://prefix.dev/conda-forge/osx-arm64/yaml-0.2.5-h74c22ad_3.conda - conda: https://prefix.dev/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - - pypi: git+https://github.com/RoboStack/vinca.git?rev=74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5#74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5 + - pypi: git+https://github.com/EzraBrooks/vinca.git?rev=ec69ef82c330e52496bb11a60004a9d06a80ae0b#ec69ef82c330e52496bb11a60004a9d06a80ae0b - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/63/327d25d91af32209b394abe5626706c4b7ad5a4812cc464a4e3a2be6916f/rospkg-1.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/35/79/5e2cffa1c77432f11cd93a5351f30732c997a239d3a3090856a72d6d8ba7/ruamel.yaml-0.17.40-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/95/88ed47cb7da88569a78b7d6fb9420298df7e99997810c844a924d96d3c08/empy-3.3.4.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/53/b5e80bac66d0db921ddbbe3c8d37198455c19302a29da1a434708c35c79d/catkin_pkg-1.1.1-py3-none-any.whl @@ -331,7 +343,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl - pypi: https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl @@ -339,11 +350,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl win-64: + - conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda - conda: https://prefix.dev/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/m2-msys2-runtime-3.6.1.4-hc364b38_6.conda @@ -352,6 +366,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://prefix.dev/conda-forge/noarch/pygments-2.21.0-pyhcf101f3_0.conda - conda: https://prefix.dev/conda-forge/noarch/pytest-9.1.1-pyhc364b38_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-9_cp314.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-81.0.0-pyh332efcf_0.conda - conda: https://prefix.dev/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda @@ -378,6 +393,7 @@ environments: - conda: https://prefix.dev/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda - conda: https://prefix.dev/conda-forge/win-64/m2-conda-epoch-20250515-0_x86_64.conda - conda: https://prefix.dev/conda-forge/win-64/openssl-3.6.5-hf411b9b_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/python-3.14.7-h53f6dd8_106_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.80.0-he94b42d_0.conda @@ -389,15 +405,13 @@ environments: - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://prefix.dev/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://prefix.dev/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - - pypi: git+https://github.com/RoboStack/vinca.git?rev=74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5#74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5 + - pypi: git+https://github.com/EzraBrooks/vinca.git?rev=ec69ef82c330e52496bb11a60004a9d06a80ae0b#ec69ef82c330e52496bb11a60004a9d06a80ae0b - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/15/63/327d25d91af32209b394abe5626706c4b7ad5a4812cc464a4e3a2be6916f/rospkg-1.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/35/79/5e2cffa1c77432f11cd93a5351f30732c997a239d3a3090856a72d6d8ba7/ruamel.yaml-0.17.40-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3b/95/88ed47cb7da88569a78b7d6fb9420298df7e99997810c844a924d96d3c08/empy-3.3.4.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/53/b5e80bac66d0db921ddbbe3c8d37198455c19302a29da1a434708c35c79d/catkin_pkg-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl @@ -411,6 +425,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl @@ -885,6 +900,29 @@ packages: run_exports: {} size: 152126 timestamp: 1790866799280 +- conda: https://prefix.dev/conda-forge/linux-64/py-rattler-0.28.1-py311hc91d8b8_0.conda + noarch: python + sha256: a205cc0cbdc40f7ca84aec0741ca241a8d007f46130898bfb6b4648769ddb222 + md5: 5372f7af3bd02484bf49d68f42f1bae6 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - openssl >=3.5.9,<4.0a0 + - _python_abi3_support 1.* + - cpython >=3.11 + constrains: + - __glibc >=2.17 + extra_depends: + networkx: + - networkx + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/py-rattler?source=hash-mapping + run_exports: {} + size: 15049429 + timestamp: 1793309611253 - conda: https://prefix.dev/conda-forge/linux-64/python-3.14.7-hcd007b5_106_cp314.conda build_number: 106 sha256: 4cd05407d6b07d00fd5b6cc78bd2f60ae5e21f777eb26ead82be2e3751c610a1 @@ -1480,6 +1518,28 @@ packages: run_exports: {} size: 155583 timestamp: 1790866789930 +- conda: https://prefix.dev/conda-forge/linux-aarch64/py-rattler-0.28.1-py311h40f29f8_0.conda + noarch: python + sha256: d75dad485bbe578e016b39661d87124ceb8daa44dcbc8584caedd45c4be20c52 + md5: d6db7695bed4394f99383e477348ebd8 + depends: + - python + - libgcc >=15 + - openssl >=3.5.9,<4.0a0 + - _python_abi3_support 1.* + - cpython >=3.11 + constrains: + - __glibc >=2.17 + extra_depends: + networkx: + - networkx + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/py-rattler?source=hash-mapping + run_exports: {} + size: 14632423 + timestamp: 1793309633956 - conda: https://prefix.dev/conda-forge/linux-aarch64/python-3.14.7-hbec3b18_106_cp314.conda build_number: 106 sha256: 39af053b358d0a6d53549c8cb7fa33b03da4f8a39e966e12a84d1235bdc7023f @@ -1629,6 +1689,18 @@ packages: - zstd >=1.5.7,<1.6.0a0 size: 615477 timestamp: 1786599613561 +- conda: https://prefix.dev/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda + sha256: 2a7204314663eeda5dec482a956f0e2eaf289bd5b9953eaaaad0e81aa64638f2 + md5: 3845f3d75991bae0fb90884662f4327c + depends: + - cpython + - python-gil + license: MIT + license_family: MIT + purls: [] + run_exports: {} + size: 8144 + timestamp: 1784221492234 - conda: https://prefix.dev/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda sha256: 95e8e74062a5fe5f870ac8c90302b6e89945165fdaed7810606e84ddee6aac12 md5: e27d2ac27b096dc51fedfcf775a53f9b @@ -1661,6 +1733,18 @@ packages: run_exports: {} size: 27011 timestamp: 1733218222191 +- conda: https://prefix.dev/conda-forge/noarch/cpython-3.14.10-py314hd8ed1ab_100.conda + noarch: generic + sha256: 80c395601d9cff6d342d3dc81b7c19fabf00a3644b7df053cff14783077e6d15 + md5: 81608e10400d700890e3782b9b8d3987 + depends: + - python >=3.14,<3.15.0a0 + - python_abi * *_cp314 + license: Python-2.0 + purls: [] + run_exports: {} + size: 52072 + timestamp: 1793014778194 - conda: https://prefix.dev/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda sha256: ee6cf346d017d954255bbcbdb424cddea4d14e4ed7e9813e429db1d795d01144 md5: 8e662bd460bda79b1ea39194e3c4c9ab @@ -1769,6 +1853,17 @@ packages: run_exports: {} size: 306724 timestamp: 1782127176429 +- conda: https://prefix.dev/conda-forge/noarch/python-gil-3.14.10-h4df99d1_100.conda + sha256: d3d2134ec9f8c42095bb823260499352cfc14d78d97d12d4abb84fbe06f7f84d + md5: 834210c44f2cb3f1fb07373f62b21cbf + depends: + - cpython 3.14.10.* + - python_abi * *_cp314 + license: Python-2.0 + purls: [] + run_exports: {} + size: 52037 + timestamp: 1793014785617 - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.14-9_cp314.conda build_number: 9 sha256: e7f8e965bbfb98e08feb2365cacdad8bb218fa5b5a0a2752f747287f425f213c @@ -2176,6 +2271,27 @@ packages: - 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 + md5: 0e2ba8eb0a436efb8af0628aae74f49b + depends: + - python + - __osx >=11.0 + - _python_abi3_support 1.* + - cpython >=3.11 + constrains: + - __osx >=11.0 + extra_depends: + networkx: + - networkx + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/py-rattler?source=hash-mapping + run_exports: {} + size: 14590908 + timestamp: 1793309708900 - conda: https://prefix.dev/conda-forge/osx-64/python-3.14.7-hafa0b4b_106_cp314.conda build_number: 106 sha256: 74e72b101d4985b9919fcf01fec55ff9c82e862377d6be23b99bfe5562ffc207 @@ -2662,6 +2778,27 @@ packages: - 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 + md5: 8f5df37090edaca13f6cf60fbf2d8ff5 + depends: + - python + - __osx >=11.0 + - _python_abi3_support 1.* + - cpython >=3.11 + constrains: + - __osx >=11.0 + extra_depends: + networkx: + - networkx + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/py-rattler?source=hash-mapping + run_exports: {} + size: 13242554 + timestamp: 1793309637666 - conda: https://prefix.dev/conda-forge/osx-arm64/python-3.14.7-h0ae1c2c_106_cp314.conda build_number: 106 sha256: 5ffc8349ca089ec5ac39ad36a095323ad177ebe05cbfc690c1e135560cfd68b7 @@ -3112,6 +3249,27 @@ packages: - openssl >=3.6.5,<4.0a0 size: 9402647 timestamp: 1791099870810 +- conda: https://prefix.dev/conda-forge/win-64/py-rattler-0.28.1-py311h66afae6_0.conda + noarch: python + sha256: 3e15f8f31d02178aeaba8c6250f832fa82eac1f2c5e762130084c36b9c8bc6ac + md5: 927cbf1a57f34b7693a6f05f845b8532 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - _python_abi3_support 1.* + - cpython >=3.11 + extra_depends: + networkx: + - networkx + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/py-rattler?source=hash-mapping + run_exports: {} + size: 16468002 + timestamp: 1793309604859 - conda: https://prefix.dev/conda-forge/win-64/python-3.14.7-h53f6dd8_106_cp314.conda build_number: 106 sha256: 9fdcb8a5018d91a22484e67ac6c5ae9116f082f953328f43ed4421418740dfb1 @@ -3289,12 +3447,12 @@ packages: - zstd >=1.5.7,<1.6.0a0 size: 387535 timestamp: 1786599623274 -- pypi: git+https://github.com/RoboStack/vinca.git?rev=74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5#74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5 +- pypi: git+https://github.com/EzraBrooks/vinca.git?rev=ec69ef82c330e52496bb11a60004a9d06a80ae0b#ec69ef82c330e52496bb11a60004a9d06a80ae0b name: vinca version: 0.2.0 requires_dist: - catkin-pkg>=0.4.16 - - ruamel-yaml>=0.16.6,<0.18.0 + - ruamel-yaml>=0.19.1,<0.20.0 - rosdistro>=0.8.0 - empy>=3.3.4,<4.0.0 - requests>=2.24.0 @@ -3302,8 +3460,7 @@ packages: - rich>=10 - jinja2>=3.0.0 - license-expression>=30.0.0 - - packaging>=23.0 - - zstandard>=0.19.0 + - py-rattler>=0.28.1,<0.29 requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl name: certifi @@ -3342,29 +3499,11 @@ packages: version: 3.5.2 sha256: 34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - name: zstandard - version: 0.25.0 - sha256: e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0 - requires_dist: - - cffi~=1.17 ; python_full_version < '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - - cffi>=2.0.0b0 ; python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl name: docutils version: '0.23' sha256: 25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/35/79/5e2cffa1c77432f11cd93a5351f30732c997a239d3a3090856a72d6d8ba7/ruamel.yaml-0.17.40-py3-none-any.whl - name: ruamel-yaml - version: 0.17.40 - sha256: b16b6c3816dff0a93dca12acf5e70afd089fa5acb80604afd1ffa8b465b7722c - requires_dist: - - ruamel-yaml-clib>=0.2.7 ; python_full_version < '3.13' and platform_python_implementation == 'CPython' - - ryd ; extra == 'docs' - - mercurial>5.7 ; extra == 'docs' - - ruamel-yaml-jinja2>=0.2 ; extra == 'jinja2' - requires_python: '>=3' - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl name: pyparsing version: 3.3.3 @@ -3373,26 +3512,10 @@ packages: - railroad-diagrams ; extra == 'diagrams' - jinja2 ; extra == 'diagrams' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl - name: zstandard - version: 0.25.0 - sha256: c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2 - requires_dist: - - cffi~=1.17 ; python_full_version < '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - - cffi>=2.0.0b0 ; python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/3b/95/88ed47cb7da88569a78b7d6fb9420298df7e99997810c844a924d96d3c08/empy-3.3.4.tar.gz name: empy version: 3.3.4 sha256: 73ac49785b601479df4ea18a7c79bc1304a8a7c34c02b9472cf1206ae88f01b3 -- pypi: https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl - name: zstandard - version: 0.25.0 - sha256: e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3 - requires_dist: - - cffi~=1.17 ; python_full_version < '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - - cffi>=2.0.0b0 ; python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/3f/53/b5e80bac66d0db921ddbbe3c8d37198455c19302a29da1a434708c35c79d/catkin_pkg-1.1.1-py3-none-any.whl name: catkin-pkg version: 1.1.1 @@ -3507,14 +3630,6 @@ packages: - markdown-it-py>=2.2.0 - pygments>=2.13.0,<3.0.0 requires_python: '>=3.9.0' -- pypi: https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl - name: zstandard - version: 0.25.0 - sha256: 05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f - requires_dist: - - cffi~=1.17 ; python_full_version < '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - - cffi>=2.0.0b0 ; python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: markupsafe version: 3.0.4 @@ -3548,14 +3663,6 @@ packages: - pysocks>=1.5.6,!=1.5.7 ; extra == 'socks' - chardet>=3.0.2,<8 ; extra == 'use-chardet-on-py3' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - name: zstandard - version: 0.25.0 - sha256: 223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439 - requires_dist: - - cffi~=1.17 ; python_full_version < '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - - cffi>=2.0.0b0 ; python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and extra == 'cffi' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl name: markupsafe version: 3.0.4 @@ -3624,6 +3731,17 @@ packages: version: 1.17.0 sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- pypi: https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl + name: ruamel-yaml + version: 0.19.1 + sha256: 27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93 + requires_dist: + - ruamel-yaml-clib ; platform_python_implementation == 'CPython' and extra == 'oldlibyaml' + - ruamel-yaml-clibz>=0.3.7 ; platform_python_implementation == 'CPython' and extra == 'libyaml' + - ruamel-yaml-jinja2>=0.2 ; extra == 'jinja2' + - ryd ; extra == 'docs' + - mercurial>5.7 ; extra == 'docs' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl name: charset-normalizer version: 3.5.2 diff --git a/pixi.toml b/pixi.toml index c76cfff92..4de7ddfd3 100644 --- a/pixi.toml +++ b/pixi.toml @@ -21,6 +21,7 @@ rattler-build = ">=0.80.0,<0.81" cmake = "<4.0" setuptools = "<82.0" rattler-index = ">=0.27.16" +py-rattler = ">=0.28.1,<0.29" pyyaml = "*" # Some git repos need git-lfs, see https://github.com/RoboStack/ros-jazzy/issues/118 git-lfs = "*" @@ -36,7 +37,7 @@ git = "*" [pypi-dependencies] # One vinca for all distributions. -vinca = { git = "https://github.com/RoboStack/vinca.git", rev = "74d1e856d5b00c0cb5ee0c1be41dac4afcae7bf5" } +vinca = { git = "https://github.com/EzraBrooks/vinca.git", rev = "ec69ef82c330e52496bb11a60004a9d06a80ae0b" } # For local development, temporarily use (and regenerate pixi.lock): # vinca = { path = "../vinca", editable = true } @@ -44,5 +45,5 @@ vinca = { git = "https://github.com/RoboStack/vinca.git", rev = "74d1e856d5b00c0 rs = { cmd = "python tools/robostack.py", description = "Run a task for one distribution: `pixi run rs ` (generate-recipes, build, build-one, check-patches, check-deps, create-snapshot, render-pinning, sort, ...)." } sort = { cmd = "sh -lc 'vinca-sort-vinca-lists $@ shared/vinca.yaml && vinca-sort-yaml-keys $@ shared/robostack.yaml shared/pkg_additional_info.yaml shared/patch/dependencies.yaml && for d in distros/*/; do (cd $d && vinca-sort-vinca-lists $@ vinca.yaml && vinca-sort-yaml-keys $@ pkg_additional_info.yaml rosdistro_additional_recipes.yaml $(ls patch/dependencies.yaml 2>/dev/null)) || exit 1; done' --", description = "Sort the YAML files of all distributions (`--check` to only check)." } test = { cmd = "python -m pytest tools/tests", description = "Unit tests of the repository tooling (tools/tests)." } -abi-check = { cmd = "pixi exec -s python=3.14 -s zstandard -s pyyaml -s pyelftools python tools/abi_check.py", description = "Compare the ABI of package builds with libabigail (Linux only): `pixi run abi-check --distro jazzy --latest-pairs 30`, `--old a.conda --new b.conda`, or `--pr-builds ` (a pull request's builds against the released ones)." } +abi-check = { cmd = "pixi exec -s python=3.14 -s py-rattler -s pyyaml -s pyelftools python tools/abi_check.py", description = "Compare the ABI of package builds with libabigail (Linux only): `pixi run abi-check --distro jazzy --latest-pairs 30`, `--old a.conda --new b.conda`, or `--pr-builds ` (a pull request's builds against the released ones)." } render-pinning = { cmd = "sh -lc 'for d in distros/*/; do python tools/robostack.py $(basename $d) render-pinning || exit 1; done'", description = "Regenerate every distribution's conda_build_config.yaml from shared/pinning and its distro.yaml." } diff --git a/tools/abi_check.py b/tools/abi_check.py index 0aab03c67..28bea9c98 100644 --- a/tools/abi_check.py +++ b/tools/abi_check.py @@ -30,62 +30,48 @@ from __future__ import annotations import argparse -import io +import asyncio import re import json import os import shutil import subprocess import sys -import tarfile import tempfile -import urllib.error -import urllib.request -import zipfile from pathlib import Path -import zstandard +import rattler +from rattler.exceptions import FetchRepoDataError +from rattler.package_streaming import download_and_extract, download_to_path, extract # abidiff exit status bits (libabigail's abidiff_status) ABI_ERROR = 1 ABI_USAGE_ERROR = 2 ABI_CHANGE = 4 ABI_INCOMPATIBLE_CHANGE = 8 +REPODATA_CACHE = Path(tempfile.gettempdir()) / "robostack-abi-check-repodata" -def _open(url: str): - return urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "robostack-abi-check"}), timeout=300) - - -def extract_conda(path: Path, dest: Path) -> None: - """Unpack a .conda (zip with zstd-compressed tarballs) or .tar.bz2 package.""" - dest.mkdir(parents=True, exist_ok=True) - if path.suffix == ".conda": - with zipfile.ZipFile(path) as z: - for name in z.namelist(): - if name.startswith("pkg-") and name.endswith(".tar.zst"): - data = zstandard.ZstdDecompressor().stream_reader(io.BytesIO(z.read(name))) - with tarfile.open(fileobj=data, mode="r|") as tar: - tar.extractall(dest, filter="data") - else: - with tarfile.open(path, "r:bz2") as tar: - tar.extractall(dest, filter="data") +def read_index(path: Path) -> dict: + """The repodata record of a package file (its info/index.json).""" + return json.loads(asyncio.run(rattler.RepoDataRecord.from_package_archive(path)).to_json()) -def read_index(path: Path) -> dict: - """info/index.json of a package.""" - if path.suffix == ".conda": - with zipfile.ZipFile(path) as z: - name = next(n for n in z.namelist() if n.startswith("info-") and n.endswith(".tar.zst")) - data = zstandard.ZstdDecompressor().stream_reader(io.BytesIO(z.read(name))) - with tarfile.open(fileobj=data, mode="r|") as tar: - for member in tar: - if member.name == "info/index.json": - return json.load(tar.extractfile(member)) - else: - with tarfile.open(path, "r:bz2") as tar: - return json.load(tar.extractfile("info/index.json")) - raise ValueError(f"{path} has no info/index.json") +def channel_records(channel: str, platform: str) -> list[dict]: + """The repodata records of the channel for the platform (none when it has no + packages for it). The abi-check task runs without vinca, so not vinca's + get_channel_records.""" + try: + [repodata] = asyncio.run(rattler.fetch_repo_data( + channels=[rattler.Channel(channel)], platforms=[rattler.Subdir(platform)], + cache_path=REPODATA_CACHE, callback=None, + )) + except FetchRepoDataError as error: + # network failures raise the same type + if not str(error).startswith("repodata not found"): + raise + return [] + return [json.loads(record.to_json()) for record in repodata.load_all_records()] def libraries(prefix: Path) -> dict[str, Path]: @@ -125,8 +111,8 @@ def classify(rc: int, report: str) -> str: def compare(old_pkg: Path, new_pkg: Path, work: Path) -> dict: a, b = work / "old", work / "new" shutil.rmtree(work, ignore_errors=True) - extract_conda(old_pkg, a) - extract_conda(new_pkg, b) + extract(old_pkg, a) + extract(new_pkg, b) old_libs, new_libs = libraries(a), libraries(b) result = {"libraries": {}, "removed_libraries": sorted(set(old_libs) - set(new_libs)), "headers": header_changes(a, b)} @@ -225,10 +211,6 @@ def short_name(name: str, distro: str) -> str | None: return None -def _version_key(v: str): - return [int(p) if p.isdigit() else 0 for p in v.replace("-", ".").split(".")] - - def _belongs_to(rec: dict, distro: str) -> bool: """A record of this distribution: ros--* names, or ros2-* builds pinned to its mutex (a channel such as the test channel can hold several distributions).""" @@ -243,15 +225,7 @@ def released(channels: str | list[str], platform: str, distro: str) -> dict[str, packages are left out.""" by_name: dict[str, dict[str, dict]] = {} for channel in [channels] if isinstance(channels, str) else channels: - try: - with _open(f"{channel.rstrip('/')}/{platform}/repodata.json") as r: - data = json.load(r) - except urllib.error.HTTPError as error: - if error.code == 404: # no packages for this platform there - continue - raise - records = list(data.get("packages", {}).items()) + list(data.get("packages.conda", {}).items()) - for filename, rec in records: + for rec in channel_records(channel, platform): short = short_name(rec["name"], distro) if short is None or _is_compatibility_package(rec) or not _belongs_to(rec, distro): continue @@ -260,12 +234,12 @@ def released(channels: str | list[str], platform: str, distro: str) -> dict[str, if current is None or (rec.get("build_number", 0), rec.get("timestamp", 0)) > ( current.get("build_number", 0), current.get("timestamp", 0) ): - versions[rec["version"]] = {"file": filename, "channel": channel, **rec} + versions[rec["version"]] = {**rec, "file": rec["fn"], "channel": channel} return by_name def newest(versions: dict[str, dict]) -> dict: - return versions[max(versions, key=_version_key)] + return versions[max(versions, key=rattler.Version)] def latest_pairs(channel: str, platform: str, count: int, distro: str) -> list[tuple[str, dict, dict]]: @@ -275,7 +249,7 @@ def latest_pairs(channel: str, platform: str, count: int, distro: str) -> list[t for short, versions in sorted(released(channel, platform, distro).items()): if len(versions) < 2: continue - ordered = sorted(versions, key=_version_key) + ordered = sorted(versions, key=rattler.Version) pairs.append((short, versions[ordered[-2]], versions[ordered[-1]])) # most recently uploaded new versions first pairs.sort(key=lambda p: p[2].get("timestamp", 0), reverse=True) @@ -343,11 +317,10 @@ def channel_for(distro: str) -> list[str]: return extra + [rs.repodata_url(distro)] -def download(channel: str, platform: str, filename: str, dest: Path) -> Path: - target = dest / filename +def download(rec: dict, dest: Path) -> Path: + target = dest / rec["file"] if not target.exists(): - with _open(f"{channel.rstrip('/')}/{platform}/{filename}") as r, open(target, "wb") as f: - shutil.copyfileobj(r, f) + asyncio.run(download_to_path(rattler.Client(), rec["url"], target)) return target @@ -355,19 +328,16 @@ def download(channel: str, platform: str, filename: str, dest: Path) -> Path: MAX_DEPENDENTS_TO_INSPECT = 40 -def linking_dependents(channel, platform, by_name, candidates, sonames, downloads: Path, tmp: Path) -> dict: +def linking_dependents(by_name, candidates, sonames, tmp: Path) -> dict: """Which of the dependents link to one of the SONAMEs (and so need a rebuild); metapackages, Python packages and configurations don't.""" if len(candidates) > MAX_DEPENDENTS_TO_INSPECT: return {"rebuild": candidates, "checked": False} rebuild = [] for name in candidates: - rec = newest(by_name[name]) - pkg = download(rec["channel"], platform, rec["file"], downloads) target = tmp / "dependent" shutil.rmtree(target, ignore_errors=True) - extract_conda(pkg, target) - pkg.unlink(missing_ok=True) + asyncio.run(download_and_extract(rattler.Client(), newest(by_name[name])["url"], target)) if needed(target) & set(sonames): rebuild.append(name) return {"rebuild": rebuild, "checked": True, "candidates": len(candidates)} @@ -407,14 +377,14 @@ def check_pr_builds(pr_dir: Path, channel: str, platform: str, distro: str, tmp: if old["file"] == pkg.name: continue print(f"{short}: {old['version']} (build {old.get('build_number', 0)}) -> {index['version']} (PR)", flush=True) - old_pkg = download(old["channel"], platform, old["file"], downloads) + old_pkg = download(old, downloads) res = compare(old_pkg, pkg, tmp / "work") old_pkg.unlink(missing_ok=True) res.update(old=f"{old['version']} (build {old.get('build_number', 0)})", new=index["version"], pins=pin_changes(old, index, distro)) if res["broken_sonames"]: res["dependents"] = linking_dependents( - channel, platform, by_name, dependents(by_name, short, distro), res["broken_sonames"], downloads, tmp + by_name, dependents(by_name, short, distro), res["broken_sonames"], tmp ) results[short] = res print(f" -> {res['verdict']}", flush=True) @@ -566,8 +536,8 @@ def main() -> int: downloads.mkdir() for name, old, new in latest_pairs(channel, args.platform, args.latest_pairs, args.distro): print(f"{name}: {old['version']} -> {new['version']}", flush=True) - old_pkg = download(old["channel"], args.platform, old["file"], downloads) - new_pkg = download(new["channel"], args.platform, new["file"], downloads) + old_pkg = download(old, downloads) + new_pkg = download(new, downloads) res = compare(old_pkg, new_pkg, tmp / "work") res.update(old=old["version"], new=new["version"]) results[name] = res diff --git a/tools/build_gap_report.py b/tools/build_gap_report.py index 945e947d9..fa814e559 100644 --- a/tools/build_gap_report.py +++ b/tools/build_gap_report.py @@ -2,7 +2,8 @@ """Report gaps between generated recipes and built conda artifacts. Default behavior is platform-agnostic: it inspects all output/ folders that -contain conda artifacts and reports gaps per platform. Only artifacts built with the +contain conda artifacts and reports gaps per platform; output/noarch artifacts count for +every platform. Only artifacts built with the CURRENT build_number (and, for the mutex package, its own build_number) are counted — older-build_number leftovers from a previous full rebuild are ignored, since counting them makes the report claim far more packages are done than the current build actually @@ -293,8 +294,12 @@ def main() -> int: ) print(f"Filtering to build_number {build_number}{mutex_note}{override_note}\n") + # noarch packages (e.g. the compatibility packages) count as built on every platform + built_noarch = built_packages_for_platform( + output_root, "noarch", build_number, mutex_norm_name, mutex_build_number, pkg_build_number_overrides + ) for idx, platform in enumerate(selected_platforms): - built = built_packages_for_platform( + built = built_noarch | built_packages_for_platform( output_root, platform, build_number, mutex_norm_name, mutex_build_number, pkg_build_number_overrides ) diff --git a/tools/check_dependency_compat.py b/tools/check_dependency_compat.py index ff145731f..d03217230 100644 --- a/tools/check_dependency_compat.py +++ b/tools/check_dependency_compat.py @@ -47,6 +47,8 @@ from urllib.request import Request, urlopen import ruamel.yaml +from rattler import Version, VersionSpec +from rattler.exceptions import InvalidVersionError, InvalidVersionSpecError ROS_PREFIXES = ("ros-", "ros2-") DEFAULT_CHANNELS = ["https://repo.prefix.dev/conda-forge"] @@ -659,7 +661,7 @@ def report_conflicts( if any(len(spec.split()) > 1 for _, spec in specs): print(" note: the spec is version-restricted (dummy package in pkg_additional_info.yaml?);" " a newer conda-forge version may already be built against the pinned libraries.") - glibc_needs = sorted({m.group(1) for m in _GLIBC_NEED_RE.finditer(block)}, key=version_tuple) + glibc_needs = sorted({m.group(1) for m in _GLIBC_NEED_RE.finditer(block)}, key=Version) if (glibc_needs and host_names is not None and normalized(culprit) not in host_names and args.platform.startswith("linux") and solver.solve(f"glibc-{culprit}", pins, specs, glibc=glibc_needs[-1])[0]): @@ -793,74 +795,31 @@ def report_migrations( # ----------------------------------------------------------------------- stale -_VERSION_PART_RE = re.compile(r"^(\d+)(.*)$") - - -def version_tuple(version: str) -> tuple[int, ...]: - parts: list[int] = [] - for part in version.strip().split("."): - match = _VERSION_PART_RE.match(part) - if not match: - break - parts.append(int(match.group(1))) - if match.group(2): # pre-release suffix such as '0a0': stop here - break - return tuple(parts) - - -def _pad(t: tuple[int, ...], n: int) -> tuple[int, ...]: - return t + (0,) * (n - len(t)) - - -def _cmp(a: tuple[int, ...], b: tuple[int, ...]) -> int: - n = max(len(a), len(b)) - a, b = _pad(a, n), _pad(b, n) - return (a > b) - (a < b) - - -def pin_range(pin_version: str) -> tuple[tuple[int, ...], tuple[int, ...], bool]: - """Return (lowest, upper_exclusive, exact) for a pin such as '1.90', '7.35.1.*' or '11.*'.""" - text = pin_version.strip() - exact = not text.endswith(".*") and "*" not in text - prefix = version_tuple(text.rstrip("*").rstrip(".")) - if not prefix: - return (0,), (10**9,), False - upper = prefix[:-1] + (prefix[-1] + 1,) - return prefix, upper, exact +_SPEC_VERSION_RE = re.compile(r"\d[0-9A-Za-z_.+!]*") def constraint_compatible(constraint: str, pin_version: str) -> bool: - """Whether some version can satisfy both the dependency constraint and the pin.""" - low, upper_excl, _ = pin_range(pin_version) - constraint = constraint.strip() - if constraint in ("", "*"): + """Whether some version can satisfy both the dependency constraint and the pin + ('1.90', '1.90.*': every version starting with 1.90).""" + pin = pin_version.strip().rstrip("*").rstrip(".") + if not pin[:1].isdigit(): + return True + try: + spec = VersionSpec(constraint.strip() or "*") + pinned = VersionSpec(f"{pin}.*") + except InvalidVersionSpecError: return True - if "|" in constraint: - return any(constraint_compatible(part, pin_version) for part in constraint.split("|")) - for clause in [c.strip() for c in constraint.split(",") if c.strip()]: - if clause.startswith(">="): - if _cmp(upper_excl, version_tuple(clause[2:])) <= 0: - return False - elif clause.startswith(">"): - if _cmp(upper_excl, version_tuple(clause[1:])) <= 0: - return False - elif clause.startswith("<="): - if _cmp(low, version_tuple(clause[2:])) > 0: - return False - elif clause.startswith("<"): - if _cmp(low, version_tuple(clause[1:])) >= 0: - return False - elif clause.startswith("!="): + # rattler can't intersect specs; if the two overlap, the lowest version they share + # is the pin's, a bound of the constraint, or (for a > bound) just above one + bounds = [v.rstrip(".") for v in _SPEC_VERSION_RE.findall(constraint)] + for candidate in [pin, *bounds, *(f"{v}.1" for v in bounds)]: + try: + version = Version(candidate) + except InvalidVersionError: continue - else: - other = clause[2:] if clause.startswith("==") else clause - other_low, other_upper, _ = pin_range(other) - n = max(len(low), len(other_low)) - a, b = _pad(low, n), _pad(other_low, n) - k = min(len(low), len(other_low)) - if a[:k] != b[:k]: - return False - return True + if spec.matches(version) and pinned.matches(version): + return True + return False _CBC_KEY_RE = re.compile(r"^([A-Za-z0-9_.-]+):\s*(?:#\s*\[(.+?)\])?\s*$") @@ -937,21 +896,21 @@ def variant_pins(variant_config: Path, platform: str) -> dict[str, str]: def load_repodata(source: str, platform: str) -> tuple[dict[str, Any], bool]: + """The records of a channel URL, a platform directory of a local channel + (output/) or either's repodata.json, by file name.""" + from vinca.utils import get_channel_records + remote = "://" in source if remote: - url = source.rstrip("/") - if not url.endswith("repodata.json"): - url = f"{url}/{platform}/repodata.json" - with _urlopen(url, timeout=300) as response: - data = json.load(response) + channel = source.rstrip("/") + if channel.endswith("/repodata.json"): + channel, platform = channel.removesuffix("/repodata.json").rsplit("/", 1) else: path = Path(source) - if path.is_dir(): - path = path / "repodata.json" - data = json.loads(path.read_text(encoding="utf-8")) - packages = dict(data.get("packages", {})) - packages.update(data.get("packages.conda", {})) - return packages, remote + if path.name == "repodata.json": + path = path.parent + channel, platform = str(path.parent), path.name + return {record["fn"]: record for record in get_channel_records(channel, platform)}, remote def ros_name_map(vinca_conf: dict[str, Any]) -> dict[str, str]: diff --git a/tools/ci_build.py b/tools/ci_build.py index d87b153c7..5b3e28eed 100644 --- a/tools/ci_build.py +++ b/tools/ci_build.py @@ -10,15 +10,21 @@ import subprocess import sys +from ci_config import cache_dirs + class Interrupted(Exception): def __init__(self, signum): self.code = 124 if signum == signal.SIGALRM else 128 + signum -def run(cmd: list[str], cache_dir: Path, timeout: float) -> int: - cache_dir = cache_dir.resolve() - original = {p: (p.stat().st_mtime_ns, p.stat().st_size) for p in cache_dir.glob("*.conda")} +def run(cmd: list[str], package_dirs: list[Path], timeout: float) -> int: + package_dirs = [d.resolve() for d in package_dirs] + + def archives(): + return [p for d in package_dirs for p in d.glob("*.conda")] + + original = {p: (p.stat().st_mtime_ns, p.stat().st_size) for p in archives()} completed = set() def output(line): @@ -57,7 +63,7 @@ def interrupt(signum, frame): output(line) proc.stdout.close() # Parallel builds can leave several partial archives, not just the newest one. - for p in cache_dir.glob("*.conda"): + for p in archives(): if p not in completed and original.get(p) != (p.stat().st_mtime_ns, p.stat().st_size): print(f"Removing unfinished archive: {p}", flush=True) p.unlink() @@ -72,10 +78,11 @@ def interrupt(signum, frame): if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--cache-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True, help="rattler-build's output directory") + parser.add_argument("--platform", required=True) parser.add_argument("--timeout", type=float, required=True, help="Build budget in seconds") parser.add_argument("command", nargs=argparse.REMAINDER) args = parser.parse_args() if args.timeout <= 0 or not args.command or args.command[0] != "--" or len(args.command) < 2: parser.error("provide a positive timeout and a command after --") - sys.exit(run(args.command[1:], args.cache_dir, args.timeout)) + sys.exit(run(args.command[1:], cache_dirs(args.output_dir, args.platform), args.timeout)) diff --git a/tools/ci_config.py b/tools/ci_config.py index 31c3ff031..de1b87f2a 100644 --- a/tools/ci_config.py +++ b/tools/ci_config.py @@ -30,9 +30,17 @@ MARKER = ".ci-controls" +def cache_dirs(output_dir: Path, platform: str) -> list[Path]: + """The cached package directories in rattler-build's output directory: the + platform's and noarch (its other contents, e.g. bld/ and src_cache/, are not + cached).""" + return [output_dir / platform, output_dir / "noarch"] + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--cache-dir", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path, help="rattler-build's output directory") + parser.add_argument("--platform", required=True) parser.add_argument("--config", default="ci.yaml", type=Path) parser.add_argument("--vinca", default="vinca.yaml", type=Path) args = parser.parse_args() @@ -41,7 +49,12 @@ def main() -> int: if args.config.exists(): config = yaml.safe_load(args.config.read_text()) or {} distro = yaml.safe_load(args.vinca.read_text())["ros_distro"] - cache = args.cache_dir + for cache in cache_dirs(args.output_dir, args.platform): + apply(cache, config, distro) + return 0 + + +def apply(cache: Path, config: dict, distro: str) -> None: controls = json.dumps({"full_rebuild": bool(config.get("full_rebuild", False)), "evict_cache": [str(e) for e in config.get("evict_cache") or []]}, sort_keys=True) marker = cache / MARKER @@ -50,11 +63,11 @@ def main() -> int: print(f"No cache directory {cache}; nothing to do.") cache.mkdir(parents=True) marker.write_text(controls) - return 0 + return if marker.is_file() and marker.read_text() == controls: print("ci.yaml: controls already applied to this cache (a retry); keeping its packages.") - return 0 + return if config.get("full_rebuild", False): print("ci.yaml: full_rebuild is set, ignoring the restored cache.") @@ -64,7 +77,7 @@ def main() -> int: else: child.unlink() marker.write_text(controls) - return 0 + return for entry in config.get("evict_cache") or []: name = str(entry).replace("_", "-") @@ -76,7 +89,6 @@ def main() -> int: print(f"ci.yaml: evicting {path.name}") path.unlink() marker.write_text(controls) - return 0 if __name__ == "__main__": diff --git a/tools/ci_estimate.py b/tools/ci_estimate.py index 60eb5d2a5..46e5e6db5 100644 --- a/tools/ci_estimate.py +++ b/tools/ci_estimate.py @@ -1,6 +1,6 @@ """How much a pull-request build still has to do, and roughly how long it takes. - python tools/ci_estimate.py --recipes distros//work/recipes --cache-dir --platform

+ python tools/ci_estimate.py --recipes distros//work/recipes --output-dir

--platform

A recipe makes one build per variant: per Python version for the packages with Python content, when the distribution pins several (build_timings.variant_builds). Builds whose @@ -19,6 +19,7 @@ from pathlib import Path import build_timings +from ci_config import cache_dirs SECONDS = { # platform: (per built package, per skipped package) "linux-64": (19, 2), @@ -42,14 +43,15 @@ def duration(seconds: float) -> str: def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--recipes", required=True, type=Path) - parser.add_argument("--cache-dir", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path, help="rattler-build's output directory") parser.add_argument("--platform", required=True) args = parser.parse_args() recipes = {p.name for p in args.recipes.iterdir() if (p / "recipe.yaml").is_file()} if args.recipes.is_dir() else set() variants = build_timings.variant_builds(args.recipes, args.platform) if recipes else {} builds = {r: variants.get(r, 1) for r in recipes} - cached = Counter(f.name.rsplit("-", 2)[0] for f in args.cache_dir.glob("*.conda")) if args.cache_dir.is_dir() else Counter() + cached = Counter(f.name.rsplit("-", 2)[0] for d in cache_dirs(args.output_dir, args.platform) + for f in d.glob("*.conda")) to_build = {r: n - min(n, cached[r]) for r, n in builds.items() if n > cached[r]} skipped = sum(min(n, cached[r]) for r, n in builds.items()) build, skip = SECONDS.get(args.platform, max(SECONDS.values())) diff --git a/tools/maintenance.py b/tools/maintenance.py index 8c0f81d49..7d4125006 100644 --- a/tools/maintenance.py +++ b/tools/maintenance.py @@ -100,31 +100,20 @@ def fold(title: str, body: list[str]) -> None: return "\n".join(lines) -REPODATA_SUBDIRS = ("noarch", "linux-64", "linux-aarch64", "osx-64", "osx-arm64", "win-64") +REPODATA_SUBDIRS = ("linux-64", "linux-aarch64", "osx-64", "osx-arm64", "win-64") # get_channel_records adds noarch def released_builds(distro: str) -> dict[str, int] | None: """Highest build number of every package on the distribution's channel (all platforms), or None when the channel can't be read.""" - import urllib.error - import urllib.request + from vinca.utils import get_channel_records base = rs.repodata_url(distro) builds: dict[str, int] = {} - read = False for subdir in REPODATA_SUBDIRS: - try: - request = urllib.request.Request(f"{base}/{subdir}/repodata.json", headers={"User-Agent": "robostack-bot"}) - with urllib.request.urlopen(request, timeout=300) as response: - data = json.load(response) - except urllib.error.HTTPError as error: - if error.code == 404: - continue - raise - read = True - for record in [*data.get("packages", {}).values(), *data.get("packages.conda", {}).values()]: + for record in get_channel_records(base, subdir): builds[record["name"]] = max(builds.get(record["name"], 0), int(record.get("build_number", 0))) - return builds if read else None + return builds or None def released_build_number(distro: str, builds: dict[str, int] | None = None) -> int | None: @@ -358,11 +347,12 @@ def rosdistro_syncs() -> list[dict]: def _newer(a: str, b: str) -> bool: - from packaging.version import InvalidVersion, Version + from rattler import Version + from rattler.exceptions import InvalidVersionError try: return Version(a) > Version(b) - except InvalidVersion: + except InvalidVersionError: return False diff --git a/tools/package_tests.py b/tools/package_tests.py index 675be12fb..ba4e350b3 100644 --- a/tools/package_tests.py +++ b/tools/package_tests.py @@ -21,6 +21,7 @@ from __future__ import annotations import argparse +import asyncio import json import os import re @@ -30,9 +31,12 @@ import sys import tempfile import time -import urllib.request from pathlib import Path +import rattler +from rattler.package_streaming import download_to_path +from vinca.utils import get_channel_records + sys.path.insert(0, str(Path(__file__).resolve().parent)) import robostack as rs @@ -41,9 +45,6 @@ REPORT_TITLE = "Package tests" # the issues of before, one per distribution: closed in favour of the one issue OLD_TITLE = re.compile(r"^\S+: package tests$") -# packages.prefix.dev (where repo.prefix.dev redirects) answers Python-urllib's default -# User-Agent with 403 Forbidden -USER_AGENT = "robostack-package-tests" MAX_ISSUE_BODY = 60000 # GitHub's limit is 65536 characters LOG_TAIL = 60 # lines of a failed test's output kept in the results # a package's tests that run longer are stopped and reported as failed: a test that hangs @@ -65,44 +66,21 @@ def tested_packages(distro: str) -> list[str]: return sorted(names) -def request(url: str) -> urllib.request.Request: - return urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) - - def download(url: str, path: Path) -> None: - with urllib.request.urlopen(request(url), timeout=600) as response, path.open("wb") as fh: - shutil.copyfileobj(response, fh) + asyncio.run(download_to_path(rattler.Client(), url, path)) def free_space(path: str) -> str: return f"{shutil.disk_usage(path).free / 2**30:.1f} GiB free" -def fetch_json(url: str, attempts: int = 3) -> dict: - for attempt in range(1, attempts + 1): - try: - with urllib.request.urlopen(request(url), timeout=120) as response: - return json.load(response) - except urllib.error.HTTPError as error: - if error.code == 404: - return {} - if attempt == attempts: - raise - except OSError: - if attempt == attempts: - raise - time.sleep(10 * attempt) - return {} - - -def newest_builds(repodata: dict, names: list[str]) -> list[dict]: +def newest_builds(channel_records: list[dict], names: list[str]) -> list[dict]: """For each name, every variant of its newest build: the records with the version and build number of the newest upload (one per build string, e.g. per Python).""" records: dict[str, list[dict]] = {} - for section in ("packages", "packages.conda"): - for filename, record in (repodata.get(section) or {}).items(): - if record.get("name") in names: - records.setdefault(record["name"], []).append({**record, "fn": filename}) + for record in channel_records: + if record.get("name") in names: + records.setdefault(record["name"], []).append(record) selected = [] for name in sorted(records): newest = max(records[name], key=lambda r: (r.get("build_number", 0), r.get("timestamp", 0))) @@ -120,13 +98,10 @@ def newest_builds(repodata: dict, names: list[str]) -> list[dict]: def select(distro: str, platform: str) -> list[dict]: """The package files to test on this platform: {name, version, build, subdir, url}.""" names = tested_packages(distro) - base = rs.repodata_url(distro) selected = [] - for subdir in (platform, "noarch"): - repodata = fetch_json(f"{base}/{subdir}/repodata.json") - for record in newest_builds(repodata, names): - selected.append({"name": record["name"], "version": record["version"], "build": record["build"], - "subdir": subdir, "url": f"{base}/{subdir}/{record['fn']}"}) + for record in newest_builds(get_channel_records(rs.repodata_url(distro), platform), names): + selected.append({"name": record["name"], "version": record["version"], "build": record["build"], + "subdir": record["subdir"], "url": record["url"]}) return selected @@ -196,7 +171,7 @@ def run(distro: str, platform: str, results: Path, only: list[str] | None = None print(log, "\nāŒ failed, trying again", flush=True) ok, log, count = test_package(distro, package_file) flaky = ok - except OSError as error: + except (OSError, RuntimeError) as error: # rattler's download errors are RuntimeErrors ok, log, count, flaky = False, f"download failed: {error}", 0, False finally: package_file.unlink(missing_ok=True) diff --git a/tools/python_abi_audit.py b/tools/python_abi_audit.py new file mode 100644 index 000000000..1837a66be --- /dev/null +++ b/tools/python_abi_audit.py @@ -0,0 +1,154 @@ +"""Check vinca's Python classification (conditional_python_dependency) against the +published packages. + + pixi run rs lyrical python-audit # linux-64, info/paths.json only + pixi run rs lyrical python-audit --elf # also scan ELF files for libpython + pixi run rs lyrical python-audit --platform osx-arm64 --json audit.json + +A generated recipe (work/recipes) either has python in host, and is built for every +Python of the distribution's pinning, or not, and is built once. This compares that +with what the newest published build of each ros2-* package actually contains: + +- native: CPython extensions (*.cpython-*.so, *.pyd), rosidl_generator_py libraries, + or (--elf) ELF files that link libpython; +- pure: only Python modules (site-packages); +- none: no Python content. + +A recipe built once whose published package has Python content is a miss: that +content would only work with the Python it happened to be built with. Misses make +the exit status 1; fix them with `add_host: ["python"]` in patch/dependencies.yaml. +Recipes built per Python without Python content in the package are only reported +(an unneeded rebuild per Python, not a broken package). + +Run it before the switch to per-Python builds, while the channel still has the +packages that were built with python in host (their content is complete). +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import re +import sys +from pathlib import Path + +import rattler +import yaml +from rattler.package_streaming import PackageArchive +from vinca.utils import get_channel_records + +SITE_PACKAGES = re.compile(r"(^|/)(lib/python[0-9.]*/site-packages|Lib/site-packages)/") +EXTENSION = re.compile(r"\.cpython-[^/]*\.(so|dylib)$|\.pyd$") +CONCURRENCY = 16 + + +async def inspect(client: rattler.Client, url: str, elf: bool) -> dict: + """Classify by info/paths.json (read with range requests); with *elf*, the ELF + files of a package without Python files are searched for libpython (embedding).""" + for attempt in range(3): + try: + archive = await PackageArchive.from_url(client, url) + paths = [entry.relative_path.as_posix() for entry in (await archive.paths_json()).paths] + result = classify(paths, []) + if elf and result["content"] == "none": + linked = [entry.name async for entry in archive.stream("pkg") + if entry.is_file and (data := await entry.read())[:4] == b"\x7fELF" + and b"libpython3." in data] + result = classify(paths, linked) + return result + except Exception as err: # noqa: BLE001 - network or archive trouble: retried, then reported + error = err + return {"content": "error", "error": str(error)} + + +async def inspect_all(published: dict[str, dict], elf: bool) -> dict[str, dict]: + client = rattler.Client() + limit = asyncio.Semaphore(CONCURRENCY) + + async def one(name: str, record: dict) -> tuple[str, dict]: + async with limit: + return name, {"file": record["fn"], **await inspect(client, record["url"], elf)} + + return dict(await asyncio.gather(*(one(name, record) for name, record in published.items()))) + + +def classify(paths: list[str], linked: list[str]) -> dict: + site = [p for p in paths if SITE_PACKAGES.search(p)] + native = [p for p in paths if EXTENSION.search(p)] + native += [p for p in paths if "rosidl_generator_py" in p and re.search(r"\.(so|dylib|dll)$", p)] + native += linked + content = "native" if native else "pure" if site else "none" + return {"content": content, "examples": (native or site)[:3]} + + +def flatten(requirements) -> list[str]: + out = [] + for r in requirements or []: + if isinstance(r, dict): + out += flatten(r.get("then")) + flatten(r.get("else")) + else: + out.append(str(r)) + return out + + +def recipes_needing_python(recipes: Path) -> dict[str, bool]: + result = {} + for path in sorted(recipes.glob("ros2-*/recipe.yaml")): + recipe = yaml.safe_load(path.read_text()) + host = flatten((recipe.get("requirements") or {}).get("host")) + result[recipe["package"]["name"]] = "python" in host + return result + + +def newest_builds(records: list[dict]) -> dict[str, dict]: + newest: dict[str, tuple] = {} + for rec in records: + key = (rec["build_number"], rec.get("timestamp", 0)) + if rec["name"].startswith("ros2-") and key > newest.get(rec["name"], ((-1, 0), None))[0]: + newest[rec["name"]] = (key, rec) + return {name: rec for name, (_, rec) in newest.items()} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--channel", required=True, help="channel URL, e.g. https://prefix.dev/robostack-lyrical") + parser.add_argument("--platform", default="linux-64") + parser.add_argument("--recipes", default="recipes", type=Path, help="generated recipes (default: ./recipes)") + parser.add_argument("--elf", action="store_true", + help="download the packages without Python files and look for libpython in their ELF files") + parser.add_argument("--json", type=Path, help="write the per-package results here") + args = parser.parse_args() + + needs = recipes_needing_python(args.recipes) + if not needs: + print(f"No ros2-* recipes in {args.recipes}: run generate-recipes first", file=sys.stderr) + return 1 + published = newest_builds(get_channel_records(args.channel, args.platform)) + results = asyncio.run(inspect_all({n: published[n] for n in needs if n in published}, args.elf)) + + misses = sorted(n for n, r in results.items() if not needs[n] and r["content"] in ("native", "pure")) + unneeded = sorted(n for n, r in results.items() if needs[n] and r["content"] == "none") + errors = sorted(n for n, r in results.items() if r["content"] == "error") + counts: dict[tuple[bool, str], int] = {} + for n, r in results.items(): + counts[(needs[n], r["content"])] = counts.get((needs[n], r["content"]), 0) + 1 + + print(f"{len(needs)} recipes, {len(results)} with a published {args.platform} package, " + f"{len(needs) - len(results)} unpublished") + for (per_python, content), count in sorted(counts.items()): + print(f" {'per Python' if per_python else 'built once':10} package content {content:7} {count}") + for name in misses: + print(f"MISS {name}: built once, but ships {results[name]['content']} Python: {results[name]['examples']}") + if unneeded: + print(f"{len(unneeded)} recipes are built per Python without Python content: {', '.join(unneeded)}") + for name in errors: + print(f"ERROR {name}: {results[name]['error']}", file=sys.stderr) + if args.json: + args.json.write_text(json.dumps({n: {"per_python": needs[n], **r} for n, r in results.items()}, + indent=1, sort_keys=True) + "\n") + return 1 if misses or errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/robostack.py b/tools/robostack.py index 96d2f4c67..d284ba5e5 100644 --- a/tools/robostack.py +++ b/tools/robostack.py @@ -441,7 +441,8 @@ def upload(distro: str, files: list[str], cwd: Path) -> int: def anaconda_upload() -> list[str] | None: # asked again before every attempt: an upload that timed out may still have - # arrived, and uploading it again fails with "already exists" + # arrived, or another platform's job uploaded the same noarch package, and + # uploading it again fails with "already exists" missing = [f for f in files if not on_anaconda(channel, Path(cwd, f))] return ["rattler-build", "upload", "anaconda", "-o", channel, "--api-key", token, *missing] if missing else None @@ -550,6 +551,8 @@ def task(distro: str, name: str, args: list[str]) -> int: return run(py + [str(TOOLS / "check_dependency_compat.py"), *args], w) if name == "gap-report": return run(py + [str(TOOLS / "build_gap_report.py"), *args], w) + if name == "python-audit": + return run(py + [str(TOOLS / "python_abi_audit.py"), "--channel", repodata_url(distro), *args], w) if name in ("build", "build-one"): if not (w / "recipes").is_dir(): print(f"No {(w / 'recipes').relative_to(ROOT)}: run `pixi run rs {distro} generate-recipes` first", diff --git a/tools/tests/test_build_gap_report.py b/tools/tests/test_build_gap_report.py new file mode 100644 index 000000000..e1623789d --- /dev/null +++ b/tools/tests/test_build_gap_report.py @@ -0,0 +1,24 @@ +"""build_gap_report.py counts noarch artifacts for every platform.""" + +import build_gap_report + + +def test_noarch_artifacts_count_for_every_platform(tmp_path, monkeypatch, capsys): + recipes = tmp_path / "recipes" + for recipe in ("ros2-a", "ros2-b", "ros2-c"): + (recipes / recipe).mkdir(parents=True) + output = tmp_path / "output" + for subdir, package in (("linux-64", "ros2-a-1.0-h0_1"), ("osx-arm64", "ros2-c-1.0-h0_1"), + ("noarch", "ros2-b-1.0-h0_1")): + (output / subdir).mkdir(parents=True) + (output / subdir / f"{package}.conda").write_text("") + monkeypatch.setattr("sys.argv", ["build_gap_report.py", "--output-dir", str(output), "--recipes-dir", + str(recipes), "--any-build-number"]) + + assert build_gap_report.main() == 0 + + report = capsys.readouterr().out + assert "Platform: noarch" not in report + linux, osx = report.split("Platform: linux-64")[1].split("Platform: osx-arm64") + assert "1 out of 3\n - ros2-c\n" in linux + assert "1 out of 3\n - ros2-a\n" in osx diff --git a/tools/tests/test_build_timings.py b/tools/tests/test_build_timings.py index a9edd28bb..64a74f07b 100644 --- a/tools/tests/test_build_timings.py +++ b/tools/tests/test_build_timings.py @@ -112,15 +112,17 @@ def test_ci_estimate_uses_the_measured_times(tmp_path, monkeypatch, capsys): for r in ("ros2-a", "ros-jazzy-a", "ros2-b", "ros2-new"): (recipes / r).mkdir(parents=True) (recipes / r / "recipe.yaml").write_text("") - cache = tmp_path / "cache" - cache.mkdir() - (cache / "ros2-b-1.0-h0_1.conda").write_text("") + output = tmp_path / "output" + (output / "linux-64").mkdir(parents=True) + (output / "linux-64" / "ros2-b-1.0-h0_1.conda").write_text("") + (output / "noarch").mkdir() + (output / "noarch" / "ros-jazzy-a-1.0-h0_1.conda").write_text("") monkeypatch.setenv("GITHUB_STEP_SUMMARY", "") - monkeypatch.setattr("sys.argv", ["ci_estimate.py", "--recipes", str(recipes), "--cache-dir", str(cache), + monkeypatch.setattr("sys.argv", ["ci_estimate.py", "--recipes", str(recipes), "--output-dir", str(output), "--platform", "linux-64"]) assert ci_estimate.main() == 0 - # ros2-a 150 s (average), its alias 0.3 s, ros2-new the median 80 s, ros2-b cached 2 s - assert capsys.readouterr().out.startswith("3 packages to build, 1 restored from the cache: roughly 4 min") + # ros2-a 150 s (average), ros2-new the median 80 s, ros2-b and the noarch alias cached 2 s each + assert capsys.readouterr().out.startswith("2 packages to build, 2 restored from the cache: roughly 4 min") def test_build_ci_parallel_times_each_build(tmp_path, monkeypatch, capsys): @@ -236,13 +238,13 @@ def test_ci_estimate_counts_each_variant(tmp_path, monkeypatch, capsys): {"ros2-cpp": {"lyrical": 60.0}, "ros2-py": {"lyrical": 100.0}, "ros2-py-hdf5": {"lyrical": 10.0}, "ros2-win-py": {"lyrical": 30.0}})) monkeypatch.setattr(build_timings, "TIMINGS", timings) - cache = tmp_path / "cache" - cache.mkdir() + cache = tmp_path / "output" / "linux-64" + cache.mkdir(parents=True) for f in ("ros2-py-1.0-py312h0_1.conda", "ros2-cpp-1.0-h0_1.conda"): (cache / f).write_text("") monkeypatch.setenv("GITHUB_STEP_SUMMARY", "") - monkeypatch.setattr("sys.argv", ["ci_estimate.py", "--recipes", str(recipes), "--cache-dir", str(cache), - "--platform", "linux-64"]) + monkeypatch.setattr("sys.argv", ["ci_estimate.py", "--recipes", str(recipes), "--output-dir", + str(tmp_path / "output"), "--platform", "linux-64"]) assert ci_estimate.main() == 0 # ros2-py: 2 of 3 builds left, 200 s; ros2-py-hdf5 6 x 10 s; ros2-win-py 30 s; 2 cached x 2 s out = capsys.readouterr().out diff --git a/tools/tests/test_ci_build.py b/tools/tests/test_ci_build.py index 3c62f29bc..26dda8d07 100644 --- a/tools/tests/test_ci_build.py +++ b/tools/tests/test_ci_build.py @@ -14,21 +14,21 @@ pytestmark = pytest.mark.skipif(os.name != "posix", reason="POSIX CI supervisor") -def command(cache, timeout, script): - return [sys.executable, str(ROOT / "tools/ci_build.py"), "--cache-dir", str(cache), +def command(output_dir, timeout, script): + return [sys.executable, str(ROOT / "tools/ci_build.py"), "--output-dir", str(output_dir), "--platform", "linux-64", "--timeout", str(timeout), "--", sys.executable, str(script)] @pytest.mark.parametrize("signum", [signal.SIGINT, signal.SIGTERM, None]) def test_interruption_stops_descendants_and_preserves_only_finished_archives(tmp_path, signum): - cache = tmp_path / "packages" + cache = tmp_path / "linux-64" cache.mkdir() (cache / "restored.conda").write_text("cached") script = tmp_path / "build.py" # A descendant that ignores graceful termination and keeps modifying the output. script.write_text('''import os, signal, subprocess, sys, time from pathlib import Path -cache = Path(sys.argv[0]).parent / "packages" +cache = Path(sys.argv[0]).parent / "linux-64" (cache / "completed.conda").write_text("complete") print(f"Archive written to '{cache / 'completed.conda'}'", flush=True) for i in range(3): @@ -47,7 +47,7 @@ def test_interruption_stops_descendants_and_preserves_only_finished_archives(tmp ''') output = tmp_path / "github-output" env = {**os.environ, "GITHUB_OUTPUT": str(output)} - proc = subprocess.Popen(command(cache, 2 if signum is None else 30, script), env=env, + proc = subprocess.Popen(command(tmp_path, 2 if signum is None else 30, script), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) group = None try: diff --git a/tools/tests/test_ci_config.py b/tools/tests/test_ci_config.py new file mode 100644 index 000000000..1844851af --- /dev/null +++ b/tools/tests/test_ci_config.py @@ -0,0 +1,50 @@ +"""ci_config.py applies ci.yaml's cache controls to the platform's and noarch packages.""" + +import ci_config + + +def _run(tmp_path, monkeypatch, output, ci_yaml=None): + config = tmp_path / "ci.yaml" + if ci_yaml is not None: + config.write_text(ci_yaml) + vinca = tmp_path / "vinca.yaml" + vinca.write_text("ros_distro: lyrical\n") + monkeypatch.setattr("sys.argv", ["ci_config.py", "--output-dir", str(output), "--platform", "linux-64", + "--config", str(config), "--vinca", str(vinca)]) + assert ci_config.main() == 0 + + +def test_evicts_from_the_platform_and_noarch_caches(tmp_path, monkeypatch): + output = tmp_path / "output" + platform, noarch = output / "linux-64", output / "noarch" + for cache, package in ((platform, "ros2-foo-1.0-h0_1.conda"), (noarch, "ros-lyrical-foo-1.0-h0_1.conda")): + cache.mkdir(parents=True) + (cache / package).write_text("") + (cache / "ros2-bar-1.0-h0_1.conda").write_text("") + + _run(tmp_path, monkeypatch, output, "evict_cache: [foo]\n") + + for cache in (platform, noarch): + assert sorted(p.name for p in cache.iterdir()) == [".ci-controls", "ros2-bar-1.0-h0_1.conda"] + + +def test_creates_a_missing_noarch_cache(tmp_path, monkeypatch): + output = tmp_path / "output" + (output / "linux-64").mkdir(parents=True) + + _run(tmp_path, monkeypatch, output) + + assert (output / "noarch" / ".ci-controls").is_file() + + +def test_full_rebuild_keeps_the_rest_of_the_output_directory(tmp_path, monkeypatch): + output = tmp_path / "output" + for d in ("linux-64", "noarch", "bld"): + (output / d).mkdir(parents=True) + (output / d / "ros2-foo-1.0-h0_1.conda").write_text("") + + _run(tmp_path, monkeypatch, output, "full_rebuild: true\n") + + assert [p.name for p in (output / "linux-64").iterdir()] == [".ci-controls"] + assert [p.name for p in (output / "noarch").iterdir()] == [".ci-controls"] + assert (output / "bld" / "ros2-foo-1.0-h0_1.conda").is_file() diff --git a/tools/tests/test_dependency_compat.py b/tools/tests/test_dependency_compat.py new file mode 100644 index 000000000..cd465de99 --- /dev/null +++ b/tools/tests/test_dependency_compat.py @@ -0,0 +1,28 @@ +import pytest + +import abi_check +from check_dependency_compat import constraint_compatible + + +@pytest.mark.parametrize("constraint, pin, compatible", [ + (">=1.88.0,<1.89.0a0", "1.88", True), + (">=1.88.0,<1.89.0a0", "1.90", False), + (">=3.12,<3.13.0a0", "3.12.*", True), + (">=11.2,<12", "11.*", True), + (">=2.0.2,<3", "2", True), + (">1.88,<1.89", "1.88", True), + ("<1.88.0a0", "1.88", False), + (">=1.86|>=2", "2", True), + ("==1.88.1", "1.88", True), + ("==1.88.1", "1.90", False), + ("~=1.88.1", "1.88", True), + ("", "1.88", True), + ("==1.88.1", "gcc", True), +]) +def test_constraint_compatible(constraint, pin, compatible): + assert constraint_compatible(constraint, pin) is compatible + + +def test_newest_orders_conda_versions(): + versions = {v: {"version": v} for v in ("1.10.0", "1.9.2", "1.10.0rc1", "1.2")} + assert abi_check.newest(versions)["version"] == "1.10.0" diff --git a/tools/tests/test_package_tests.py b/tools/tests/test_package_tests.py index 4a18793b3..652e9b1fd 100644 --- a/tools/tests/test_package_tests.py +++ b/tools/tests/test_package_tests.py @@ -6,8 +6,8 @@ import package_tests -def record(name, version, build, number, timestamp=0): - return {"name": name, "version": version, "build": build, "build_number": number, "timestamp": timestamp} +def record(fn, name, version, build, number, timestamp=0): + return {"fn": fn, "name": name, "version": version, "build": build, "build_number": number, "timestamp": timestamp} def test_tested_packages_are_the_test_files(tmp_path, monkeypatch): @@ -23,24 +23,37 @@ def test_tested_packages_are_the_test_files(tmp_path, monkeypatch): def test_newest_builds_keeps_every_variant_of_the_newest_build(): - repodata = { - "packages.conda": { - "a-1-py312_25.conda": record("ros2-a", "1", "py312_25", 25, 1), - "a-1-py314_27.conda": record("ros2-a", "1", "py314_27", 27, 2), - "a-1-py313_27.conda": record("ros2-a", "1", "py313_27", 27, 3), - "b-2-h1_3.conda": record("ros2-b", "2", "h1_3", 3), - "c-1-h_1.conda": record("ros2-untested", "1", "h_1", 1), - }, + records = [ + record("a-1-py312_25.conda", "ros2-a", "1", "py312_25", 25, 1), + record("a-1-py314_27.conda", "ros2-a", "1", "py314_27", 27, 2), # the same build in the old format too: the .conda one is tested - "packages": {"a-1-py314_27.tar.bz2": record("ros2-a", "1", "py314_27", 27, 2)}, - } - selected = package_tests.newest_builds(repodata, ["ros2-a", "ros2-b"]) + record("a-1-py314_27.tar.bz2", "ros2-a", "1", "py314_27", 27, 2), + record("a-1-py313_27.conda", "ros2-a", "1", "py313_27", 27, 3), + record("b-2-h1_3.conda", "ros2-b", "2", "h1_3", 3), + record("c-1-h_1.conda", "ros2-untested", "1", "h_1", 1), + ] + selected = package_tests.newest_builds(records, ["ros2-a", "ros2-b"]) assert [(r["name"], r["build"], r["fn"]) for r in selected] == [ ("ros2-a", "py313_27", "a-1-py313_27.conda"), ("ros2-a", "py314_27", "a-1-py314_27.conda"), ("ros2-b", "h1_3", "b-2-h1_3.conda"), ] - assert package_tests.newest_builds({}, ["ros2-a"]) == [] + assert package_tests.newest_builds([], ["ros2-a"]) == [] + + +def test_select_reads_the_platform_and_noarch_once(monkeypatch): + calls = [] + + def get_channel_records(channel, platform): + calls.append((channel, platform)) + return [{**record("a-1-h_3.conda", "ros2-a", "1", "h_3", 3), "subdir": "linux-64", "url": "u/a"}, + {**record("b-1-h_3.conda", "ros-jazzy-b", "1", "h_3", 3), "subdir": "noarch", "url": "u/b"}] + + monkeypatch.setattr(package_tests, "get_channel_records", get_channel_records) + monkeypatch.setattr(package_tests, "tested_packages", lambda distro: ["ros-jazzy-b", "ros2-a"]) + assert [(p["name"], p["subdir"], p["url"]) for p in package_tests.select("jazzy", "linux-64")] == [ + ("ros-jazzy-b", "noarch", "u/b"), ("ros2-a", "linux-64", "u/a")] + assert calls == [(package_tests.rs.repodata_url("jazzy"), "linux-64")] def test_ran_test_counts_the_tests_rattler_build_ran(): @@ -109,11 +122,6 @@ def gh(args): assert closed == [["api", "-X", "PATCH", "repos/o/r/issues/149", "-f", "state=closed", "-f", "state_reason=not_planned"]] -def test_requests_send_a_user_agent(): - # packages.prefix.dev answers Python-urllib's default User-Agent with 403 - assert package_tests.request("https://repo.prefix.dev/x").get_header("User-agent") == package_tests.USER_AGENT - - def test_a_test_that_hangs_is_stopped_and_failed(monkeypatch): # the test leaves a process behind that ignores SIGTERM and keeps the output open, # like a node the test didn't stop: the whole process group is killed at the timeout diff --git a/tools/tests/test_python_abi_audit.py b/tools/tests/test_python_abi_audit.py new file mode 100644 index 000000000..2ec720d8c --- /dev/null +++ b/tools/tests/test_python_abi_audit.py @@ -0,0 +1,30 @@ +import python_abi_audit as audit + + +def test_classify_by_package_content(): + assert audit.classify(["share/a/package.xml", "share/a/launch/__pycache__/x.cpython-314.pyc"], [])["content"] == "none" + assert audit.classify(["lib/python3.14/site-packages/a/__init__.py"], [])["content"] == "pure" + assert audit.classify(["lib/python/site-packages/a/__init__.py"], [])["content"] == "pure" + assert audit.classify(["Lib/site-packages/a/__init__.py"], [])["content"] == "pure" + assert audit.classify(["lib/a/_a.cpython-314-x86_64-linux-gnu.so"], [])["content"] == "native" + assert audit.classify(["Lib/site-packages/a/_a.cp314-win_amd64.pyd"], [])["content"] == "native" + assert audit.classify(["lib/liba_msgs__rosidl_generator_py.so"], [])["content"] == "native" + assert audit.classify(["lib/a/node"], ["lib/a/node"])["content"] == "native" + + +def test_recipes_needing_python_reads_host(tmp_path): + for name, host in (("ros2-a", "[{if: unix, then: [python]}, ros2-b]"), + ("ros2-b", "[\"python ${{ python_min }}.*\"]"), + ("ros-lyrical-a", "[python]")): + (tmp_path / name).mkdir() + (tmp_path / name / "recipe.yaml").write_text(f"package: {{name: {name}}}\nrequirements:\n host: {host}\n") + assert audit.recipes_needing_python(tmp_path) == {"ros2-a": True, "ros2-b": False} + + +def test_newest_builds_takes_the_highest_build_number(): + def rec(fn, name, number, timestamp=0): + return {"fn": fn, "name": name, "build_number": number, "timestamp": timestamp} + + records = [rec("a-1-h_30.conda", "ros2-a", 30), rec("a-1-h_29.conda", "ros2-a", 29, 9), + rec("a-1-g_30.conda", "ros2-a", 30, 5), rec("x.conda", "ros-lyrical-a", 31)] + assert {name: r["fn"] for name, r in audit.newest_builds(records).items()} == {"ros2-a": "a-1-g_30.conda"} diff --git a/tools/tests/test_upload.py b/tools/tests/test_upload.py index 2ee2c4e08..9e3f62ce3 100644 --- a/tools/tests/test_upload.py +++ b/tools/tests/test_upload.py @@ -55,3 +55,19 @@ def test_anaconda_only(calls, monkeypatch): monkeypatch.setattr(rs, "on_anaconda", lambda channel, package: False) assert rs.upload("humble", ["linux-64/a-1-0.conda"], Path(".")) == 0 assert [c[2] for c in calls] == ["anaconda"] and calls[0][4] == "robostack-humble" + + +def test_on_anaconda_looks_up_noarch_packages_in_noarch(monkeypatch): + import urllib.error + import urllib.request + + urls = [] + + def urlopen(request, timeout): + urls.append(request.full_url) + raise urllib.error.HTTPError(request.full_url, 404, "Not Found", {}, None) + + monkeypatch.setattr(urllib.request, "urlopen", urlopen) + assert rs.on_anaconda("robostack-lyrical", Path("out/noarch/ros-lyrical-a-1.0.0-h0_31.conda")) is False + assert urls == ["https://api.anaconda.org/dist/robostack-lyrical/ros-lyrical-a/1.0.0/noarch/" + "ros-lyrical-a-1.0.0-h0_31.conda"]