diff --git a/.taskcluster.yml b/.taskcluster.yml index fadc67c16d..89b15ec6c5 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -175,6 +175,8 @@ tasks: uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=hackbot_client --cov-append tests/ && cd ../testrail-client && uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=testrail_client --cov-append tests/ && + cd ../github-client && + uv run --locked --with pytest==9.1.0 --with pytest-cov==7.1.0 --with pytest-asyncio==1.4.0 pytest --cov=github_client --cov-append tests/ && cd ../.. && bash <(curl -s https://codecov.io/bash)" metadata: diff --git a/libs/github-client/github_client/__init__.py b/libs/github-client/github_client/__init__.py new file mode 100644 index 0000000000..af5dc290a7 --- /dev/null +++ b/libs/github-client/github_client/__init__.py @@ -0,0 +1,15 @@ +from github_client.client import ( + READ_ONLY_PERMISSIONS, + READ_WRITE_PERMISSIONS, + GitHubApp, + InstallationToken, +) +from github_client.config import GitHubAppSettings + +__all__ = [ + "READ_ONLY_PERMISSIONS", + "READ_WRITE_PERMISSIONS", + "GitHubApp", + "GitHubAppSettings", + "InstallationToken", +] diff --git a/libs/github-client/github_client/client.py b/libs/github-client/github_client/client.py new file mode 100644 index 0000000000..2a2068a165 --- /dev/null +++ b/libs/github-client/github_client/client.py @@ -0,0 +1,143 @@ +"""GitHub App authentication on top of githubkit. + +githubkit signs the App JWT and mints, caches and refreshes installation tokens +for the clients returned here. :meth:`GitHubApp.installation_token` is for +callers that need the raw token instead, e.g. ``git push`` or a subprocess. +""" + +import asyncio +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from types import MappingProxyType +from typing import Any, cast + +from githubkit import AppAuthStrategy, AppInstallationAuthStrategy, GitHub +from githubkit.cache import MemCacheStrategy +from githubkit.utils import UNSET +from githubkit_schemas.latest.types import AppPermissionsType + +from github_client.config import GitHubAppSettings + +# Requesting a permission the App wasn't granted fails the mint with a 422, so +# keep these within what the App is configured with. Read-only, so no caller can +# widen them for the whole process. +READ_ONLY_PERMISSIONS: Mapping[str, str] = MappingProxyType( + {"contents": "read", "pull_requests": "read"} +) +READ_WRITE_PERMISSIONS: Mapping[str, str] = MappingProxyType( + {"contents": "write", "pull_requests": "write"} +) + +_PERMISSION_NAMES = ( + AppPermissionsType.__required_keys__ | AppPermissionsType.__optional_keys__ +) + + +@dataclass(frozen=True) +class InstallationToken: + token: str = field(repr=False) + expires_at: datetime + + +class GitHubApp: + """Authenticates as a GitHub App and its installations. + + Extra keyword arguments are passed through to :class:`githubkit.GitHub` + (e.g. ``base_url``, ``timeout``). + """ + + def __init__( + self, + settings: GitHubAppSettings, + *, + refresh_margin: timedelta = timedelta(minutes=5), + **github_options: Any, + ): + self._settings = settings + self._auth = AppAuthStrategy( + settings.app_id, settings.private_key.get_secret_value() + ) + # Our own cache rather than githubkit's process-wide default, so tokens + # are never shared between GitHubApp instances. + self._github_options = {"cache_strategy": MemCacheStrategy(), **github_options} + self._refresh_margin = refresh_margin + self._tokens: dict[tuple, InstallationToken] = {} + self._lock = asyncio.Lock() + + def as_app(self) -> GitHub[AppAuthStrategy]: + """A client authenticated as the App itself, for the ``/app`` endpoints.""" + return GitHub(self._auth, **self._github_options) + + def as_installation( + self, + installation_id: int | None = None, + *, + permissions: Mapping[str, str], + repositories: Sequence[str] | None = None, + ) -> GitHub[AppInstallationAuthStrategy]: + """A client authenticated as an installation, refreshing its own token. + + ``permissions`` and ``repositories`` (names, without the owner) narrow + the token below what the installation was granted. + """ + auth = self._auth.as_installation( + self._resolve_installation_id(installation_id), + repositories=UNSET if repositories is None else list(repositories), + permissions=_check_permissions(permissions), + ) + return GitHub(auth, **self._github_options) + + async def installation_token( + self, + installation_id: int | None = None, + *, + permissions: Mapping[str, str], + repositories: Sequence[str] | None = None, + ) -> InstallationToken: + """A raw installation token, reused until it is close to expiring.""" + installation_id = self._resolve_installation_id(installation_id) + permissions = _check_permissions(permissions) + key = ( + installation_id, + tuple(sorted(permissions.items())), + None if repositories is None else tuple(sorted(repositories)), + ) + async with self._lock: + cached = self._tokens.get(key) + if cached and cached.expires_at - datetime.now(UTC) > self._refresh_margin: + return cached + + # githubkit's ``rest`` namespace only holds a weak reference to its + # client, so the client must outlive the call. + github = self.as_app() + response = await github.rest.apps.async_create_installation_access_token( + installation_id, + permissions=permissions, + repositories=UNSET if repositories is None else list(repositories), + ) + minted = InstallationToken( + token=response.parsed_data.token, + expires_at=datetime.fromisoformat(response.parsed_data.expires_at), + ) + self._tokens[key] = minted + return minted + + def _resolve_installation_id(self, installation_id: int | None) -> int: + installation_id = installation_id or self._settings.installation_id + if installation_id is None: + raise ValueError( + "No GitHub App installation ID: pass one or set GITHUB_INSTALLATION_ID." + ) + return installation_id + + +def _check_permissions(permissions: Mapping[str, str]) -> AppPermissionsType: + # A token minted without permissions gets everything the App has, and + # githubkit silently drops names it doesn't know, so an empty set or a typo + # could widen the token instead of narrowing it. + if not permissions: + raise ValueError("permissions must name at least one permission") + if unknown := permissions.keys() - _PERMISSION_NAMES: + raise ValueError(f"Unknown GitHub App permissions: {sorted(unknown)}") + return cast(AppPermissionsType, dict(permissions)) diff --git a/libs/github-client/github_client/config.py b/libs/github-client/github_client/config.py new file mode 100644 index 0000000000..410a7b2447 --- /dev/null +++ b/libs/github-client/github_client/config.py @@ -0,0 +1,51 @@ +"""Configuration for :class:`GitHubApp`. + +``GitHubAppSettings`` is a plain, validated config model with no env I/O, so it +can be embedded in a larger settings object without triggering a second +environment parse. Use :meth:`GitHubAppSettings.from_env` for standalone, +env-driven config. +""" + +from typing import Annotated + +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class GitHubAppSettings(BaseModel): + # Never echo the private key back in a validation error. + model_config = ConfigDict(hide_input_in_errors=True) + + app_id: Annotated[int, Field(gt=0)] + private_key: SecretStr + # Default installation for callers that don't pass one, e.g. a deployment + # that only ever talks to one org. + installation_id: Annotated[int | None, Field(gt=0)] = None + + @field_validator("private_key") + @classmethod + def _check_private_key(cls, value: SecretStr) -> SecretStr: + # Env vars and secret stores often flatten the PEM's newlines to "\n". + pem = value.get_secret_value().replace("\\n", "\n").strip() + try: + key = load_pem_private_key(pem.encode(), password=None) + except ValueError, TypeError: + key = None + if not isinstance(key, RSAPrivateKey): + raise ValueError( + "must be the GitHub App's PEM-encoded RSA private key " + "(-----BEGIN RSA PRIVATE KEY----- ...)" + ) + return SecretStr(pem) + + @classmethod + def from_env(cls) -> GitHubAppSettings: + return _GitHubAppEnvSettings() + + +class _GitHubAppEnvSettings(GitHubAppSettings, BaseSettings): + model_config = SettingsConfigDict( + env_prefix="GITHUB_", extra="ignore", hide_input_in_errors=True + ) diff --git a/libs/github-client/pyproject.toml b/libs/github-client/pyproject.toml new file mode 100644 index 0000000000..f6d28b83c3 --- /dev/null +++ b/libs/github-client/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "github-client" +version = "0.1.0" +description = "Shared GitHub App auth for hackbot (githubkit-based)" +requires-python = ">=3.14" +dependencies = [ + "cryptography>=42.0.0", + "githubkit[auth-app]>=0.16.0", + "pydantic-settings>=2.1.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["github_client"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/libs/github-client/tests/test_client.py b/libs/github-client/tests/test_client.py new file mode 100644 index 0000000000..a7aa949a7b --- /dev/null +++ b/libs/github-client/tests/test_client.py @@ -0,0 +1,315 @@ +"""Tests for the shared GitHub App auth helper.""" + +import asyncio +import json +from datetime import UTC, datetime, timedelta + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from github_client import ( + READ_ONLY_PERMISSIONS, + READ_WRITE_PERMISSIONS, + GitHubApp, + GitHubAppSettings, +) +from pydantic import ValidationError + +APP_ID = 123 +INSTALLATION_ID = 456 + + +@pytest.fixture(scope="module") +def rsa_key() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(scope="module") +def pem(rsa_key) -> str: + return rsa_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ).decode() + + +class _FakeGitHub: + """Stands in for api.github.com, minting a numbered token per request.""" + + def __init__(self, expires_in: timedelta = timedelta(hours=1)): + self.expires_in = expires_in + self.requests: list[httpx.Request] = [] + + @property + def mint_requests(self) -> list[httpx.Request]: + return [r for r in self.requests if r.url.path.endswith("/access_tokens")] + + async def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if request.url.path.endswith("/access_tokens"): + # Yield like a real request would, so concurrent callers interleave. + await asyncio.sleep(0) + expires_at = datetime.now(UTC) + self.expires_in + return httpx.Response( + 201, + json={ + "token": f"ghs_{len(self.mint_requests)}", + "expires_at": expires_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + ) + return httpx.Response(200, json={}) + + +def _app(pem: str, fake: _FakeGitHub, **settings) -> GitHubApp: + settings = {"app_id": APP_ID, "private_key": pem, **settings} + return GitHubApp( + GitHubAppSettings(**settings), + async_transport=httpx.MockTransport(fake), + http_cache=False, + ) + + +def _set_env(monkeypatch, **env: str) -> None: + for name in ("GITHUB_APP_ID", "GITHUB_PRIVATE_KEY", "GITHUB_INSTALLATION_ID"): + monkeypatch.delenv(name, raising=False) + for name, value in env.items(): + monkeypatch.setenv(f"GITHUB_{name}", value) + + +def test_settings_load_from_env(monkeypatch, pem): + _set_env(monkeypatch, APP_ID="123", PRIVATE_KEY=pem, INSTALLATION_ID="456") + settings = GitHubAppSettings.from_env() + assert settings.app_id == 123 + assert settings.installation_id == 456 + assert settings.private_key.get_secret_value() == pem.strip() + + +def test_settings_accept_a_private_key_with_escaped_newlines(monkeypatch, pem): + _set_env(monkeypatch, APP_ID="123", PRIVATE_KEY=pem.replace("\n", "\\n")) + settings = GitHubAppSettings.from_env() + assert settings.private_key.get_secret_value() == pem.strip() + + +@pytest.mark.parametrize("missing", ["APP_ID", "PRIVATE_KEY"]) +def test_settings_require_app_id_and_private_key(monkeypatch, pem, missing): + env = {"APP_ID": "123", "PRIVATE_KEY": pem} + del env[missing] + _set_env(monkeypatch, **env) + with pytest.raises(ValidationError, match=missing.lower()): + GitHubAppSettings.from_env() + + +@pytest.mark.parametrize("app_id", ["abc", "0", "-1"]) +def test_settings_reject_an_invalid_app_id(monkeypatch, pem, app_id): + _set_env(monkeypatch, APP_ID=app_id, PRIVATE_KEY=pem) + with pytest.raises(ValidationError, match="app_id"): + GitHubAppSettings.from_env() + + +def _ec_pem() -> str: + return ( + ec.generate_private_key(ec.SECP256R1()) + .private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + .decode() + ) + + +@pytest.mark.parametrize("private_key", ["not-a-key", "", _ec_pem()]) +def test_settings_reject_anything_but_an_rsa_private_key(monkeypatch, private_key): + _set_env(monkeypatch, APP_ID="123", PRIVATE_KEY=private_key) + with pytest.raises(ValidationError, match="PEM-encoded RSA private key") as exc: + GitHubAppSettings.from_env() + assert "input_value" not in str(exc.value) + + +def test_settings_repr_hides_the_private_key(pem): + settings = GitHubAppSettings(app_id=APP_ID, private_key=pem) + assert "PRIVATE KEY" not in repr(settings) + + +async def test_app_client_signs_a_short_lived_jwt(pem, rsa_key): + fake = _FakeGitHub() + await _app(pem, fake).as_app().arequest("GET", "/app") + + scheme, token = fake.requests[0].headers["Authorization"].split() + assert scheme == "Bearer" + # Also verifies the signature and that the token hasn't expired. + claims = jwt.decode(token, rsa_key.public_key(), algorithms=["RS256"]) + assert claims["iss"] == str(APP_ID) + assert claims["exp"] - claims["iat"] <= 10 * 60 + + +@pytest.mark.parametrize( + "permissions", [READ_ONLY_PERMISSIONS, READ_WRITE_PERMISSIONS], ids=["ro", "rw"] +) +async def test_installation_token_requests_the_given_permissions( + pem, rsa_key, permissions +): + fake = _FakeGitHub() + token = await _app(pem, fake).installation_token( + INSTALLATION_ID, permissions=permissions + ) + + assert token.token == "ghs_1" + assert token.expires_at > datetime.now(UTC) + [request] = fake.requests + assert request.method == "POST" + assert request.url.path == f"/app/installations/{INSTALLATION_ID}/access_tokens" + assert json.loads(request.content) == {"permissions": permissions} + _, jwt_token = request.headers["Authorization"].split() + assert jwt.decode(jwt_token, rsa_key.public_key(), algorithms=["RS256"]) + + +def test_permission_presets(): + assert set(READ_ONLY_PERMISSIONS.values()) == {"read"} + assert set(READ_WRITE_PERMISSIONS.values()) == {"write"} + assert READ_ONLY_PERMISSIONS.keys() == READ_WRITE_PERMISSIONS.keys() + + +@pytest.mark.parametrize("preset", [READ_ONLY_PERMISSIONS, READ_WRITE_PERMISSIONS]) +def test_permission_presets_cannot_be_widened(preset): + with pytest.raises(TypeError): + preset["administration"] = "write" + + +async def test_installation_token_can_be_limited_to_repositories(pem): + fake = _FakeGitHub() + await _app(pem, fake).installation_token( + INSTALLATION_ID, permissions=READ_ONLY_PERMISSIONS, repositories=["repo"] + ) + body = json.loads(fake.requests[0].content) + assert body == {"permissions": READ_ONLY_PERMISSIONS, "repositories": ["repo"]} + + +@pytest.mark.parametrize( + ("permissions", "error"), + [({}, "at least one"), ({"pull_request": "read"}, "pull_request")], + ids=["empty", "typo"], +) +async def test_permissions_that_would_widen_the_token_are_refused( + pem, permissions, error +): + fake = _FakeGitHub() + app = _app(pem, fake) + with pytest.raises(ValueError, match=error): + await app.installation_token(INSTALLATION_ID, permissions=permissions) + with pytest.raises(ValueError, match=error): + app.as_installation(INSTALLATION_ID, permissions=permissions) + assert fake.requests == [] + + +async def test_installation_id_defaults_to_the_settings(pem): + fake = _FakeGitHub() + await _app(pem, fake, installation_id=789).installation_token( + permissions=READ_ONLY_PERMISSIONS + ) + assert fake.requests[0].url.path == "/app/installations/789/access_tokens" + + +async def test_installation_id_is_required_somewhere(pem): + with pytest.raises(ValueError, match="GITHUB_INSTALLATION_ID"): + await _app(pem, _FakeGitHub()).installation_token( + permissions=READ_ONLY_PERMISSIONS + ) + + +async def test_fresh_installation_token_is_reused(pem): + fake = _FakeGitHub(expires_in=timedelta(hours=1)) + app = _app(pem, fake) + + first = await app.installation_token( + INSTALLATION_ID, permissions=READ_ONLY_PERMISSIONS + ) + second = await app.installation_token( + INSTALLATION_ID, permissions=READ_ONLY_PERMISSIONS + ) + + assert first == second + assert len(fake.mint_requests) == 1 + + +async def test_near_expiry_installation_token_is_refreshed(pem): + fake = _FakeGitHub(expires_in=timedelta(minutes=2)) + app = _app(pem, fake) + + first = await app.installation_token( + INSTALLATION_ID, permissions=READ_ONLY_PERMISSIONS + ) + second = await app.installation_token( + INSTALLATION_ID, permissions=READ_ONLY_PERMISSIONS + ) + + assert (first.token, second.token) == ("ghs_1", "ghs_2") + assert len(fake.mint_requests) == 2 + + +async def test_concurrent_callers_share_one_mint(pem): + fake = _FakeGitHub() + app = _app(pem, fake) + + tokens = await asyncio.gather( + *( + app.installation_token(INSTALLATION_ID, permissions=READ_ONLY_PERMISSIONS) + for _ in range(5) + ) + ) + + assert len(set(tokens)) == 1 + assert len(fake.mint_requests) == 1 + + +async def test_installation_tokens_are_cached_per_scope(pem): + fake = _FakeGitHub() + app = _app(pem, fake) + + read_only = await app.installation_token( + INSTALLATION_ID, permissions=READ_ONLY_PERMISSIONS + ) + read_write = await app.installation_token( + INSTALLATION_ID, permissions=READ_WRITE_PERMISSIONS + ) + again = await app.installation_token( + INSTALLATION_ID, permissions=dict(reversed(READ_ONLY_PERMISSIONS.items())) + ) + + assert read_only != read_write + assert again == read_only + assert len(fake.mint_requests) == 2 + + +async def test_installation_client_uses_a_downscoped_token(pem): + fake = _FakeGitHub() + gh = _app(pem, fake).as_installation( + INSTALLATION_ID, permissions=READ_ONLY_PERMISSIONS + ) + + await gh.arequest("GET", "/repos/owner/repo/pulls/1") + + mint, call = fake.requests + assert json.loads(mint.content) == {"permissions": READ_ONLY_PERMISSIONS} + assert call.url.path == "/repos/owner/repo/pulls/1" + assert call.headers["Authorization"] == "token ghs_1" + + +@pytest.mark.parametrize( + ("expires_in", "mints"), + [(timedelta(hours=1), 1), (timedelta(seconds=30), 2)], + ids=["fresh", "near-expiry"], +) +async def test_installation_client_refreshes_its_token(pem, expires_in, mints): + fake = _FakeGitHub(expires_in=expires_in) + gh = _app(pem, fake).as_installation( + INSTALLATION_ID, permissions=READ_ONLY_PERMISSIONS + ) + + await gh.arequest("GET", "/repos/owner/repo/pulls/1") + await gh.arequest("GET", "/repos/owner/repo/pulls/1") + + assert len(fake.mint_requests) == mints + assert fake.requests[-1].headers["Authorization"] == f"token ghs_{mints}" diff --git a/uv.lock b/uv.lock index 0086705e48..45dad4cefe 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,7 @@ members = [ "bugbug", "bugbug-http-service", "bugbug-mcp", + "github-client", "hackbot-agent-autowebcompat-diagnosis", "hackbot-agent-autowebcompat-intervention", "hackbot-agent-autowebcompat-repro", @@ -351,6 +352,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "anysqlite" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432, upload-time = "2023-10-02T13:49:25.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907, upload-time = "2023-10-02T13:49:26.943Z" }, +] + [[package]] name = "apispec" version = "6.10.0" @@ -1784,6 +1797,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/70/037e1fed8c40b185c8769e137c3be2ab3c19766471462514cd09d0eb022e/fxpoppet-0.4.1-py3-none-any.whl", hash = "sha256:f8d75e5a3b128aa7e78f6a93c2c60443f163694607027809c1acb279c754aaef", size = 49288, upload-time = "2025-11-19T21:58:39.789Z" }, ] +[[package]] +name = "github-client" +version = "0.1.0" +source = { editable = "libs/github-client" } +dependencies = [ + { name = "cryptography" }, + { name = "githubkit", extra = ["auth-app"] }, + { name = "pydantic-settings" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=42.0.0" }, + { name = "githubkit", extras = ["auth-app"], specifier = ">=0.16.0" }, + { name = "pydantic-settings", specifier = ">=2.1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "githubkit" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "githubkit-schemas" }, + { name = "hishel", extra = ["async", "httpx"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/a8/ebfcbddccb3e813837600f4cdb79fc462fd284c1db03abc1ad31072d0757/githubkit-0.16.1.tar.gz", hash = "sha256:f56ea4e796f169773d114c7f39089790f99443f1d05871d594d6bdca7928945c", size = 36999, upload-time = "2026-08-14T07:46:05.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/eb/ee6424e6d257433a8a62e86016cdbce6efdbd26d7db5735c5c099da85e44/githubkit-0.16.1-py3-none-any.whl", hash = "sha256:a791e0eb57ec80824a711321376c5816aa1cd204e6982e4ad5ddce6c87490863", size = 42935, upload-time = "2026-08-14T07:46:04.167Z" }, +] + +[package.optional-dependencies] +auth-app = [ + { name = "pyjwt", extra = ["crypto"] }, +] + +[[package]] +name = "githubkit-schemas" +version = "26.10.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "githubkit" }, + { name = "githubkit-schemas-2026-03-10" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/c7/d9c1b6a7c9c27f576865e553f506c76468d90aa62d288b67019a7e3d75ec/githubkit_schemas-26.10.21.tar.gz", hash = "sha256:db1d50a983a42f680728e996e2ea8d5d29bca069dff9e2ba42538ac04996c706", size = 133192, upload-time = "2026-10-21T12:03:19.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/42/d46642456c963fd15be017d960e46b5a1f8dfe0b7a71154c9ad5ddb2ea52/githubkit_schemas-26.10.21-py3-none-any.whl", hash = "sha256:c7bdacbcb9ebb927acee77ca1e98c30826cce72242395fd1d7e781a8c9790f88", size = 135469, upload-time = "2026-10-21T12:03:10.741Z" }, +] + +[[package]] +name = "githubkit-schemas-2026-03-10" +version = "26.10.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "githubkit-schemas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/33/e10bf271bc4394033ae0e6465d84da1a881a3170d0ff7bfa2ca81269ae79/githubkit_schemas_2026_03_10-26.10.21.tar.gz", hash = "sha256:c1bceb0e6a997260f7d92d4063289e051c85d2eb857b9e01dd051660b8b32114", size = 1567622, upload-time = "2026-10-21T12:03:22.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/a2/019937870e0097e48bdff41ac3f4a2e33aa299bc50d336df93228be56c47/githubkit_schemas_2026_03_10-26.10.21-py3-none-any.whl", hash = "sha256:3faed30ed851fcc0b22c66c522c736a9dfb9add70ccdf685ee1d0787817bab4b", size = 3631914, upload-time = "2026-10-21T12:03:13.947Z" }, +] + [[package]] name = "glean-parser" version = "17.3.0" @@ -2766,6 +2852,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, ] +[[package]] +name = "hishel" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/ff/efba29288e1d304359491fa3aab83174c3ef24a63819e6acad6873d8c0a9/hishel-1.4.0.tar.gz", hash = "sha256:e406b052c658ce28629fc31423b9914b637d47d7c923f5fe9e288ec47e4823d1", size = 813499, upload-time = "2026-09-16T15:38:54.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/c5/2cf7b80da9833de6ae5099111b64cf541830ccb4ef26605ae86f23ed5604/hishel-1.4.0-py3-none-any.whl", hash = "sha256:38cb62d7d38fb437dc086f789c26d0cab41310d12497ec3f56dcead4b08571f0", size = 80795, upload-time = "2026-09-16T15:38:52.545Z" }, +] + +[package.optional-dependencies] +async = [ + { name = "anyio" }, + { name = "anysqlite" }, +] +httpx = [ + { name = "anyio" }, + { name = "anysqlite" }, + { name = "httpx" }, +] + [[package]] name = "hpack" version = "4.2.0" @@ -4160,6 +4270,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "msgpack" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/52/1ec7ead2c7528bf3d1db73e886fa4df10f9e6f0eb7854a448b7090cfcef7/msgpack-1.2.4.tar.gz", hash = "sha256:475c44e12f2a5de766a8448783f149c9b33f3f49b88953c6cd739a77d93c732c", size = 197764, upload-time = "2026-10-26T17:55:48.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/34/747f9ea2b428088201c73fcb3a885cebcbedcd71def0f1f3c36896294b27/msgpack-1.2.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:895e9369185df4a874768849405c30cba7990caceb3fcb1692ebe62bb050f55e", size = 92449, upload-time = "2026-10-26T17:54:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/83/7e/52d208cb1561f869762c17be0e2c004e4d34f2842d239a450492d6246d62/msgpack-1.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c97f5f17407e1761f23f1944be33cbf1d5ce67017db32544dcd5e16e0b1b4a3b", size = 91202, upload-time = "2026-10-26T17:54:38.797Z" }, + { url = "https://files.pythonhosted.org/packages/2e/58/5516cabb60da83506024015c669622a0277979aef51eb611308cebaf4cc9/msgpack-1.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2444a4e04f924630d059ae6eae8f91fdfcd28ed9fd17b21bacf4e74550a091f7", size = 455185, upload-time = "2026-10-26T17:54:40.213Z" }, + { url = "https://files.pythonhosted.org/packages/39/8f/bf975b5d2111e4954a0f8567fc6b60bab22c8766dd4fa509af1334c91a4f/msgpack-1.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d65b791ce4283e02b6267742ad540899155e1b32887b9cbdda47f38f5a076b5", size = 465078, upload-time = "2026-10-26T17:54:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/32/2f/c74baeafc53d1c1fadfbeb774444d0e6354778d12b40b827e437a7a35856/msgpack-1.2.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5e4c67dbac05dce90a3fa1d845137617383c415e6b319062baac9734540706ae", size = 420144, upload-time = "2026-10-26T17:54:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f2/bdf2ce9e284821397dc5549101824c7607ee6ba6e0635390d168a77ac49c/msgpack-1.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:781e856db4f2cb398e4992e2a74305295d3ebf694eec26fa0f4bc766eb9fa0a0", size = 446479, upload-time = "2026-10-26T17:54:46.484Z" }, + { url = "https://files.pythonhosted.org/packages/76/8d/b4572d24bb75d98bcbbd031a934a3a1d03494ea88a100cd51ab16c4c7d57/msgpack-1.2.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cfc1cc237792f9a5664002b66acb961027d4184528ec9d10e0e42330f7972383", size = 418780, upload-time = "2026-10-26T17:54:48.04Z" }, + { url = "https://files.pythonhosted.org/packages/55/8e/4493bef4422730cfd3420b84094652fabd27ef8aadc8b443a8995b524d08/msgpack-1.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f60de6ae13d4d2a12a483f15c5e47cb6fa2731aa32d92fb31178e2017d6574a8", size = 462009, upload-time = "2026-10-26T17:54:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/29/efdc3d1d09e4e0e45955b0aad559b4c1f5d908767c9fb1021990c52182a5/msgpack-1.2.4-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:06a0657a4110d563255006b7cb7147432f67827144e429c2cdb1c967548d1817", size = 53714, upload-time = "2026-10-26T17:54:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/df/0b/08794c65e0a752acf20aa44e193e61dfb552b48fe6c27ba6023c146f9e77/msgpack-1.2.4-cp314-cp314-win32.whl", hash = "sha256:727a65b35db9f5ef516fc0229c4b05cab983092b1ae274a8a769b2d517fb0499", size = 70726, upload-time = "2026-10-26T17:54:52.397Z" }, + { url = "https://files.pythonhosted.org/packages/b5/da/e2cc63ec61e7ac05595b391cba01c10cb4657f4e517da1512e4a75d2d12e/msgpack-1.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:80f5d4fda1ba373d95933f98e6c1d18247feffac839fda3930bdd85e6561bb5f", size = 78346, upload-time = "2026-10-26T17:54:54.662Z" }, + { url = "https://files.pythonhosted.org/packages/93/e4/1b0eb49887009dc2c530171d659cf2aa37202b5f79c0b784e63777554b37/msgpack-1.2.4-cp314-cp314-win_arm64.whl", hash = "sha256:e5f67f0286f2ba2c6dbb4998404ed880112ca204ceda729cf239879cd89d3acf", size = 74152, upload-time = "2026-10-26T17:54:55.915Z" }, + { url = "https://files.pythonhosted.org/packages/d0/4a/6defc8fd0270c4ba675e6625f4f76ce936fd0c48733395fd7ba912cd423e/msgpack-1.2.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:45506bfb902cde24a0d33fb0581f58497ddb2682686dd1f6aa07e7b769df7ca4", size = 94083, upload-time = "2026-10-26T17:54:57.108Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/59f7e076739048864710a4de3403ae1c1b43ed5077b796cd9e8cbce9b3f6/msgpack-1.2.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc9dc681958e69f46b9f37def3e7fd6cc5dcfaa5943fb00bfe9c223a1a1052f6", size = 95037, upload-time = "2026-10-26T17:54:58.489Z" }, + { url = "https://files.pythonhosted.org/packages/2c/dd/dbae250b23b831cd0571eb664089f80de3249a39e98c6ef55f9a0efe928d/msgpack-1.2.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bef691e2a95119cc6bf3ded29325dbd89c4b6f8ff233070431948ed70902421", size = 472722, upload-time = "2026-10-26T17:54:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e2/383ece1c1b27c1ed322055fce8ab8972bc6d529ede881592065ce0c461bf/msgpack-1.2.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e6106d2d085af42b2de72efeb38ed255241a24b32295ca889707c1d679c442", size = 468726, upload-time = "2026-10-26T17:55:01.492Z" }, + { url = "https://files.pythonhosted.org/packages/37/2f/5410a8815ae74a824ac87543c612e40cb6674851b70a6ad858bb32813203/msgpack-1.2.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:996dfc4774c240c8e6adf1166ae102141cfbed33c8856fde821bd6c0bd35ee41", size = 420488, upload-time = "2026-10-26T17:55:02.848Z" }, + { url = "https://files.pythonhosted.org/packages/39/8e/16fdf02b4f91dff9c1480433c1bf80756c2fedc0b74fc6a231f02dfed007/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:efa1357fb0c7201e833de28638be371ee127476f7ccff9e7a94211b0afc33ddf", size = 455541, upload-time = "2026-10-26T17:55:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/c4/76/2454ef54d780a167667552d02e7bc977a3d419943bcbdf6a96a4da6786ad/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b9270b63409e70c8c6deb5faedc81fc27c6bd0196b38084a8773f68286abf034", size = 419514, upload-time = "2026-10-26T17:55:06.257Z" }, + { url = "https://files.pythonhosted.org/packages/ac/8b/2af9bf378def33c24875f250421b0d0b1b06f72a1c2c3f964749fc4cdee1/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2b597bb01aff61d65e305c2cf2589c93c86609929d42d575bfc359f3c54ea8e1", size = 461910, upload-time = "2026-10-26T17:55:08.05Z" }, + { url = "https://files.pythonhosted.org/packages/0e/46/a133ac6c4cef4d3939383c00cc19baa16ee85fd70af0c5990e7a9ca8767f/msgpack-1.2.4-cp314-cp314t-win32.whl", hash = "sha256:ad5bc540cbc34db236f8535721a9a872d12a9d88d1322470f1fc7c5e9dadfdb2", size = 76441, upload-time = "2026-10-26T17:55:09.711Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f5/8d22ec454c188f1795107a3fd9f68ada485076ebf394a4aaaa5fbb6093e7/msgpack-1.2.4-cp314-cp314t-win_amd64.whl", hash = "sha256:287e52c2ebaa94f8eca045628374859f51584e77bc07086e6835a9bb2204c774", size = 84321, upload-time = "2026-10-26T17:55:10.947Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/1390514aee47d14ca3fe6ea0cf236fddc55021867ace30daf8761deba36f/msgpack-1.2.4-cp314-cp314t-win_arm64.whl", hash = "sha256:f3f19fe5decd348b35b003ae5f32d3fa90877413a0183da676f748a3a7ab9270", size = 78589, upload-time = "2026-10-26T17:55:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d8/0465d475c1a7063af1a1fb4f8c69e0dd661c4dd172055c9757a343a07d5d/msgpack-1.2.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:765584b93ed6f0b561f0c56e9735f24945c0682f857c9b3ee3b4caad9c32f66a", size = 92449, upload-time = "2026-10-26T17:55:13.665Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d0/5220a8a53579dda39f3526394ba5b7eafc28736085f2626deda7fcd22cd1/msgpack-1.2.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0221748c30ace75fde0dec8c470abe65fb5374cd4f37adb9db6aca7a3c362c0a", size = 91209, upload-time = "2026-10-26T17:55:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/b2/98/f151742eba413503074a10d8f636bf03dd4f3f7cba29689767a35d19bd1c/msgpack-1.2.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5917972ce1bf5f85f761031e00dab6fe0ad9f880cb3d2d43ed74b330c850400", size = 455713, upload-time = "2026-10-26T17:55:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/b0/54/890510a2c9841aff63258e085d15d4e140c7eef559d4da81ef2d2d867436/msgpack-1.2.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ae8cf6012372cecb43016132d1b87ccc2ebb8ae5ab5078687399e82423386a", size = 466177, upload-time = "2026-10-26T17:55:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/3d/41/a6f20d7c2c380fb072b95ffc5b865a1d62eaf49d77fbee44466c45a1a8de/msgpack-1.2.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e78c66cf97ac5675037f705ca433ad98ac9177f9523e7fdbb34e486ae29044c8", size = 424398, upload-time = "2026-10-26T17:55:19.979Z" }, + { url = "https://files.pythonhosted.org/packages/89/86/fa9559961f2b0d498e6cefa226298ddee2b896cb223449c211702bc34071/msgpack-1.2.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:59887281c3c163a7afff7dffaaad2f78cd2b513b7e1a7843f1ee8e7cd6a94231", size = 445686, upload-time = "2026-10-26T17:55:21.775Z" }, + { url = "https://files.pythonhosted.org/packages/39/23/5a9d94036cdd77d4310bbb7cfaf3ccd1f087c4a5d5a4befec4361c5fc0dc/msgpack-1.2.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9f46e70c8d22e96856967ab9d80c51dcf5bc63133e4da2d66fab64c0d4119b82", size = 422096, upload-time = "2026-10-26T17:55:24Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4b/86a5384112509a94ff6e20028aba6845138743effb7c6617e959ad1256ab/msgpack-1.2.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:0a1fd6c31952155b7cd01061288c70303e6554ae855244dfb38a897fd94f8749", size = 463407, upload-time = "2026-10-26T17:55:26.146Z" }, + { url = "https://files.pythonhosted.org/packages/f4/cf/14db289e0ad531e22ce0f03284865f81d14fbc80bf3f79d3b1714bcf76cf/msgpack-1.2.4-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:b1f08ec826417e3c8dc3edadb50f38debba033af6cb849229f0eac72f80f2342", size = 53743, upload-time = "2026-10-26T17:55:27.497Z" }, + { url = "https://files.pythonhosted.org/packages/46/72/a76fc90b51503c0bdacbc7fc823aa6fa62d40596ac47f9ea8492d6f95a34/msgpack-1.2.4-cp315-cp315-win32.whl", hash = "sha256:e8ba23c838739fe929e868095171e71f2f38d6ce0fcf5840b576fb82648e58e6", size = 70780, upload-time = "2026-10-26T17:55:28.705Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/fa9d4a1b5dfead83ea6e03b4007041a5da988c25eb941d42612e0211d3c0/msgpack-1.2.4-cp315-cp315-win_amd64.whl", hash = "sha256:67310606cf2528a6dc3ece1298dcd5cc6c57add148b16d22c5111138aa84820c", size = 78288, upload-time = "2026-10-26T17:55:29.928Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f3/8163d7938a8fe003952fe2719ddb31da71998559bbe290c55ffe622b886b/msgpack-1.2.4-cp315-cp315-win_arm64.whl", hash = "sha256:ce76e7a7c11459157887a8dadf07c107ff74eca98cf914f0de6ee7ab4b47104d", size = 74235, upload-time = "2026-10-26T17:55:31.112Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fa/37c3219b250d81bee788c0dd78ae2fc673db2da1c21ae51f7982d85698f7/msgpack-1.2.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fb9ddbf23b8c6e89752af399fcf4daed60055e239cda44c6acfaa76d19331e8c", size = 93793, upload-time = "2026-10-26T17:55:32.304Z" }, + { url = "https://files.pythonhosted.org/packages/83/9f/b8afcd20d32cf4be1fa3c327e997656d6d446e667452b0fcc66b91710e82/msgpack-1.2.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e283cf563166c21762131b88e3870ee2ec57757b08a8736929757b5a67541061", size = 94746, upload-time = "2026-10-26T17:55:33.624Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4f/03348d381fcbbc2685356833408cc7e9d2af0fe0775926935a2889650f64/msgpack-1.2.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e91bf8325f492e8eb3c3aa6de06b20593c225336bb52b585c913d7c1da025b", size = 468480, upload-time = "2026-10-26T17:55:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/16/46/e54af8d9a51fb562e068e69a0d75a51e9c2fedec49f911f888515f5477ab/msgpack-1.2.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfa86432bf329d6b976f5a00ad4c9064fd51736e4967a52526750236345cc2da", size = 470064, upload-time = "2026-10-26T17:55:36.615Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/8f9a6b3af1d803e832b11017c8dad44d761fefdb91dba04ee58cb51838da/msgpack-1.2.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:42e0a7dc26df739149478585d4264338b4d330f24b3e3b57d5aa89e48866e8ab", size = 421267, upload-time = "2026-10-26T17:55:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8f/7302b0d18eadbbfef9be1dbbdc49905f7f3b847200c3e47068d093976be4/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8e680367090a58c966052d61418cc943111863538fd82471d61fca5271d911e1", size = 451244, upload-time = "2026-10-26T17:55:39.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/05717fb56b510b5e974446f2cb8e9b26fa4347fafc0c5927745b176939b3/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a6c9926f8871b261622fe6b599db72c69be5bca8fdc0976886f9a531a301dee9", size = 420273, upload-time = "2026-10-26T17:55:41.488Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/a19077d664c37319097e16ba8de14e6b3a2edcc8ee78a2069c0808e38960/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c50bac325dbf1a9c80e10da6dbd1100d7082f6b12e153084501e18e86cd3a037", size = 460421, upload-time = "2026-10-26T17:55:43.223Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bd/0e2033a7435f8d2311be37363fedc88bdfa9fd95c364ee8ac3216eb63e36/msgpack-1.2.4-cp315-cp315t-win32.whl", hash = "sha256:34051de19ae532ba47fcab5b0abd08c25c7a64ff763ad819f18764d45f3c2c81", size = 76256, upload-time = "2026-10-26T17:55:44.588Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/aa7265f25dafb6af7567a1a7d0a95c5566803db2226c5e084dc5850cfd96/msgpack-1.2.4-cp315-cp315t-win_amd64.whl", hash = "sha256:6ad2f0808099d4281208ff2c756efa958a55a7c8eb21fe1c7052b95c584f02f1", size = 83939, upload-time = "2026-10-26T17:55:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/c53904c4bc3da828fb75678bf489ece808a277affa73f8a7a91dfb7c5851/msgpack-1.2.4-cp315-cp315t-win_arm64.whl", hash = "sha256:e38d854f1c60e94f46d998765201126338bde3fe1b029be0d849094bcbef72c2", size = 77995, upload-time = "2026-10-26T17:55:47.016Z" }, +] + [[package]] name = "multidict" version = "6.9.1"