From d34000ab1a7162eb12beb46f157b646b26e640dd Mon Sep 17 00:00:00 2001 From: Ching Wei Kang Date: Sat, 4 Jul 2026 09:35:21 -0500 Subject: [PATCH] fix(tls): use system trust store by default --- build/apm.spec | 3 +- build/hooks/runtime_hook_ssl_certs.py | 49 +++++++--- .../docs/troubleshooting/common-errors.md | 9 +- .../docs/troubleshooting/ssl-issues.md | 16 ++-- pyproject.toml | 1 + src/apm_cli/cli.py | 6 ++ src/apm_cli/core/tls.py | 45 +++++++++ src/apm_cli/install/validation.py | 7 +- tests/unit/test_ssl_cert_hook.py | 68 +++++++++++-- tests/unit/test_tls_truststore.py | 96 +++++++++++++++++++ uv.lock | 11 +++ 11 files changed, 274 insertions(+), 37 deletions(-) create mode 100644 src/apm_cli/core/tls.py create mode 100644 tests/unit/test_tls_truststore.py diff --git a/build/apm.spec b/build/apm.spec index 10624330e..9b2c48a21 100644 --- a/build/apm.spec +++ b/build/apm.spec @@ -187,7 +187,8 @@ hiddenimports = [ 'pathlib', 'frontmatter', 'requests', - 'certifi', # CA certificate bundle for SSL verification in frozen binary + 'truststore', # System trust store support for enterprise TLS roots + 'certifi', # Fallback CA bundle for frozen binaries # Rich modules (lazily imported, must be explicitly included) 'rich', 'rich.console', diff --git a/build/hooks/runtime_hook_ssl_certs.py b/build/hooks/runtime_hook_ssl_certs.py index 4b6462d2f..f28dd77cb 100644 --- a/build/hooks/runtime_hook_ssl_certs.py +++ b/build/hooks/runtime_hook_ssl_certs.py @@ -1,15 +1,14 @@ -# PyInstaller runtime hook -- configures SSL certificate paths for the -# frozen binary so that HTTPS connections work on every platform without -# requiring the user to install Python or set environment variables. +# PyInstaller runtime hook -- configures TLS trust for the frozen binary so +# HTTPS connections work on every platform without requiring the user to +# install Python or set environment variables. # -# Problem: PyInstaller bundles OpenSSL, but the compiled-in certificate -# search path points at the *build machine's* Python framework directory -# (e.g. /Library/Frameworks/Python.framework/...). On end-user machines -# that path rarely exists, causing SSL verification failures. +# Problem: requests defaults to certifi, while enterprise machines typically +# install corporate or internal PKI roots into the OS trust store used by git, +# curl, and browsers. Frozen binaries also have brittle OpenSSL default paths. # -# Solution: Point ``SSL_CERT_FILE`` at the certifi CA bundle shipped -# inside the frozen binary. ``requests``, ``urllib3``, and the stdlib -# ``ssl`` module all honour this variable. +# Solution: Use truststore first so urllib3/requests/stdlib HTTPS share the OS +# trust store. If truststore is unavailable, fall back to the bundled certifi +# CA bundle so frozen binaries still have a working public-web baseline. # # This hook executes before any application code so the variables are # visible to every subsequent import. @@ -17,14 +16,40 @@ import os import sys +_CA_OVERRIDE_ENV_VARS = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "SSL_CERT_DIR", +) + + +def _has_explicit_ca_override() -> bool: + """Return True when the user explicitly selected a CA bundle/path.""" + return any((os.environ.get(name) or "").strip() for name in _CA_OVERRIDE_ENV_VARS) + + +def _inject_system_trust_store() -> bool: + """Inject truststore into stdlib SSL if available.""" + try: + import truststore + + truststore.inject_into_ssl() + return True + except Exception: + return False + def _configure_ssl_certs() -> None: - """Set SSL_CERT_FILE to the bundled certifi CA bundle when frozen.""" + """Configure TLS trust for frozen binaries.""" if not getattr(sys, "frozen", False): return # Honour explicit user overrides -- never clobber them. - if os.environ.get("SSL_CERT_FILE") or os.environ.get("REQUESTS_CA_BUNDLE"): + if _has_explicit_ca_override(): + return + + if _inject_system_trust_store(): return try: diff --git a/docs/src/content/docs/troubleshooting/common-errors.md b/docs/src/content/docs/troubleshooting/common-errors.md index f43335b74..e2d3ecfcc 100644 --- a/docs/src/content/docs/troubleshooting/common-errors.md +++ b/docs/src/content/docs/troubleshooting/common-errors.md @@ -241,14 +241,15 @@ See also: [./install-failures/](./install-failures/) ### `TLS verification failed` ``` -TLS verification failed -- if you're behind a corporate proxy or -firewall, set the REQUESTS_CA_BUNDLE environment variable to the -path of your organisation's CA bundle (a PEM file) and retry. +TLS verification failed -- APM uses the system trust store by default. +If you're behind a corporate proxy or firewall, make sure your +organisation's CA is installed in the OS trust store, or set +REQUESTS_CA_BUNDLE to a readable PEM bundle and retry. ``` Cause: Python's TLS stack rejected the server certificate. Almost always a corporate proxy doing TLS interception with a CA that is not in the system trust store. -Fix: export `REQUESTS_CA_BUNDLE=/path/to/corporate-ca.pem` and retry. Do not disable TLS verification. +Fix: install the corporate CA into the OS trust store and retry. For a per-shell override, export `REQUESTS_CA_BUNDLE=/path/to/corporate-ca.pem`; `SSL_CERT_FILE` alone is not a reliable requests override. Do not disable TLS verification. See also: [./ssl-issues/](./ssl-issues/) diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index 706c80aaf..5e1b1ddb3 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -5,7 +5,7 @@ sidebar: order: 4 --- -`apm install` and `apm audit` reach out to GitHub, GHES, GitLab, Azure DevOps, and package archives over HTTPS. When the system can't verify the server certificate, the operation fails. This page maps the failure modes to fixes. +`apm install` and `apm audit` reach out to GitHub, GHES, GitLab, Azure DevOps, and package archives over HTTPS. APM uses the operating system trust store by default for Python HTTPS calls, matching the trust roots your browser, curl, and git usually use. When the system can't verify the server certificate, the operation fails. This page maps the failure modes to fixes. Related: [environment variables](../reference/environment-variables/), [install failures](./install-failures/), [security model](../enterprise/security/), [authentication](../getting-started/authentication/). @@ -14,8 +14,10 @@ Related: [environment variables](../reference/environment-variables/), [install Typical errors APM surfaces or passes through from the underlying HTTP/git stack: ```text -[!] TLS verification failed -- if you're behind a corporate proxy or - firewall, set REQUESTS_CA_BUNDLE to your organisation's CA bundle. +[!] TLS verification failed -- APM uses the system trust store by default. + If you're behind a corporate proxy or firewall, make sure your + organisation's CA is installed in the OS trust store, or set + REQUESTS_CA_BUNDLE to a readable PEM bundle. ``` ```text @@ -54,7 +56,7 @@ apm install --verbose ## Configure trust -APM uses `requests` for HTTP and shells out to `git` for repository operations. Both honour standard environment variables. Set them at the shell or in your profile (`~/.zshrc`, `~/.bashrc`, or the Windows user environment). +APM uses `truststore` so Python HTTP clients use the OS trust store by default, and it shells out to `git` for repository operations. The best long-term fix is to install the corporate or internal CA into the OS trust store. Use the environment variables below only when you need a per-shell or per-host override. ### Python HTTP layer @@ -65,7 +67,7 @@ export SSL_CERT_FILE=/path/to/ca-bundle.pem export SSL_CERT_DIR=/etc/ssl/certs ``` -`REQUESTS_CA_BUNDLE` wins for `requests`. `SSL_CERT_FILE` / `SSL_CERT_DIR` cover the rest of the Python TLS stack. +`REQUESTS_CA_BUNDLE` wins for `requests` and is the right override for APM's Python HTTP layer. `SSL_CERT_FILE` / `SSL_CERT_DIR` cover the rest of the Python TLS stack, but by themselves they are not a reliable requests override; set `REQUESTS_CA_BUNDLE` as well when you need a custom PEM bundle. ### Git operations @@ -128,7 +130,7 @@ export HTTP_PROXY=http://proxy.example.com:8080 export NO_PROXY=localhost,127.0.0.1,.internal.example.com ``` -If the proxy performs TLS interception, you also need the proxy's signing CA in the trust store - see [Configure trust](#configure-trust). Importing the CA into the OS trust store (Keychain on macOS, `update-ca-certificates` on Debian/Ubuntu, `update-ca-trust` on RHEL, the Trusted Root store on Windows) is the most durable fix; consult your OS documentation rather than copying steps from here. +If the proxy performs TLS interception, you also need the proxy's signing CA in the trust store - see [Configure trust](#configure-trust). Importing the CA into the OS trust store (Keychain on macOS, `update-ca-certificates` on Debian/Ubuntu, `update-ca-trust` on RHEL, the Trusted Root store on Windows) is the most durable fix and is the path APM uses by default; consult your OS documentation rather than copying steps from here. ## Verify the fix @@ -170,6 +172,6 @@ unset GIT_SSL_NO_VERIFY PYTHONHTTPSVERIFY [>] Re-run with `--verbose` and capture the full exception chain. [>] Check `curl -v https://` from the same shell - if it fails, the problem is the system trust store, not APM. -[>] Confirm `REQUESTS_CA_BUNDLE` and `GIT_SSL_CAINFO` point at a readable PEM file (`openssl x509 -in $REQUESTS_CA_BUNDLE -noout -subject` should print a subject line). +[>] If using overrides, confirm `REQUESTS_CA_BUNDLE` and `GIT_SSL_CAINFO` point at a readable PEM file (`openssl x509 -in $REQUESTS_CA_BUNDLE -noout -subject` should print a subject line). [>] If only one host fails, see [GHES and GitLab self-managed](#ghes-and-gitlab-self-managed) and the per-host `git config` recipe above. [>] If the install proceeds past TLS but then fails, continue at [install failures](./install-failures/). diff --git a/pyproject.toml b/pyproject.toml index 87f17e639..fdc3f0be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "colorama>=0.4.6", "pyyaml>=6.0.0", "requests>=2.31.0", + "truststore>=0.10.0", "python-frontmatter>=1.0.0", "llm>=0.28", "llm-github-models>=0.18.0", diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index e2553cf51..e8fafa3e7 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -3,6 +3,8 @@ Thin wiring layer -- all command logic lives in ``apm_cli.commands.*`` modules. """ +# ruff: noqa: E402 + import ctypes import logging import os @@ -11,6 +13,10 @@ import click +from apm_cli.core.tls import configure_system_trust_store + +configure_system_trust_store() + from apm_cli.commands._helpers import ( ERROR, RESET, diff --git a/src/apm_cli/core/tls.py b/src/apm_cli/core/tls.py new file mode 100644 index 000000000..dbe96b3ea --- /dev/null +++ b/src/apm_cli/core/tls.py @@ -0,0 +1,45 @@ +"""TLS trust-store bootstrap helpers for the APM CLI.""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping + +_CA_OVERRIDE_ENV_VARS = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "SSL_CERT_DIR", +) + + +def has_explicit_ca_override(env: Mapping[str, str] | None = None) -> bool: + """Return True when the user explicitly selected a CA bundle/path.""" + environ = os.environ if env is None else env + return any((environ.get(name) or "").strip() for name in _CA_OVERRIDE_ENV_VARS) + + +def configure_system_trust_store(env: Mapping[str, str] | None = None) -> bool: + """Make Python HTTPS clients use the OS trust store by default. + + APM is an application, so using truststore's stdlib injection is appropriate + as long as it happens before network libraries construct SSL contexts. + Explicit CA environment variables continue to win for edge cases. + """ + if has_explicit_ca_override(env): + return False + + try: + import truststore + except ImportError as exc: + logging.getLogger(__name__).debug("truststore unavailable: %s", exc) + return False + + try: + truststore.inject_into_ssl() + except Exception as exc: # pragma: no cover - defensive against platform APIs + logging.getLogger(__name__).debug("truststore injection failed: %s", exc) + return False + + return True diff --git a/src/apm_cli/install/validation.py b/src/apm_cli/install/validation.py index 3546f2bd6..1d198c1e8 100644 --- a/src/apm_cli/install/validation.py +++ b/src/apm_cli/install/validation.py @@ -66,9 +66,10 @@ def _log_tls_failure(host_display: str, exc: BaseException, verbose_log, logger) Verbose: also include the host name and the underlying exception text. """ logger.warning( - "TLS verification failed -- if you're behind a corporate proxy or " - "firewall, set the REQUESTS_CA_BUNDLE environment variable to the " - "path of your organisation's CA bundle (a PEM file) and retry. " + "TLS verification failed -- APM uses the system trust store by default. " + "If you're behind a corporate proxy or firewall, make sure your " + "organisation's CA is installed in the OS trust store, or set " + "REQUESTS_CA_BUNDLE to a readable PEM bundle and retry. " "See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" ) if verbose_log: diff --git a/tests/unit/test_ssl_cert_hook.py b/tests/unit/test_ssl_cert_hook.py index 1c4d0b708..d761f2217 100644 --- a/tests/unit/test_ssl_cert_hook.py +++ b/tests/unit/test_ssl_cert_hook.py @@ -100,35 +100,81 @@ def test_respects_existing_requests_ca_bundle(self, monkeypatch): assert "SSL_CERT_FILE" not in os.environ - # -- Happy path: frozen + certifi available ------------------------------ + def test_respects_existing_curl_ca_bundle(self, monkeypatch): + """If the user already set CURL_CA_BUNDLE, do not set SSL_CERT_FILE.""" + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.setenv("CURL_CA_BUNDLE", "/custom/curl-bundle.pem") + + fn = _get_configure_fn() + fn() + + assert "SSL_CERT_FILE" not in os.environ + + def test_respects_existing_ssl_cert_dir(self, monkeypatch): + """If the user already set SSL_CERT_DIR, do not set SSL_CERT_FILE.""" + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + monkeypatch.setenv("SSL_CERT_DIR", "/custom/certs") + + fn = _get_configure_fn() + fn() - def test_sets_ssl_cert_file_when_frozen(self, monkeypatch, tmp_path): - """In a frozen binary with certifi, SSL_CERT_FILE is set automatically.""" + assert "SSL_CERT_FILE" not in os.environ + + # -- Happy path: frozen + truststore available ---------------------------- + + def test_injects_truststore_when_frozen(self, monkeypatch): + """In a frozen binary, truststore is used before certifi fallback.""" + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + + mock_truststore = MagicMock() + + with patch.dict("sys.modules", {"truststore": mock_truststore}): + fn = _get_configure_fn() + fn() + + assert mock_truststore.inject_into_ssl.called + assert "SSL_CERT_FILE" not in os.environ + + # -- Fallback: truststore/certifi unavailable ----------------------------- + + def test_sets_ssl_cert_file_when_truststore_missing(self, monkeypatch, tmp_path): + """If truststore is unavailable, frozen binaries fall back to certifi.""" ca_file = tmp_path / "cacert.pem" ca_file.write_text("--- dummy CA bundle ---") monkeypatch.setattr(sys, "frozen", True, raising=False) monkeypatch.delenv("SSL_CERT_FILE", raising=False) monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) mock_certifi = MagicMock() mock_certifi.where.return_value = str(ca_file) - with patch.dict("sys.modules", {"certifi": mock_certifi}): + with patch.dict("sys.modules", {"truststore": None, "certifi": mock_certifi}): fn = _get_configure_fn() fn() assert os.environ.get("SSL_CERT_FILE") == str(ca_file) - # -- Fallback: certifi missing ------------------------------------------- - - def test_graceful_when_certifi_missing(self, monkeypatch): - """If certifi is not importable, the hook silently continues.""" + def test_graceful_when_truststore_and_certifi_missing(self, monkeypatch): + """If both truststore and certifi are unavailable, continue silently.""" monkeypatch.setattr(sys, "frozen", True, raising=False) monkeypatch.delenv("SSL_CERT_FILE", raising=False) monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) - with patch.dict("sys.modules", {"certifi": None}): + with patch.dict("sys.modules", {"truststore": None, "certifi": None}): fn = _get_configure_fn() fn() # must not raise @@ -141,11 +187,13 @@ def test_skips_when_ca_file_missing(self, monkeypatch, tmp_path): monkeypatch.setattr(sys, "frozen", True, raising=False) monkeypatch.delenv("SSL_CERT_FILE", raising=False) monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) mock_certifi = MagicMock() mock_certifi.where.return_value = str(tmp_path / "does_not_exist.pem") - with patch.dict("sys.modules", {"certifi": mock_certifi}): + with patch.dict("sys.modules", {"truststore": None, "certifi": mock_certifi}): fn = _get_configure_fn() fn() diff --git a/tests/unit/test_tls_truststore.py b/tests/unit/test_tls_truststore.py new file mode 100644 index 000000000..bea4f878a --- /dev/null +++ b/tests/unit/test_tls_truststore.py @@ -0,0 +1,96 @@ +"""Tests for APM's process-wide TLS trust-store bootstrap.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import types +from pathlib import Path +from unittest.mock import Mock, patch + +from apm_cli.core.tls import configure_system_trust_store, has_explicit_ca_override + + +def _repo_root() -> Path: + current = Path(__file__).resolve().parent + for parent in [current] + list(current.parents): # noqa: RUF005 + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("Cannot locate repository root") + + +def test_has_explicit_ca_override_detects_supported_env_vars() -> None: + assert has_explicit_ca_override({"REQUESTS_CA_BUNDLE": "corp.pem"}) is True + assert has_explicit_ca_override({"CURL_CA_BUNDLE": "corp.pem"}) is True + assert has_explicit_ca_override({"SSL_CERT_FILE": "corp.pem"}) is True + assert has_explicit_ca_override({"SSL_CERT_DIR": "/etc/ssl/certs"}) is True + assert has_explicit_ca_override({"REQUESTS_CA_BUNDLE": " "}) is False + + +def test_configure_system_trust_store_injects_without_override() -> None: + fake_truststore = types.SimpleNamespace(inject_into_ssl=Mock()) + + with patch.dict(sys.modules, {"truststore": fake_truststore}): + assert configure_system_trust_store(env={}) is True + + fake_truststore.inject_into_ssl.assert_called_once_with() + + +def test_configure_system_trust_store_respects_explicit_override() -> None: + fake_truststore = types.SimpleNamespace(inject_into_ssl=Mock()) + + with patch.dict(sys.modules, {"truststore": fake_truststore}): + assert configure_system_trust_store(env={"REQUESTS_CA_BUNDLE": "corp.pem"}) is False + + fake_truststore.inject_into_ssl.assert_not_called() + + +def test_configure_system_trust_store_graceful_when_unavailable() -> None: + with patch.dict(sys.modules, {"truststore": None}): + assert configure_system_trust_store(env={}) is False + + +def test_cli_import_injects_truststore_before_requests(tmp_path) -> None: + sentinel = tmp_path / "sentinel.txt" + fake_truststore = tmp_path / "truststore.py" + fake_truststore.write_text( + "\n".join( + [ + "import os", + "import pathlib", + "import sys", + "", + "def inject_into_ssl():", + " pathlib.Path(os.environ['TRUSTSTORE_SENTINEL']).write_text(", + " 'requests_imported=' + str('requests' in sys.modules),", + " encoding='utf-8',", + " )", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + for name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR"): + env.pop(name, None) + env["TRUSTSTORE_SENTINEL"] = str(sentinel) + env["PYTHONPATH"] = os.pathsep.join( + [ + str(tmp_path), + str(_repo_root() / "src"), + env.get("PYTHONPATH", ""), + ] + ) + + result = subprocess.run( + [sys.executable, "-c", "import apm_cli.cli"], + cwd=_repo_root(), + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert sentinel.read_text(encoding="utf-8") == "requests_imported=False" diff --git a/uv.lock b/uv.lock index 1a3612936..26b9ac3fd 100644 --- a/uv.lock +++ b/uv.lock @@ -218,6 +218,7 @@ dependencies = [ { name = "ruamel-yaml" }, { name = "toml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "truststore" }, { name = "watchdog" }, { name = "websockets" }, ] @@ -262,6 +263,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, { name = "toml", specifier = ">=0.10.2" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.2.0" }, + { name = "truststore", specifier = ">=0.10.0" }, { name = "watchdog", specifier = ">=3.0.0" }, { name = "websockets", specifier = ">=12,<17" }, ] @@ -1963,6 +1965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"