diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 910f9c0..a18cf80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,10 +21,14 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Secure pip bootstrap (eMudhra/emSign advisory) + run: | + python -m pip install "pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70" + python -m pip install --upgrade "certifi>=2026.10.19" + - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" + python -m pip install -e ".[dev]" - name: Run pytest with coverage run: pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60 diff --git a/README.md b/README.md index 6074a49..01dcd2a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,18 @@ pip install -e . ``` --- +### Security: eMudhra/emSign CA incident + +A [security incident](https://discuss.python.org/t/information-for-python-users-regarding-emudhra-emsign-ca-certificate-security-incident/109576) affecting eMudhra intermediate certificates means older packaging tools can be man-in-the-middled. Use at least: + +- pip 26.2.2 +- uv 0.13.4 +- certifi 2026.10.19 (this SDK now requires it) + +Upgrade pip and uv in existing virtual environments, refresh lockfiles (`uv lock --upgrade-package certifi`), and rebuild container images. The official `python:3.11` and `3.12` Docker images keep an older pip, so upgrade it with the hash-pinned command from the advisory. + +`nitrostack-py install` upgrades the pip in the project `.venv` to a hash-pinned 26.2.2 before installing anything, and refuses to run `uv` older than 0.13.4. + ## Scaffolding a New Project (Recommended) diff --git a/nitrostack/cli/install.py b/nitrostack/cli/install.py index 2bb09d3..ac8c64e 100644 --- a/nitrostack/cli/install.py +++ b/nitrostack/cli/install.py @@ -1,20 +1,29 @@ """Dependency installation wrapper for `nitrostack-py install`.""" from __future__ import annotations - import os import re import shutil import subprocess import sys -from typing import List, Optional - +from typing import List, Optional, Tuple from nitrostack.cli._shared import write_text_atomic _TRUE_TOKENS = frozenset({"1", "true", "yes", "on"}) _PEP503_KEEP = re.compile(r"[^A-Za-z0-9._-]+") _PEP503_DASH = re.compile(r"[-_.]+") +# eMudhra/emSign CA incident minimums (python.org advisory). +MIN_PIP_VERSION = (26, 2, 2) +MIN_UV_VERSION = (0, 13, 4) +# Hash-pinned wheel taken verbatim from the advisory. Do not edit by hand. +SECURE_PIP_SPEC = ( + "pip @ https://files.pythonhosted.org/packages/" + "3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/" + "pip-26.2.2-py3-none-any.whl" + "#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70" +) + def _read(path: str) -> str: with open(path, "r", encoding="utf-8") as handle: @@ -115,8 +124,68 @@ def ensure_project_venv(root: str) -> str: return python +def _parse_version(text: str, tool: str) -> Optional[Tuple[int, int, int]]: + """Parse `pip 24.0 from ...` or `uv 0.13.4 (abc 2026-10-20)` into a tuple.""" + match = re.search(rf"\b{tool}\s+(\d+)\.(\d+)(?:\.(\d+))?", text or "") + if not match: + return None + major, minor, patch = match.groups() + return (int(major), int(minor), int(patch or 0)) + + +def _tool_version(cmd: List[str], tool: str) -> Optional[Tuple[int, int, int]]: + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + except OSError: + return None + if result.returncode != 0: + return None + return _parse_version(result.stdout, tool) + + +def _get_pip_version(python: str) -> Optional[Tuple[int, int, int]]: + return _tool_version([python, "-m", "pip", "--version"], "pip") + + +def _get_uv_version(uv: str) -> Optional[Tuple[int, int, int]]: + return _tool_version([uv, "--version"], "uv") + + +def _ensure_secure_pip(python: str, cwd: str) -> None: + """Make sure the venv's pip is >= 26.2.2 before it downloads anything. + + The venv pip is whatever the interpreter bundled, which can trust the + compromised emSign intermediates. The pinned wheel is verified by sha256, + so the bootstrap itself cannot be tampered with. + """ + version = _get_pip_version(python) + if version is not None and version >= MIN_PIP_VERSION: + return + print("Updating pip securely before installing dependencies (eMudhra/emSign advisory).") + result = subprocess.run([python, "-m", "pip", "install", SECURE_PIP_SPEC], cwd=cwd) + if result.returncode != 0: + raise RuntimeError( + "Secure pip upgrade failed. Upgrade pip using the python.org eMudhra/emSign " + "advisory instructions, then retry `nitrostack-py install`." + ) + version = _get_pip_version(python) + if version is None or version < MIN_PIP_VERSION: + raise RuntimeError( + "pip is still below 26.2.2 after the secure upgrade. " + "Upgrade pip manually, then retry `nitrostack-py install`." + ) + +def _check_uv_version(uv: str) -> None: + version = _get_uv_version(uv) + if version is None or version < MIN_UV_VERSION: + raise RuntimeError( + "uv 0.13.4 or later is required (eMudhra/emSign CA incident). " + "Upgrade uv (`uv self update` or reinstall) and retry." + ) + def _run_pip(args: List[str], cwd: str) -> None: python = ensure_project_venv(cwd) + _ensure_secure_pip(python, cwd) cmd = [python, "-m", "pip", "install", *args] print(f"Running: {' '.join(cmd)}") result = subprocess.run(cmd, cwd=cwd) @@ -131,6 +200,7 @@ def _run_uv(args: List[str], cwd: str) -> None: uv = _uv_bin() if not uv: raise RuntimeError("uv is not installed") + _check_uv_version(uv) cmd = [uv, *args] print(f"Running: {' '.join(cmd)}") result = subprocess.run(cmd, cwd=cwd) diff --git a/pyproject.toml b/pyproject.toml index 5b4a3b3..78c3fb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "watchfiles>=0.18.0", "anyio>=4.0.0", "packaging>=23.0.0", + "certifi>=2026.10.19", "tomli>=2.0.0; python_version < '3.11'", ] @@ -85,4 +86,4 @@ exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "if __name__ == .__main__.:", -] +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ebe86ba..982a4e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ uvicorn>=0.20.0 watchfiles>=0.18.0 anyio>=4.0.0 packaging>=23.0.0 +certifi>=2026.10.19 tomli>=2.0.0; python_version < "3.11" diff --git a/tests/test_cli_install.py b/tests/test_cli_install.py index 2088914..3d2b3c5 100644 --- a/tests/test_cli_install.py +++ b/tests/test_cli_install.py @@ -24,6 +24,9 @@ def _uv_absent(monkeypatch): """Keep the existing pip-path tests off a real uv binary on PATH.""" monkeypatch.setattr("nitrostack.cli.install._uv_bin", lambda: None) + # pip/uv version gates are covered in test_cli_install_security.py + monkeypatch.setattr("nitrostack.cli.install._ensure_secure_pip", lambda *a, **k: None) + monkeypatch.setattr("nitrostack.cli.install._check_uv_version", lambda *a, **k: None) def test_optional_extra_names_parses_pyproject(): diff --git a/tests/test_cli_install_security.py b/tests/test_cli_install_security.py new file mode 100644 index 0000000..2649b01 --- /dev/null +++ b/tests/test_cli_install_security.py @@ -0,0 +1,158 @@ +"""eMudhra/emSign remediation: secure pip bootstrap and uv version gate.""" +from __future__ import annotations + +import os +import sys +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.cli.install import ( # noqa: E402 + SECURE_PIP_SPEC, + _check_uv_version, + _ensure_secure_pip, + _get_pip_version, + _parse_version, + _run_pip, + _run_uv, +) + +MOD = "nitrostack.cli.install" + + +def _ok(stdout: str = "") -> SimpleNamespace: + return SimpleNamespace(returncode=0, stdout=stdout, stderr="") + + +def _fail() -> SimpleNamespace: + return SimpleNamespace(returncode=1, stdout="", stderr="") + + +# ---- version parsing ------------------------------------------------------ + +def test_parse_pip_two_and_three_part_versions(): + assert _parse_version("pip 24.0 from /x/pip (python 3.12)", "pip") == (24, 0, 0) + assert _parse_version("pip 26.2.2 from /x/pip (python 3.12)", "pip") == (26, 2, 2) + + +def test_parse_uv_version_with_build_suffix(): + assert _parse_version("uv 0.13.4 (abc123 2026-10-20)", "uv") == (0, 13, 4) + + +def test_parse_garbage_returns_none(): + assert _parse_version("command not found", "pip") is None + assert _parse_version("", "uv") is None + + +def test_get_pip_version_none_when_command_fails(): + with patch(f"{MOD}.subprocess.run", return_value=_fail()): + assert _get_pip_version("python") is None + + +def test_get_pip_version_none_when_python_missing(): + with patch(f"{MOD}.subprocess.run", side_effect=OSError("nope")): + assert _get_pip_version("python") is None + + +# ---- pip bootstrap -------------------------------------------------------- + +@pytest.mark.parametrize("version", [(26, 2, 2), (26, 3, 0), (27, 0, 0)]) +def test_pip_already_secure_skips_bootstrap(version, tmp_path): + with patch(f"{MOD}._get_pip_version", return_value=version): + with patch(f"{MOD}.subprocess.run") as run: + _ensure_secure_pip("python", str(tmp_path)) + run.assert_not_called() + + +def test_outdated_pip_is_upgraded_with_pinned_wheel(tmp_path): + with patch(f"{MOD}._get_pip_version", side_effect=[(24, 0, 0), (26, 2, 2)]): + with patch(f"{MOD}.subprocess.run", return_value=_ok()) as run: + _ensure_secure_pip("python", str(tmp_path)) + run.assert_called_once() + assert run.call_args.args[0] == ["python", "-m", "pip", "install", SECURE_PIP_SPEC] + assert "#sha256=" in SECURE_PIP_SPEC + + +def test_unknown_pip_version_triggers_upgrade(tmp_path): + with patch(f"{MOD}._get_pip_version", side_effect=[None, (26, 2, 2)]): + with patch(f"{MOD}.subprocess.run", return_value=_ok()) as run: + _ensure_secure_pip("python", str(tmp_path)) + run.assert_called_once() + + +def test_pip_upgrade_failure_raises_actionable_error(tmp_path): + with patch(f"{MOD}._get_pip_version", return_value=(24, 0, 0)): + with patch(f"{MOD}.subprocess.run", return_value=_fail()): + with pytest.raises(RuntimeError, match="Secure pip upgrade failed"): + _ensure_secure_pip("python", str(tmp_path)) + + +def test_pip_still_old_after_upgrade_raises(tmp_path): + with patch(f"{MOD}._get_pip_version", side_effect=[(24, 0, 0), (24, 0, 0)]): + with patch(f"{MOD}.subprocess.run", return_value=_ok()): + with pytest.raises(RuntimeError, match="still below 26.2.2"): + _ensure_secure_pip("python", str(tmp_path)) + + +def test_pip_version_unverifiable_after_upgrade_raises(tmp_path): + with patch(f"{MOD}._get_pip_version", side_effect=[(24, 0, 0), None]): + with patch(f"{MOD}.subprocess.run", return_value=_ok()): + with pytest.raises(RuntimeError, match="still below 26.2.2"): + _ensure_secure_pip("python", str(tmp_path)) + + +def test_run_pip_stops_before_installing_when_bootstrap_fails(tmp_path): + with patch(f"{MOD}.ensure_project_venv", return_value="python"): + with patch(f"{MOD}._ensure_secure_pip", side_effect=RuntimeError("boom")): + with patch(f"{MOD}.subprocess.run") as run: + with pytest.raises(RuntimeError, match="boom"): + _run_pip(["-e", "."], cwd=str(tmp_path)) + run.assert_not_called() + + +def test_run_pip_bootstraps_before_installing(tmp_path): + order: list[str] = [] + with patch(f"{MOD}.ensure_project_venv", return_value="python"): + with patch(f"{MOD}._ensure_secure_pip", side_effect=lambda *a, **k: order.append("bootstrap")): + with patch( + f"{MOD}.subprocess.run", + side_effect=lambda *a, **k: order.append("install") or _ok(), + ): + _run_pip(["-e", "."], cwd=str(tmp_path)) + assert order == ["bootstrap", "install"] + + +# ---- uv gate -------------------------------------------------------------- + +@pytest.mark.parametrize("version", [(0, 13, 4), (0, 14, 0), (1, 0, 0)]) +def test_uv_meeting_minimum_is_allowed(version): + with patch(f"{MOD}._get_uv_version", return_value=version): + _check_uv_version("/usr/bin/uv") + + +@pytest.mark.parametrize("version", [(0, 13, 3), (0, 9, 0), None]) +def test_old_or_unknown_uv_is_blocked(version): + with patch(f"{MOD}._get_uv_version", return_value=version): + with pytest.raises(RuntimeError, match="0.13.4"): + _check_uv_version("/usr/bin/uv") + + +def test_run_uv_blocked_before_any_download(tmp_path): + with patch(f"{MOD}._uv_bin", return_value="/usr/bin/uv"): + with patch(f"{MOD}._get_uv_version", return_value=(0, 13, 3)): + with patch(f"{MOD}.subprocess.run") as run: + with pytest.raises(RuntimeError, match="uv 0.13.4"): + _run_uv(["sync"], cwd=str(tmp_path)) + run.assert_not_called() + + +def test_run_uv_runs_when_version_ok(tmp_path): + with patch(f"{MOD}._uv_bin", return_value="/usr/bin/uv"): + with patch(f"{MOD}._get_uv_version", return_value=(0, 13, 4)): + with patch(f"{MOD}.subprocess.run", return_value=_ok()) as run: + _run_uv(["sync"], cwd=str(tmp_path)) + run.assert_called_once() + assert run.call_args.args[0] == ["/usr/bin/uv", "sync"] \ No newline at end of file diff --git a/tests/test_security_config.py b/tests/test_security_config.py new file mode 100644 index 0000000..b172bbf --- /dev/null +++ b/tests/test_security_config.py @@ -0,0 +1,53 @@ +"""eMudhra/emSign remediation: lockfile, dependency floor and CI workflow guards.""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest +from packaging.version import Version + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.cli.install import SECURE_PIP_SPEC # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +MIN_CERTIFI = Version("2026.10.19") + + +def test_lock_resolves_fixed_certifi(): + data = tomllib.loads((ROOT / "uv.lock").read_text(encoding="utf-8")) + versions = [Version(p["version"]) for p in data["package"] if p["name"] == "certifi"] + assert versions, "certifi missing from uv.lock" + assert all(v >= MIN_CERTIFI for v in versions), versions + + +def test_pyproject_declares_certifi_floor(): + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + assert "certifi>=2026.10.19" in data["project"]["dependencies"] + + +def test_requirements_declares_certifi_floor(): + lines = (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines() + assert "certifi>=2026.10.19" in [line.strip() for line in lines] + + +@pytest.mark.parametrize( + "name, later_command", + [ + ("test.yml", 'pip install -e ".[dev]"'), + ("publish.yml", "pip install -U build"), + ], +) +def test_workflow_bootstraps_secure_pip_before_other_installs(name, later_command): + text = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") + assert SECURE_PIP_SPEC in text, "workflow pip spec must match install.SECURE_PIP_SPEC" + assert 'python -m pip install --upgrade "certifi>=2026.10.19"' in text + assert "pip install --upgrade pip" not in text + assert text.index(SECURE_PIP_SPEC) < text.index(later_command) \ No newline at end of file diff --git a/uv.lock b/uv.lock index ed009ff..40b12e7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,10 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", + "python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')", "python_full_version < '3.11'", ] @@ -51,11 +51,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] @@ -390,7 +390,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -433,8 +433,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, - { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "h11" }, + { name = "truststore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -572,6 +572,7 @@ version = "0.3.2" source = { editable = "." } dependencies = [ { name = "anyio" }, + { name = "certifi" }, { name = "mcp" }, { name = "packaging" }, { name = "pydantic" }, @@ -586,18 +587,21 @@ dev = [ { name = "httpx" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-timeout" }, { name = "tox" }, ] [package.metadata] requires-dist = [ { name = "anyio", specifier = ">=4.0.0" }, + { name = "certifi", specifier = ">=2026.10.19" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "mcp", specifier = ">=2,<3" }, { name = "packaging", specifier = ">=23.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3.0" }, { name = "starlette", specifier = ">=0.30.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, { name = "tox", marker = "extra == 'dev'", specifier = ">=4.0.0" }, @@ -856,6 +860,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-discovery" version = "1.6.0" @@ -1049,7 +1065,7 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", + "python_full_version == '3.11.*' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')", ] sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [