From 067dbebae2bf813e32a6a29ddc4456f400ee2237 Mon Sep 17 00:00:00 2001 From: Ezra Brooks Date: Wed, 4 Nov 2026 14:09:30 -0700 Subject: [PATCH 1/2] Conditional Pythons 3.12-3.14 for lyrical --- AGENTS.md | 5 + README.md | 10 ++ distros/lyrical/conda_build_config.yaml | 11 +- distros/lyrical/distro.yaml | 19 ++- distros/lyrical/vinca.yaml | 7 +- tools/python_abi_audit.py | 216 ++++++++++++++++++++++++ tools/robostack.py | 2 + tools/tests/test_python_abi_audit.py | 30 ++++ 8 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 tools/python_abi_audit.py create mode 100644 tools/tests/test_python_abi_audit.py 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 3e9e04305..77d0ee386 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,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/tools/python_abi_audit.py b/tools/python_abi_audit.py new file mode 100644 index 000000000..ac0de1d2d --- /dev/null +++ b/tools/python_abi_audit.py @@ -0,0 +1,216 @@ +"""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 io +import json +import re +import sys +import tarfile +import urllib.request +import zipfile +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import yaml +import zstandard + +UA = {"User-Agent": "robostack-python-audit"} +SITE_PACKAGES = re.compile(r"(^|/)(lib/python[0-9.]*/site-packages|Lib/site-packages)/") +EXTENSION = re.compile(r"\.cpython-[^/]*\.(so|dylib)$|\.pyd$") + + +class RangeReader(io.RawIOBase): + """A remote file read with HTTP range requests (zipfile only needs the end and + the info-*.tar.zst member of a .conda).""" + + def __init__(self, url: str): + with urllib.request.urlopen(urllib.request.Request(url, headers={**UA, "Range": "bytes=0-0"}), + timeout=120) as r: + self.url = r.url + content_range = r.headers.get("Content-Range") + if r.status != 206 or not content_range: + raise OSError(f"{url}: no range support") + self.size = int(content_range.split("/")[1]) + self.pos = 0 + + def readable(self) -> bool: + return True + + def seekable(self) -> bool: + return True + + def tell(self) -> int: + return self.pos + + def seek(self, offset: int, whence: int = 0) -> int: + self.pos = {0: offset, 1: self.pos + offset, 2: self.size + offset}[whence] + return self.pos + + def readinto(self, b) -> int: + n = min(len(b), self.size - self.pos) + if n <= 0: + return 0 + headers = {**UA, "Range": f"bytes={self.pos}-{self.pos + n - 1}"} + with urllib.request.urlopen(urllib.request.Request(self.url, headers=headers), timeout=300) as r: + data = r.read() + b[: len(data)] = data + self.pos += len(data) + return len(data) + + +def open_conda(url: str, whole: bool) -> zipfile.ZipFile: + if not whole: + try: + return zipfile.ZipFile(io.BufferedReader(RangeReader(url), buffer_size=1 << 16)) + except OSError: + pass + with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=600) as r: + return zipfile.ZipFile(io.BytesIO(r.read())) + + +def members(z: zipfile.ZipFile, prefix: str): + name = next(n for n in z.namelist() if n.startswith(prefix)) + stream = zstandard.ZstdDecompressor().stream_reader(z.open(name)) + with tarfile.open(fileobj=stream, mode="r|") as tar: + for member in tar: + if member.isfile(): + yield member, tar.extractfile(member) + + +def inspect(url: str, elf: bool) -> dict: + """Classify by info/paths.json; with *elf*, a package without Python files is + downloaded whole and its ELF files are searched for libpython (embedding).""" + for attempt in range(3): + try: + z = open_conda(url, whole=False) + paths = [] + for member, f in members(z, "info-"): + if member.name == "info/paths.json": + paths = [p["_path"] for p in json.load(f)["paths"]] + break + result = classify(paths, []) + if elf and result["content"] == "none": + linked = [member.name for member, f in members(open_conda(url, whole=True), "pkg-") + if (data := f.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)} + + +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(repodata: dict) -> dict[str, str]: + records = {**repodata.get("packages", {}), **repodata.get("packages.conda", {})} + newest: dict[str, tuple] = {} + for fn, rec in records.items(): + key = (rec["build_number"], rec.get("timestamp", 0)) + if rec["name"].startswith("ros2-") and key > newest.get(rec["name"], ((-1, 0), ""))[0]: + newest[rec["name"]] = (key, fn) + return {name: fn for name, (_, fn) 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 + with urllib.request.urlopen(urllib.request.Request(f"{args.channel}/{args.platform}/repodata.json", headers=UA), + timeout=300) as r: + published = newest_builds(json.load(r)) + + names = [n for n in needs if n in published] + def work(name: str) -> tuple[str, dict]: + url = f"{args.channel}/{args.platform}/{published[name]}" + return name, {"file": published[name], **inspect(url, elf=args.elf)} + + with ThreadPoolExecutor(16) as pool: + results = dict(pool.map(work, names)) + + 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 8b723dfa9..bc4ac219c 100644 --- a/tools/robostack.py +++ b/tools/robostack.py @@ -532,6 +532,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_python_abi_audit.py b/tools/tests/test_python_abi_audit.py new file mode 100644 index 000000000..580f858fd --- /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(name, number, timestamp=0): + return {"name": name, "build_number": number, "timestamp": timestamp} + + repodata = {"packages.conda": {"a-1-h_30.conda": rec("ros2-a", 30), "a-1-h_29.conda": rec("ros2-a", 29, 9), + "a-1-g_30.conda": rec("ros2-a", 30, 5), "x.conda": rec("ros-lyrical-a", 31)}} + assert audit.newest_builds(repodata) == {"ros2-a": "a-1-g_30.conda"} From 8c3397723c261b2d02995934ffad2af78c11fe3c Mon Sep 17 00:00:00 2001 From: Ezra Brooks Date: Tue, 10 Nov 2026 14:25:01 -0700 Subject: [PATCH 2/2] Bump vinca, reuse more code from Vinca & py-rattler Along the way, fixed a bug with `~=` version constraints and added a new suite of tests for version ordering and constraints. --- pixi.lock | 256 +++++++++++++++++++------- pixi.toml | 5 +- tools/abi_check.py | 106 ++++------- tools/check_dependency_compat.py | 109 ++++------- tools/maintenance.py | 22 +-- tools/package_tests.py | 49 ++--- tools/python_abi_audit.py | 124 ++++--------- tools/tests/test_dependency_compat.py | 28 +++ tools/tests/test_package_tests.py | 31 ++-- tools/tests/test_python_abi_audit.py | 10 +- 10 files changed, 357 insertions(+), 383 deletions(-) create mode 100644 tools/tests/test_dependency_compat.py diff --git a/pixi.lock b/pixi.lock index ab0edb4f3..e3f18ebce 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/RoboStack/vinca.git?rev=6022f01558a4f954bffb01d4c9d8e66af733a9a3#6022f01558a4f954bffb01d4c9d8e66af733a9a3 - 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/RoboStack/vinca.git?rev=6022f01558a4f954bffb01d4c9d8e66af733a9a3#6022f01558a4f954bffb01d4c9d8e66af733a9a3 - 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/RoboStack/vinca.git?rev=6022f01558a4f954bffb01d4c9d8e66af733a9a3#6022f01558a4f954bffb01d4c9d8e66af733a9a3 - 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/RoboStack/vinca.git?rev=6022f01558a4f954bffb01d4c9d8e66af733a9a3#6022f01558a4f954bffb01d4c9d8e66af733a9a3 - 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/RoboStack/vinca.git?rev=6022f01558a4f954bffb01d4c9d8e66af733a9a3#6022f01558a4f954bffb01d4c9d8e66af733a9a3 - 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/RoboStack/vinca.git?rev=6022f01558a4f954bffb01d4c9d8e66af733a9a3#6022f01558a4f954bffb01d4c9d8e66af733a9a3 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..08860eac8 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/RoboStack/vinca.git", rev = "6022f01558a4f954bffb01d4c9d8e66af733a9a3" } # 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/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/maintenance.py b/tools/maintenance.py index 8c0f81d49..63b2c1c55 100644 --- a/tools/maintenance.py +++ b/tools/maintenance.py @@ -106,25 +106,14 @@ def fold(title: str, body: list[str]) -> None: 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..1371bcbb3 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))) @@ -123,10 +101,9 @@ def select(distro: str, platform: str) -> list[dict]: 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): + for record in newest_builds(get_channel_records(base, subdir), names): selected.append({"name": record["name"], "version": record["version"], "build": record["build"], - "subdir": subdir, "url": f"{base}/{subdir}/{record['fn']}"}) + "subdir": subdir, "url": record["url"]}) return selected @@ -196,7 +173,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 index ac0de1d2d..1837a66be 100644 --- a/tools/python_abi_audit.py +++ b/tools/python_abi_audit.py @@ -27,97 +27,34 @@ from __future__ import annotations import argparse -import io +import asyncio import json import re import sys -import tarfile -import urllib.request -import zipfile -from concurrent.futures import ThreadPoolExecutor from pathlib import Path +import rattler import yaml -import zstandard +from rattler.package_streaming import PackageArchive +from vinca.utils import get_channel_records -UA = {"User-Agent": "robostack-python-audit"} SITE_PACKAGES = re.compile(r"(^|/)(lib/python[0-9.]*/site-packages|Lib/site-packages)/") EXTENSION = re.compile(r"\.cpython-[^/]*\.(so|dylib)$|\.pyd$") +CONCURRENCY = 16 -class RangeReader(io.RawIOBase): - """A remote file read with HTTP range requests (zipfile only needs the end and - the info-*.tar.zst member of a .conda).""" - - def __init__(self, url: str): - with urllib.request.urlopen(urllib.request.Request(url, headers={**UA, "Range": "bytes=0-0"}), - timeout=120) as r: - self.url = r.url - content_range = r.headers.get("Content-Range") - if r.status != 206 or not content_range: - raise OSError(f"{url}: no range support") - self.size = int(content_range.split("/")[1]) - self.pos = 0 - - def readable(self) -> bool: - return True - - def seekable(self) -> bool: - return True - - def tell(self) -> int: - return self.pos - - def seek(self, offset: int, whence: int = 0) -> int: - self.pos = {0: offset, 1: self.pos + offset, 2: self.size + offset}[whence] - return self.pos - - def readinto(self, b) -> int: - n = min(len(b), self.size - self.pos) - if n <= 0: - return 0 - headers = {**UA, "Range": f"bytes={self.pos}-{self.pos + n - 1}"} - with urllib.request.urlopen(urllib.request.Request(self.url, headers=headers), timeout=300) as r: - data = r.read() - b[: len(data)] = data - self.pos += len(data) - return len(data) - - -def open_conda(url: str, whole: bool) -> zipfile.ZipFile: - if not whole: - try: - return zipfile.ZipFile(io.BufferedReader(RangeReader(url), buffer_size=1 << 16)) - except OSError: - pass - with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=600) as r: - return zipfile.ZipFile(io.BytesIO(r.read())) - - -def members(z: zipfile.ZipFile, prefix: str): - name = next(n for n in z.namelist() if n.startswith(prefix)) - stream = zstandard.ZstdDecompressor().stream_reader(z.open(name)) - with tarfile.open(fileobj=stream, mode="r|") as tar: - for member in tar: - if member.isfile(): - yield member, tar.extractfile(member) - - -def inspect(url: str, elf: bool) -> dict: - """Classify by info/paths.json; with *elf*, a package without Python files is - downloaded whole and its ELF files are searched for libpython (embedding).""" +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: - z = open_conda(url, whole=False) - paths = [] - for member, f in members(z, "info-"): - if member.name == "info/paths.json": - paths = [p["_path"] for p in json.load(f)["paths"]] - break + 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 = [member.name for member, f in members(open_conda(url, whole=True), "pkg-") - if (data := f.read())[:4] == b"\x7fELF" and b"libpython3." in data] + 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 @@ -125,6 +62,17 @@ def inspect(url: str, elf: bool) -> dict: 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)] @@ -153,14 +101,13 @@ def recipes_needing_python(recipes: Path) -> dict[str, bool]: return result -def newest_builds(repodata: dict) -> dict[str, str]: - records = {**repodata.get("packages", {}), **repodata.get("packages.conda", {})} +def newest_builds(records: list[dict]) -> dict[str, dict]: newest: dict[str, tuple] = {} - for fn, rec in records.items(): + 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), ""))[0]: - newest[rec["name"]] = (key, fn) - return {name: fn for name, (_, fn) in newest.items()} + 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: @@ -177,17 +124,8 @@ def main() -> int: if not needs: print(f"No ros2-* recipes in {args.recipes}: run generate-recipes first", file=sys.stderr) return 1 - with urllib.request.urlopen(urllib.request.Request(f"{args.channel}/{args.platform}/repodata.json", headers=UA), - timeout=300) as r: - published = newest_builds(json.load(r)) - - names = [n for n in needs if n in published] - def work(name: str) -> tuple[str, dict]: - url = f"{args.channel}/{args.platform}/{published[name]}" - return name, {"file": published[name], **inspect(url, elf=args.elf)} - - with ThreadPoolExecutor(16) as pool: - results = dict(pool.map(work, names)) + 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") 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..79e152ce2 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,22 @@ 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_ran_test_counts_the_tests_rattler_build_ran(): @@ -109,11 +107,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 index 580f858fd..2ec720d8c 100644 --- a/tools/tests/test_python_abi_audit.py +++ b/tools/tests/test_python_abi_audit.py @@ -22,9 +22,9 @@ def test_recipes_needing_python_reads_host(tmp_path): def test_newest_builds_takes_the_highest_build_number(): - def rec(name, number, timestamp=0): - return {"name": name, "build_number": number, "timestamp": timestamp} + def rec(fn, name, number, timestamp=0): + return {"fn": fn, "name": name, "build_number": number, "timestamp": timestamp} - repodata = {"packages.conda": {"a-1-h_30.conda": rec("ros2-a", 30), "a-1-h_29.conda": rec("ros2-a", 29, 9), - "a-1-g_30.conda": rec("ros2-a", 30, 5), "x.conda": rec("ros-lyrical-a", 31)}} - assert audit.newest_builds(repodata) == {"ros2-a": "a-1-g_30.conda"} + 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"}