diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a9a7b36de..667ca1b0f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,9 +88,15 @@ jobs: adk-server-test-integration: name: mise adk-server:test:integration needs: [setup, microshift-vm-build] + # Skip on fork PRs: secrets and vars (LLM_API_BASE, OPENAI_MODEL, etc.) + # are not exposed to pull_request events from forks, so this job would + # crash with `ValueError: '' is not a valid ModelProviderType` in + # conftest.py before any test could run. if: >- always() && needs.microshift-vm-build.result != 'failure' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) && (needs.setup.outputs.adk-server == 'true' || needs.setup.outputs.adk-cli == 'true') timeout-minutes: 45 @@ -126,9 +132,15 @@ jobs: adk-server-test-e2e: name: mise adk-server:test:e2e needs: [setup, microshift-vm-build] + # Skip on fork PRs: same secrets-and-vars limitation as + # adk-server-test-integration above. Without LLM_MODEL the conftest + # crashes; without OIDC_PROVIDER_* the adk-ui Deployment never goes + # ready and the job times out at 25 minutes. if: >- always() && needs.microshift-vm-build.result != 'failure' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) && (needs.setup.outputs.adk-server == 'true' || needs.setup.outputs.adk-cli == 'true' || needs.setup.outputs.adk-py == 'true' || diff --git a/apps/adk-py/pyproject.toml b/apps/adk-py/pyproject.toml index 90442b72a..1a29e3654 100644 --- a/apps/adk-py/pyproject.toml +++ b/apps/adk-py/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ "jsonpatch>=1.33", ] +[project.optional-dependencies] +memoryhub = ["memoryhub>=0.7.0"] + [dependency-groups] dev = [ "beeai-framework[duckduckgo,wikipedia]>=0.1.76", diff --git a/apps/adk-py/src/kagenti_adk/a2a/extensions/services/__init__.py b/apps/adk-py/src/kagenti_adk/a2a/extensions/services/__init__.py index f5a107450..1feaad76b 100644 --- a/apps/adk-py/src/kagenti_adk/a2a/extensions/services/__init__.py +++ b/apps/adk-py/src/kagenti_adk/a2a/extensions/services/__init__.py @@ -7,4 +7,5 @@ from .form import * from .llm import * from .mcp import * +from .memoryhub import * from .platform import * diff --git a/apps/adk-py/src/kagenti_adk/a2a/extensions/services/memoryhub.py b/apps/adk-py/src/kagenti_adk/a2a/extensions/services/memoryhub.py new file mode 100644 index 000000000..5b1144256 --- /dev/null +++ b/apps/adk-py/src/kagenti_adk/a2a/extensions/services/memoryhub.py @@ -0,0 +1,157 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +import os +from types import NoneType +from typing import TYPE_CHECKING, Any, Self + +import pydantic +from a2a.server.agent_execution.context import RequestContext +from a2a.types import Message as A2AMessage +from pydantic import SecretStr +from typing_extensions import override + +from kagenti_adk.a2a.extensions.base import ( + DEFAULT_DEMAND_NAME, + BaseExtensionClient, + BaseExtensionServer, + BaseExtensionSpec, +) +from kagenti_adk.util.pydantic import REVEAL_SECRETS, SecureBaseModel, redact_str + +__all__ = [ + "MemoryHubDemand", + "MemoryHubExtensionClient", + "MemoryHubExtensionMetadata", + "MemoryHubExtensionParams", + "MemoryHubExtensionServer", + "MemoryHubExtensionSpec", + "MemoryHubFulfillment", +] + +if TYPE_CHECKING: + from kagenti_adk.server.context import RunContext + + +class MemoryHubFulfillment(SecureBaseModel): + """Connection details the client provides for a MemoryHub instance.""" + + url: str + """ + Base URL of the MemoryHub MCP endpoint, e.g. + ``https://memory-hub-mcp.example.com/mcp/``. + """ + + api_key: SecretStr | None = None + """ + Static API key for the dev/testing path. Mutually exclusive with the + OAuth fields below. + """ + + auth_url: str | None = None + """ + OAuth 2.1 token endpoint. Required together with ``client_id`` and + ``client_secret`` for the OAuth path. + """ + + client_id: str | None = None + """ + OAuth 2.1 client identifier. + """ + + client_secret: SecretStr | None = None + """ + OAuth 2.1 client secret. + """ + + @pydantic.field_serializer("url") + def _redact_url(self, v: str, info) -> str: + return redact_str(v, info) + + +class MemoryHubDemand(pydantic.BaseModel): + """A request from the agent for a MemoryHub fulfillment.""" + + description: str | None = None + """ + Short description of how the memory store will be used. Intended to be + shown in the UI alongside a connection picker. + """ + + +class MemoryHubExtensionParams(pydantic.BaseModel): + memoryhub_demands: dict[str, MemoryHubDemand] + """MemoryHub connections that the agent requires the client to provide.""" + + +class MemoryHubExtensionMetadata(pydantic.BaseModel): + memoryhub_fulfillments: dict[str, MemoryHubFulfillment] = {} + """Connection details corresponding to the agent's demands.""" + + +class MemoryHubExtensionSpec(BaseExtensionSpec[MemoryHubExtensionParams, MemoryHubExtensionMetadata]): + URI: str = "https://a2a-extensions.adk.kagenti.dev/services/memoryhub/v1" + + @classmethod + def single_demand( + cls, + name: str = DEFAULT_DEMAND_NAME, + description: str | None = None, + default: MemoryHubFulfillment | None = None, + ) -> Self: + return cls( + params=MemoryHubExtensionParams(memoryhub_demands={name: MemoryHubDemand(description=description)}), + default=(MemoryHubExtensionMetadata(memoryhub_fulfillments={name: default}) if default else None), + ) + + +class MemoryHubExtensionServer(BaseExtensionServer[MemoryHubExtensionSpec, MemoryHubExtensionMetadata]): + @override + def handle_incoming_message(self, message: A2AMessage, run_context: RunContext, request_context: RequestContext): + super().handle_incoming_message(message, run_context, request_context) + + if not self._metadata_from_client or not self._metadata_from_client.memoryhub_fulfillments: + fulfillment = _memoryhub_fulfillment_from_env() + if fulfillment: + self._metadata_from_client = MemoryHubExtensionMetadata(memoryhub_fulfillments={"default": fulfillment}) + + +class MemoryHubExtensionClient(BaseExtensionClient[MemoryHubExtensionSpec, NoneType]): + def fulfillment_metadata(self, *, memoryhub_fulfillments: dict[str, MemoryHubFulfillment]) -> dict[str, Any]: + return { + self.spec.URI: MemoryHubExtensionMetadata(memoryhub_fulfillments=memoryhub_fulfillments).model_dump( + mode="json", context={REVEAL_SECRETS: True} + ) + } + + +def _memoryhub_fulfillment_from_env() -> MemoryHubFulfillment | None: + """Build a default MemoryHub fulfillment from environment variables. + + Reads ``MEMORYHUB_URL`` (required), and either ``MEMORYHUB_API_KEY`` + (dev path) or ``MEMORYHUB_AUTH_URL`` + ``MEMORYHUB_CLIENT_ID`` + + ``MEMORYHUB_CLIENT_SECRET`` (OAuth 2.1 path). Returns None if no URL + is set or no usable credential is available. + """ + url = os.environ.get("MEMORYHUB_URL") + if not url: + return None + + api_key = os.environ.get("MEMORYHUB_API_KEY") + auth_url = os.environ.get("MEMORYHUB_AUTH_URL") + client_id = os.environ.get("MEMORYHUB_CLIENT_ID") + client_secret = os.environ.get("MEMORYHUB_CLIENT_SECRET") + + if api_key: + return MemoryHubFulfillment(url=url, api_key=SecretStr(api_key)) + if auth_url and client_id and client_secret: + return MemoryHubFulfillment( + url=url, + auth_url=auth_url, + client_id=client_id, + client_secret=SecretStr(client_secret), + ) + return None diff --git a/apps/adk-py/src/kagenti_adk/server/dependencies.py b/apps/adk-py/src/kagenti_adk/server/dependencies.py index a06f6ad34..c7d1ea88d 100644 --- a/apps/adk-py/src/kagenti_adk/server/dependencies.py +++ b/apps/adk-py/src/kagenti_adk/server/dependencies.py @@ -45,10 +45,11 @@ def __init__( def __call__( self, message: Message, context: RunContext, request_context: RequestContext ) -> AbstractAsyncContextManager[Dependency]: - instance = self._dependency_callable(message, context, request_context) - @asynccontextmanager async def lifespan() -> AsyncGenerator[Dependency]: + instance = self._dependency_callable(message, context, request_context) + if inspect.isawaitable(instance): + instance = await instance if self.extension or hasattr(instance, "lifespan"): async with instance.lifespan(): yield instance diff --git a/apps/adk-py/src/kagenti_adk/server/store/exceptions.py b/apps/adk-py/src/kagenti_adk/server/store/exceptions.py new file mode 100644 index 000000000..0713cd42c --- /dev/null +++ b/apps/adk-py/src/kagenti_adk/server/store/exceptions.py @@ -0,0 +1,18 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + + +class MemoryRejectionError(RuntimeError): + """Raised when the memory store refused to record a memory. + + Backends that run a pre-storage pipeline (deduplication, contradiction + detection, policy/curator rules) may reject a write. The ``reason`` + attribute carries the backend's explanation when one is provided. + """ + + def __init__(self, reason: str | None = None): + msg = f"Memory store rejected the memory: {reason}" if reason else "Memory store rejected the memory" + super().__init__(msg) + self.reason = reason diff --git a/apps/adk-py/src/kagenti_adk/server/store/memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py new file mode 100644 index 000000000..dc8405c2c --- /dev/null +++ b/apps/adk-py/src/kagenti_adk/server/store/memory_store.py @@ -0,0 +1,130 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +"""Long-term governed memory store abstraction for AI agents. + +This module defines the MemoryStore protocol — a complement to ContextStore +that handles durable, cross-session knowledge rather than per-context +conversation replay. ContextStore answers "what was said in this conversation"; +MemoryStore answers "what does this agent know across all conversations." + +The protocol is backend-agnostic. The MemoryHub implementation in +memoryhub_memory_store.py is one concrete backend; others (Redis, SQLite, +in-memory for testing) can implement the same interface. +""" + +from __future__ import annotations + +import abc +from typing import Protocol + +from pydantic import BaseModel, Field + +__all__ = [ + "MemoryResult", + "MemoryStore", + "MemoryStoreInstance", +] + + +class MemoryResult(BaseModel): + """A single memory returned from search or read. + + Field semantics are intentionally backend-agnostic. Concrete backends + map their own concepts onto these fields; the MemoryHub backend's + mapping is documented inline as a worked example. + """ + + memory_id: str = Field(description="Backend-assigned identifier for the memory.") + content: str = Field(description="The memory's payload. May be a stub for search results.") + scope: str = Field( + description=( + "Visibility/governance domain. Backend-defined; in MemoryHub: " + "one of user/project/campaign/organizational/enterprise." + ) + ) + weight: float = Field( + default=0.7, + description=("Priority/curation signal in the range 0.0-1.0. Backends may use it for ranking or ignore it."), + ) + relevance_score: float | None = Field( + default=None, + description=("Search relevance score returned by the backend; None for non-search results."), + ) + + +class MemoryStoreInstance(Protocol): + """Operations on governed memory, scoped to a context. + + Each method maps to a standard memory lifecycle operation. + Implementations should raise backend-specific errors for + authorization failures or validation issues. + + Common keyword arguments share semantics across all methods: + + - ``scope``: Visibility/governance domain. Backend-defined; in + MemoryHub: one of user/project/campaign/organizational/enterprise. + - ``weight``: Priority/curation signal in the range 0.0-1.0. Backends + may use it for ranking or ignore it. + - ``tags``: Free-form tags for grouping/filtering. Backend-defined + semantics; in MemoryHub: "domains" attached to a memory. + - ``project_id``: Optional grouping within a memory store; + backend-defined semantics. In MemoryHub: a project with member-based + access control. NOT a tenancy boundary — tenancy is established by + the backend's auth credentials. + """ + + async def search( + self, + query: str, + *, + scope: str | None = None, + project_id: str | None = None, + max_results: int = 10, + ) -> list[MemoryResult]: + """Search for memories matching ``query``. + + ``scope`` and ``project_id`` filter results; their semantics are + backend-defined. See the class docstring for the cross-method + conventions. + """ + ... + + async def create( + self, + content: str, + *, + scope: str = "user", + weight: float = 0.7, + tags: list[str] | None = None, + project_id: str | None = None, + ) -> str: + """Create a new memory. Returns the new memory_id. + + ``scope``, ``weight``, ``tags`` and ``project_id`` follow the + cross-method conventions documented on the class. + + Implementations may raise to signal that the backend rejected the write. + """ + ... + + async def read(self, memory_id: str) -> MemoryResult | None: ... + + async def update(self, memory_id: str, content: str) -> None: ... + + async def delete(self, memory_id: str) -> None: ... + + +class MemoryStore(abc.ABC): + """Factory that creates MemoryStoreInstance objects per context. + + Mirrors the ContextStore pattern: the factory holds connection config, + create() returns a per-context instance. + """ + + @property + def required_extensions(self) -> set[str]: + return set() + + @abc.abstractmethod + async def create(self, context_id: str) -> MemoryStoreInstance: ... diff --git a/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py new file mode 100644 index 000000000..eb7c95b54 --- /dev/null +++ b/apps/adk-py/src/kagenti_adk/server/store/memoryhub_memory_store.py @@ -0,0 +1,187 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +"""MemoryStore backed by MemoryHub (https://github.com/redhat-ai-americas/memory-hub). + +Wraps the ``memoryhub`` Python SDK to provide governed, cross-session memory +to ADK agents via the MemoryStore protocol. Requires the ``memoryhub`` extra: + + uv add kagenti-adk[memoryhub] + +The connection is supplied to agents via the MemoryHub A2A service extension +(``services.memoryhub.MemoryHubExtensionSpec``); the +:class:`MemoryHubExtensionServer` opens and closes the underlying client as +part of its ``lifespan`` and exposes per-context store instances via +:meth:`MemoryHubExtensionServer.store`. +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +from memoryhub.client import MemoryHubClient +from memoryhub.exceptions import NotFoundError +from typing_extensions import override + +from kagenti_adk.a2a.extensions.services.memoryhub import ( + MemoryHubExtensionServer as _BaseMemoryHubExtensionServer, +) +from kagenti_adk.server.store.exceptions import MemoryRejectionError +from kagenti_adk.server.store.memory_store import MemoryResult, MemoryStoreInstance + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + +__all__ = [ + "MemoryHubExtensionServer", + "MemoryHubMemoryStoreInstance", +] + + +class MemoryHubMemoryStoreInstance(MemoryStoreInstance): + """Per-context memory operations backed by MemoryHub.""" + + def __init__(self, context_id: str, client: MemoryHubClient) -> None: + self._context_id = context_id + self._client = client + + async def search( + self, + query: str, + *, + scope: str | None = None, + project_id: str | None = None, + max_results: int = 10, + ) -> list[MemoryResult]: + result = await self._client.search( + query, + scope=scope, + project_id=project_id, + max_results=max_results, + ) + return [ + MemoryResult( + memory_id=m.id, + content=m.content or m.stub or "", + scope=m.scope, + weight=m.weight, + relevance_score=m.relevance_score, + ) + for m in result.results + ] + + async def create( + self, + content: str, + *, + scope: str = "user", + weight: float = 0.7, + tags: list[str] | None = None, + project_id: str | None = None, + ) -> str: + result = await self._client.write( + content, + scope=scope, + weight=weight, + domains=tags, + project_id=project_id, + ) + # memoryhub.WriteResult.memory is None when the SDK's curation pipeline rejected the write + if result.memory is None: + raise MemoryRejectionError(result.curation.reason) + return result.memory.id + + async def read(self, memory_id: str) -> MemoryResult | None: + try: + m = await self._client.read(memory_id) + except NotFoundError: + return None + return MemoryResult( + memory_id=m.id, + content=m.content or "", + scope=m.scope, + weight=m.weight, + ) + + async def update(self, memory_id: str, content: str) -> None: + await self._client.update(memory_id, content=content) + + async def delete(self, memory_id: str) -> None: + await self._client.delete(memory_id) + + +class MemoryHubExtensionServer(_BaseMemoryHubExtensionServer): + """Server-side MemoryHub extension that owns the MemoryHub client lifecycle. + + Subclasses the protocol-only base with a ``lifespan()`` that opens the + underlying ``memoryhub.client.MemoryHubClient`` from the active + :class:`MemoryHubFulfillment` and closes it on exit. Use + :meth:`store` to obtain a :class:`MemoryHubMemoryStoreInstance` bound + to the request's context. + """ + + _client: MemoryHubClient | None = None + + @asynccontextmanager + @override + async def lifespan(self) -> AsyncGenerator[None]: + fulfillment = self._resolve_fulfillment() + if fulfillment is None: + # Extension was not fulfilled by the client and no env fallback; + # leave _client unset so accidental store() use raises clearly. + yield + return + + if fulfillment.api_key is not None: + client = MemoryHubClient( + url=fulfillment.url, + api_key=fulfillment.api_key.get_secret_value(), + ) + else: + client = MemoryHubClient( + url=fulfillment.url, + auth_url=fulfillment.auth_url, + client_id=fulfillment.client_id, + client_secret=( + fulfillment.client_secret.get_secret_value() if fulfillment.client_secret is not None else None + ), + ) + + await client.__aenter__() + self._client = client + logger.info("MemoryHub client connected to %s", fulfillment.url) + try: + yield + finally: + await client.__aexit__(None, None, None) + self._client = None + + def store(self, context_id: str) -> MemoryHubMemoryStoreInstance: + """Return a per-context store instance. + + Must be called inside the extension's ``lifespan`` window — i.e. + from agent code that depends on the extension via ``Annotated[..., + MemoryHubExtensionSpec.single_demand()]``. + """ + if self._client is None: + raise RuntimeError( + "MemoryHubExtensionServer.store() called without an active client. " + "Either fulfill the extension via A2A metadata or set MEMORYHUB_URL " + "and credentials in the environment." + ) + return MemoryHubMemoryStoreInstance(context_id=context_id, client=self._client) + + def _resolve_fulfillment(self): + # data() falls back to spec.default if the client did not provide metadata. + try: + data = self.data + except AttributeError: + return None + if data is None or not data.memoryhub_fulfillments: + return None + return next(iter(data.memoryhub_fulfillments.values())) diff --git a/apps/adk-py/tasks.toml b/apps/adk-py/tasks.toml index 7e1057492..34c03daee 100644 --- a/apps/adk-py/tasks.toml +++ b/apps/adk-py/tasks.toml @@ -70,7 +70,7 @@ dir = "{{config_root}}/apps/adk-py" usage = 'flag "--python " default="3.14"' run = """ #!/bin/bash -uv run --python="${usage_python?}" pytest +uv run --all-extras --python="${usage_python?}" pytest """ # build diff --git a/apps/adk-py/tests/unit/a2a/extensions/__init__.py b/apps/adk-py/tests/unit/a2a/extensions/__init__.py new file mode 100644 index 000000000..7abe8ca66 --- /dev/null +++ b/apps/adk-py/tests/unit/a2a/extensions/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 diff --git a/apps/adk-py/tests/unit/a2a/extensions/services/__init__.py b/apps/adk-py/tests/unit/a2a/extensions/services/__init__.py new file mode 100644 index 000000000..7abe8ca66 --- /dev/null +++ b/apps/adk-py/tests/unit/a2a/extensions/services/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 diff --git a/apps/adk-py/tests/unit/a2a/extensions/services/test_memoryhub.py b/apps/adk-py/tests/unit/a2a/extensions/services/test_memoryhub.py new file mode 100644 index 000000000..81c188d96 --- /dev/null +++ b/apps/adk-py/tests/unit/a2a/extensions/services/test_memoryhub.py @@ -0,0 +1,220 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest +from pydantic import SecretStr + +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionClient, + MemoryHubExtensionMetadata, + MemoryHubExtensionParams, + MemoryHubExtensionSpec, + MemoryHubFulfillment, +) +from kagenti_adk.a2a.extensions.services.memoryhub import _memoryhub_fulfillment_from_env +from kagenti_adk.util.pydantic import REVEAL_SECRETS + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True) +def _clean_env(): + """Ensure no MEMORYHUB env vars leak between tests.""" + env_vars = [ + "MEMORYHUB_URL", + "MEMORYHUB_API_KEY", + "MEMORYHUB_AUTH_URL", + "MEMORYHUB_CLIENT_ID", + "MEMORYHUB_CLIENT_SECRET", + ] + with patch.dict(os.environ, {}, clear=False): + for var in env_vars: + os.environ.pop(var, None) + yield + + +# --------------------------------------------------------------------------- +# Spec construction / round-trip +# --------------------------------------------------------------------------- + + +class TestMemoryHubExtensionSpec: + def test_uri_is_versioned(self): + spec = MemoryHubExtensionSpec(params=MemoryHubExtensionParams(memoryhub_demands={})) + assert spec.URI == "https://a2a-extensions.adk.kagenti.dev/services/memoryhub/v1" + + def test_single_demand_default_name(self): + spec = MemoryHubExtensionSpec.single_demand() + assert "default" in spec.params.memoryhub_demands + assert spec.params.memoryhub_demands["default"].description is None + + def test_single_demand_custom_name_and_description(self): + spec = MemoryHubExtensionSpec.single_demand(name="primary", description="cross-session knowledge") + assert "primary" in spec.params.memoryhub_demands + assert spec.params.memoryhub_demands["primary"].description == "cross-session knowledge" + + def test_single_demand_with_default_fulfillment(self): + default = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("k")) + spec = MemoryHubExtensionSpec.single_demand(default=default) + assert spec.default is not None + assert spec.default.memoryhub_fulfillments["default"].url == "http://hub" + + def test_to_agent_card_extensions_round_trips(self): + spec = MemoryHubExtensionSpec.single_demand(description="test") + extensions = spec.to_agent_card_extensions() + assert len(extensions) == 1 + assert extensions[0].uri == MemoryHubExtensionSpec.URI + + +# --------------------------------------------------------------------------- +# Fulfillment serialization (redaction) +# --------------------------------------------------------------------------- + + +class TestMemoryHubFulfillmentSerialization: + def test_api_key_redacted_in_default_dump(self): + f = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("super-secret")) + dumped = f.model_dump() + # SecretStr's repr keeps the value behind a wrapper; ensure it isn't leaked as a plain str. + assert dumped["api_key"] != "super-secret" + + def test_api_key_revealed_with_context(self): + f = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("super-secret")) + dumped = f.model_dump(context={REVEAL_SECRETS: True}) + assert dumped["api_key"] == "super-secret" + + def test_client_secret_redacted_in_default_dump(self): + f = MemoryHubFulfillment( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret=SecretStr("csec"), + ) + dumped = f.model_dump() + assert dumped["client_secret"] != "csec" + + def test_client_secret_revealed_with_context(self): + f = MemoryHubFulfillment( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret=SecretStr("csec"), + ) + dumped = f.model_dump(context={REVEAL_SECRETS: True}) + assert dumped["client_secret"] == "csec" + + +# --------------------------------------------------------------------------- +# Client metadata production +# --------------------------------------------------------------------------- + + +class TestMemoryHubExtensionClient: + def test_fulfillment_metadata_reveals_secrets(self): + spec = MemoryHubExtensionSpec.single_demand() + client = MemoryHubExtensionClient(spec) + fulfillment = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("the-key")) + + metadata = client.fulfillment_metadata(memoryhub_fulfillments={"default": fulfillment}) + + assert spec.URI in metadata + payload = metadata[spec.URI] + assert payload["memoryhub_fulfillments"]["default"]["api_key"] == "the-key" + + def test_fulfillment_metadata_round_trip(self): + spec = MemoryHubExtensionSpec.single_demand() + client = MemoryHubExtensionClient(spec) + fulfillment = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("the-key")) + + metadata = client.fulfillment_metadata(memoryhub_fulfillments={"default": fulfillment}) + # Server side parses MemoryHubExtensionMetadata back from this payload. + parsed = MemoryHubExtensionMetadata.model_validate(metadata[spec.URI]) + assert "default" in parsed.memoryhub_fulfillments + assert parsed.memoryhub_fulfillments["default"].url == "http://hub" + + +# --------------------------------------------------------------------------- +# Env-var fallback +# --------------------------------------------------------------------------- + + +class TestMemoryHubFulfillmentFromEnv: + def test_returns_none_when_no_env_vars(self): + assert _memoryhub_fulfillment_from_env() is None + + def test_returns_none_when_url_only(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + assert _memoryhub_fulfillment_from_env() is None + + def test_api_key_path(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + os.environ["MEMORYHUB_API_KEY"] = "the-key" + + result = _memoryhub_fulfillment_from_env() + assert result is not None + assert result.url == "http://hub" + assert result.api_key.get_secret_value() == "the-key" + assert result.client_secret is None + + def test_oauth_path(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + os.environ["MEMORYHUB_AUTH_URL"] = "http://auth" + os.environ["MEMORYHUB_CLIENT_ID"] = "cid" + os.environ["MEMORYHUB_CLIENT_SECRET"] = "csec" + + result = _memoryhub_fulfillment_from_env() + assert result is not None + assert result.url == "http://hub" + assert result.auth_url == "http://auth" + assert result.client_id == "cid" + assert result.client_secret.get_secret_value() == "csec" + + def test_api_key_takes_precedence_over_oauth(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + os.environ["MEMORYHUB_API_KEY"] = "the-key" + os.environ["MEMORYHUB_AUTH_URL"] = "http://auth" + os.environ["MEMORYHUB_CLIENT_ID"] = "cid" + os.environ["MEMORYHUB_CLIENT_SECRET"] = "csec" + + result = _memoryhub_fulfillment_from_env() + assert result is not None + assert result.api_key.get_secret_value() == "the-key" + assert result.auth_url is None + assert result.client_id is None + + def test_returns_none_when_oauth_partial(self): + os.environ["MEMORYHUB_URL"] = "http://hub" + os.environ["MEMORYHUB_AUTH_URL"] = "http://auth" + # missing client_id and client_secret + assert _memoryhub_fulfillment_from_env() is None + + +# --------------------------------------------------------------------------- +# Params validation +# --------------------------------------------------------------------------- + + +class TestMemoryHubExtensionParams: + def test_empty_demands_allowed(self): + params = MemoryHubExtensionParams(memoryhub_demands={}) + assert params.memoryhub_demands == {} + + def test_multiple_demands(self): + params = MemoryHubExtensionParams( + memoryhub_demands={ + "primary": _demand("primary store"), + "audit": _demand("audit log"), + } + ) + assert set(params.memoryhub_demands) == {"primary", "audit"} + + +def _demand(description: str): + from kagenti_adk.a2a.extensions.services.memoryhub import MemoryHubDemand + + return MemoryHubDemand(description=description) diff --git a/apps/adk-py/tests/unit/server/store/__init__.py b/apps/adk-py/tests/unit/server/store/__init__.py new file mode 100644 index 000000000..7abe8ca66 --- /dev/null +++ b/apps/adk-py/tests/unit/server/store/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 diff --git a/apps/adk-py/tests/unit/server/store/test_memory_store.py b/apps/adk-py/tests/unit/server/store/test_memory_store.py new file mode 100644 index 000000000..68f24e106 --- /dev/null +++ b/apps/adk-py/tests/unit/server/store/test_memory_store.py @@ -0,0 +1,387 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for MemoryStore protocol and MemoryHub implementation. + +The ``memoryhub`` SDK is now a real dev dependency, so we drive the +underlying ``MemoryHubClient`` calls via mocks rather than stubbing the +whole module tree. The MemoryHubExtensionServer's ``lifespan()`` is +exercised by patching ``MemoryHubClient`` to return an ``AsyncMock``. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import SecretStr + +from kagenti_adk.a2a.extensions.services.memoryhub import ( + MemoryHubExtensionMetadata, + MemoryHubExtensionSpec, + MemoryHubFulfillment, +) +from kagenti_adk.server.store.exceptions import MemoryRejectionError +from kagenti_adk.server.store.memory_store import MemoryResult +from kagenti_adk.server.store.memoryhub_memory_store import ( + MemoryHubExtensionServer, + MemoryHubMemoryStoreInstance, +) + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mock_client() -> AsyncMock: + """Return an async mock that looks like a MemoryHubClient.""" + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + return client + + +def _search_result(*memories) -> SimpleNamespace: + return SimpleNamespace(results=list(memories)) + + +def _memory_obj( + id: str = "mem-1", + content: str = "some content", + scope: str = "user", + weight: float = 0.7, + relevance_score: float | None = None, + stub: str | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + id=id, + content=content, + scope=scope, + weight=weight, + relevance_score=relevance_score, + stub=stub, + ) + + +def _write_result(memory=None, curation=None) -> SimpleNamespace: + if curation is None: + curation = SimpleNamespace(reason="") + return SimpleNamespace(memory=memory, curation=curation) + + +class _NotFoundError(Exception): + """Stand-in matching ``memoryhub.exceptions.NotFoundError``.""" + + +# --------------------------------------------------------------------------- +# MemoryResult model +# --------------------------------------------------------------------------- + + +class TestMemoryResult: + def test_required_fields(self): + r = MemoryResult(memory_id="id-1", content="hello", scope="user") + assert r.memory_id == "id-1" + assert r.content == "hello" + assert r.scope == "user" + + def test_weight_default(self): + r = MemoryResult(memory_id="x", content="y", scope="project") + assert r.weight == 0.7 + + def test_relevance_score_default_is_none(self): + r = MemoryResult(memory_id="x", content="y", scope="project") + assert r.relevance_score is None + + def test_explicit_weight_and_relevance(self): + r = MemoryResult(memory_id="x", content="y", scope="org", weight=0.9, relevance_score=0.85) + assert r.weight == 0.9 + assert r.relevance_score == 0.85 + + +# --------------------------------------------------------------------------- +# MemoryHubMemoryStoreInstance operations +# --------------------------------------------------------------------------- + + +class TestMemoryHubMemoryStoreInstance: + def _make(self, client=None) -> MemoryHubMemoryStoreInstance: + return MemoryHubMemoryStoreInstance( + context_id="ctx-99", + client=client or _mock_client(), + ) + + # --- search --- + + async def test_search_maps_results(self): + client = _mock_client() + client.search.return_value = _search_result( + _memory_obj(id="m1", content="alpha", scope="user", weight=0.8, relevance_score=0.9), + _memory_obj(id="m2", content="beta", scope="project", weight=0.6, relevance_score=0.7), + ) + inst = self._make(client) + + results = await inst.search("test query") + + client.search.assert_awaited_once_with("test query", scope=None, project_id=None, max_results=10) + assert len(results) == 2 + assert results[0] == MemoryResult( + memory_id="m1", content="alpha", scope="user", weight=0.8, relevance_score=0.9 + ) + assert results[1] == MemoryResult( + memory_id="m2", content="beta", scope="project", weight=0.6, relevance_score=0.7 + ) + + async def test_search_passes_optional_kwargs(self): + client = _mock_client() + client.search.return_value = _search_result() + inst = self._make(client) + + await inst.search("q", scope="project", project_id="proj-1", max_results=5) + + client.search.assert_awaited_once_with("q", scope="project", project_id="proj-1", max_results=5) + + async def test_search_falls_back_to_stub_when_content_none(self): + client = _mock_client() + client.search.return_value = _search_result(_memory_obj(id="m1", content=None, stub="stub text", scope="user")) + inst = self._make(client) + + results = await inst.search("q") + assert results[0].content == "stub text" + + async def test_search_empty_string_when_both_none(self): + client = _mock_client() + client.search.return_value = _search_result(_memory_obj(id="m1", content=None, stub=None, scope="user")) + inst = self._make(client) + + results = await inst.search("q") + assert results[0].content == "" + + async def test_search_empty_results(self): + client = _mock_client() + client.search.return_value = _search_result() + inst = self._make(client) + + results = await inst.search("nothing") + assert results == [] + + # --- create --- + + async def test_create_returns_memory_id(self): + client = _mock_client() + client.write.return_value = _write_result(memory=SimpleNamespace(id="new-id-42")) + inst = self._make(client) + + memory_id = await inst.create("important fact") + + client.write.assert_awaited_once_with( + "important fact", + scope="user", + weight=0.7, + domains=None, + project_id=None, + ) + assert memory_id == "new-id-42" + + async def test_create_passes_all_params(self): + client = _mock_client() + client.write.return_value = _write_result(memory=SimpleNamespace(id="x")) + inst = self._make(client) + + await inst.create( + "content", + scope="project", + weight=0.9, + tags=["infra", "k8s"], + project_id="proj-1", + ) + + client.write.assert_awaited_once_with( + "content", + scope="project", + weight=0.9, + domains=["infra", "k8s"], + project_id="proj-1", + ) + + async def test_create_raises_on_curation_rejection(self): + client = _mock_client() + client.write.return_value = _write_result( + memory=None, + curation=SimpleNamespace(reason="duplicate detected"), + ) + inst = self._make(client) + + with pytest.raises(MemoryRejectionError) as exc_info: + await inst.create("duplicate content") + assert exc_info.value.reason == "duplicate detected" + + # --- read --- + + async def test_read_returns_memory_result(self): + client = _mock_client() + client.read.return_value = _memory_obj(id="m-read", content="stored fact", scope="user", weight=0.8) + inst = self._make(client) + + result = await inst.read("m-read") + + client.read.assert_awaited_once_with("m-read") + assert result == MemoryResult(memory_id="m-read", content="stored fact", scope="user", weight=0.8) + + async def test_read_returns_none_for_not_found(self): + # Patch the NotFoundError in the implementation module to our local class + # so the except clause matches our raised exception. + from kagenti_adk.server.store import memoryhub_memory_store as impl + + with patch.object(impl, "NotFoundError", _NotFoundError): + client = _mock_client() + client.read.side_effect = _NotFoundError("not found") + inst = self._make(client) + + result = await inst.read("missing-id") + assert result is None + + async def test_read_empty_content_defaults_to_empty_string(self): + client = _mock_client() + client.read.return_value = _memory_obj(id="x", content=None, scope="user") + inst = self._make(client) + + result = await inst.read("x") + assert result.content == "" + + # --- update --- + + async def test_update_delegates_to_client(self): + client = _mock_client() + inst = self._make(client) + + await inst.update("m-1", "new content") + + client.update.assert_awaited_once_with("m-1", content="new content") + + async def test_update_returns_none(self): + client = _mock_client() + client.update.return_value = None + inst = self._make(client) + + result = await inst.update("m-1", "content") + assert result is None + + # --- delete --- + + async def test_delete_delegates_to_client(self): + client = _mock_client() + inst = self._make(client) + + await inst.delete("m-1") + + client.delete.assert_awaited_once_with("m-1") + + async def test_delete_returns_none(self): + client = _mock_client() + client.delete.return_value = None + inst = self._make(client) + + result = await inst.delete("m-1") + assert result is None + + +# --------------------------------------------------------------------------- +# MemoryHubExtensionServer lifecycle +# --------------------------------------------------------------------------- + + +class TestMemoryHubExtensionServerLifespan: + def _server_with_default(self, fulfillment: MemoryHubFulfillment) -> MemoryHubExtensionServer: + spec = MemoryHubExtensionSpec.single_demand(default=fulfillment) + server = MemoryHubExtensionServer(spec) + # Activate by simulating a parsed metadata payload. + server._metadata_from_client = MemoryHubExtensionMetadata(memoryhub_fulfillments={"default": fulfillment}) + return server + + async def test_lifespan_opens_and_closes_api_key_client(self): + fulfillment = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("the-key")) + server = self._server_with_default(fulfillment) + fake_client = _mock_client() + + with patch( + "kagenti_adk.server.store.memoryhub_memory_store.MemoryHubClient", + return_value=fake_client, + ) as client_cls: + async with server.lifespan(): + # Inside the lifespan, store() should hand back a usable instance. + inst = server.store("ctx-1") + assert isinstance(inst, MemoryHubMemoryStoreInstance) + assert inst._context_id == "ctx-1" + assert inst._client is fake_client + + client_cls.assert_called_once_with(url="http://hub", api_key="the-key") + fake_client.__aenter__.assert_awaited_once() + fake_client.__aexit__.assert_awaited_once_with(None, None, None) + + # After lifespan exit, store() raises. + with pytest.raises(RuntimeError): + server.store("ctx-1") + + async def test_lifespan_uses_oauth_path(self): + fulfillment = MemoryHubFulfillment( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret=SecretStr("csec"), + ) + server = self._server_with_default(fulfillment) + fake_client = _mock_client() + + with patch( + "kagenti_adk.server.store.memoryhub_memory_store.MemoryHubClient", + return_value=fake_client, + ) as client_cls: + async with server.lifespan(): + pass + + client_cls.assert_called_once_with( + url="http://hub", + auth_url="http://auth", + client_id="cid", + client_secret="csec", + ) + + async def test_lifespan_noop_without_fulfillment(self): + spec = MemoryHubExtensionSpec.single_demand() + server = MemoryHubExtensionServer(spec) + server._is_active = True # active but no metadata, no default + + with patch("kagenti_adk.server.store.memoryhub_memory_store.MemoryHubClient") as client_cls: + async with server.lifespan(): + pass + client_cls.assert_not_called() + + # store() outside an active client must raise. + with pytest.raises(RuntimeError): + server.store("ctx") + + async def test_store_outside_lifespan_raises(self): + fulfillment = MemoryHubFulfillment(url="http://hub", api_key=SecretStr("k")) + server = self._server_with_default(fulfillment) + with pytest.raises(RuntimeError): + server.store("ctx-x") + + +# --------------------------------------------------------------------------- +# httpx integration sanity check (verifies the install path stays wired) +# --------------------------------------------------------------------------- + + +class TestMemoryHubClientImportable: + def test_real_memoryhub_client_importable(self): + # Imports happen at module load; this just asserts they didn't blow up. + from memoryhub.client import MemoryHubClient + from memoryhub.exceptions import NotFoundError + + assert MemoryHubClient is not None + assert issubclass(NotFoundError, Exception) diff --git a/apps/adk-py/tests/unit/test_dependencies.py b/apps/adk-py/tests/unit/test_dependencies.py index 362b10754..dfbe8d445 100644 --- a/apps/adk-py/tests/unit/test_dependencies.py +++ b/apps/adk-py/tests/unit/test_dependencies.py @@ -3,7 +3,9 @@ from __future__ import annotations +from contextlib import asynccontextmanager from typing import Annotated, TypedDict, Unpack +from unittest.mock import MagicMock import pytest @@ -14,7 +16,7 @@ TrajectoryExtensionSpec, ) from kagenti_adk.a2a.extensions.streaming import StreamingExtensionServer, StreamingExtensionSpec -from kagenti_adk.server.dependencies import extract_dependencies +from kagenti_adk.server.dependencies import Depends, extract_dependencies pytestmark = pytest.mark.unit @@ -64,3 +66,72 @@ def agent( pass assert extract_dependencies(agent).keys() == {"a", "b"} + + +# --------------------------------------------------------------------------- +# Depends supports awaitable callables (issue #229) +# --------------------------------------------------------------------------- + + +class TestDependsAwaitable: + """Depends should resolve both sync and async dependency callables.""" + + async def test_sync_callable_still_works(self) -> None: + sentinel = object() + dep = Depends(lambda _msg, _ctx, _rctx: sentinel) + async with dep(MagicMock(), MagicMock(), MagicMock()) as resolved: + assert resolved is sentinel + + async def test_async_callable_resolves_awaited_value(self) -> None: + sentinel = object() + + async def async_provider(_msg, _ctx, _rctx): + return sentinel + + dep = Depends(async_provider) + async with dep(MagicMock(), MagicMock(), MagicMock()) as resolved: + assert resolved is sentinel + + async def test_async_callable_returning_lifespan_object_enters_and_exits(self) -> None: + events: list[str] = [] + + class Lifespanned: + @asynccontextmanager + async def lifespan(self): + events.append("enter") + try: + yield + finally: + events.append("exit") + + instance = Lifespanned() + + async def async_provider(_msg, _ctx, _rctx): + return instance + + dep = Depends(async_provider) + async with dep(MagicMock(), MagicMock(), MagicMock()) as resolved: + assert resolved is instance + events.append("body") + + assert events == ["enter", "body", "exit"] + + async def test_sync_callable_returning_lifespan_object_regression(self) -> None: + """BaseExtensionServer-style: sync callable that returns an instance with .lifespan().""" + events: list[str] = [] + + class Lifespanned: + @asynccontextmanager + async def lifespan(self): + events.append("enter") + try: + yield + finally: + events.append("exit") + + instance = Lifespanned() + dep = Depends(lambda _msg, _ctx, _rctx: instance) + async with dep(MagicMock(), MagicMock(), MagicMock()) as resolved: + assert resolved is instance + + assert events == ["enter", "exit"] diff --git a/apps/adk-py/uv.lock b/apps/adk-py/uv.lock index 8a70c4f01..4c92f81a6 100644 --- a/apps/adk-py/uv.lock +++ b/apps/adk-py/uv.lock @@ -7,7 +7,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-10T07:48:23.382492377Z" +exclude-newer = "2026-05-01T12:58:01.299597Z" exclude-newer-span = "P3D" [[package]] @@ -33,6 +33,18 @@ sqlite = [ { name = "sqlalchemy", extra = ["aiosqlite", "asyncio"] }, ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -257,6 +269,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "beeai-framework" version = "0.1.79" @@ -295,6 +325,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, ] +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -552,6 +607,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] +[[package]] +name = "cyclopts" +version = "4.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/2c/fced34890f6e5a93a4b7afb2c71e8eee2a0719fb26193a0abf159ecb714d/cyclopts-4.10.2.tar.gz", hash = "sha256:d7b950457ef2563596d56331f80cbbbf86a2772535fb8b315c4f03bc7e6127f1", size = 166664, upload-time = "2026-04-08T23:57:45.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/bd/05055d8360cef0757d79367157f3b15c0a0715e81e08f86a04018ec045f0/cyclopts-4.10.2-py3-none-any.whl", hash = "sha256:a1f2d6f8f7afac9456b48f75a40b36658778ddc9c6d406b520d017ae32c990fe", size = 204314, upload-time = "2026-04-08T23:57:46.969Z" }, +] + [[package]] name = "ddgs" version = "9.11.4" @@ -587,6 +657,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "fastapi" version = "0.135.2" @@ -603,6 +725,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] +[[package]] +name = "fastmcp" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/13/29544fbc6dfe45ea38046af0067311e0bad7acc7d1f2ad38bb08f2409fe2/fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1", size = 28746127, upload-time = "2026-04-14T01:42:24.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/76/b310d52fa0e30d39bd937eb58ec2c1f1ea1b5f519f0575e9dd9612f01deb/fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9", size = 728599, upload-time = "2026-04-14T01:42:26.85Z" }, +] + [[package]] name = "fastuuid" version = "0.14.0" @@ -871,6 +1026,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1008,6 +1172,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/34/65604740edcb20e1bda6a890348ed7d282e7dd23aa00401cbe36fd0edbd9/janus-2.0.0-py3-none-any.whl", hash = "sha256:7e6449d34eab04cd016befbd7d8c0d8acaaaab67cb59e076a69149f9031745f9", size = 12161, upload-time = "2024-12-13T12:59:06.106Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1168,6 +1377,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] +[[package]] +name = "jsonschema-path" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/7e6102f2b8bdc6705a9eb5294f8f6f9ccd3a8420e8e8e19671d1dd773251/jsonschema_path-0.4.5.tar.gz", hash = "sha256:c6cd7d577ae290c7defd4f4029e86fdb248ca1bd41a07557795b3c95e5144918", size = 15113, upload-time = "2026-03-03T09:56:46.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/d5/4e96c44f6c1ea3d812cf5391d81a4f5abaa540abf8d04ecd7f66e0ed11df/jsonschema_path-0.4.5-py3-none-any.whl", hash = "sha256:7d77a2c3f3ec569a40efe5c5f942c44c1af2a6f96fe0866794c9ef5b8f87fd65", size = 19368, upload-time = "2026-03-03T09:56:45.39Z" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -1211,6 +1434,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.optional-dependencies] +memoryhub = [ + { name = "memoryhub" }, +] + [package.dev-dependencies] dev = [ { name = "beeai-framework", extra = ["duckduckgo", "wikipedia"] }, @@ -1235,6 +1463,7 @@ requires-dist = [ { name = "janus", specifier = ">=2.0.0" }, { name = "jsonpatch", specifier = ">=1.33" }, { name = "mcp", specifier = ">=1.12.3" }, + { name = "memoryhub", marker = "extra == 'memoryhub'", specifier = ">=0.7.0" }, { name = "objprint", specifier = ">=0.3.0" }, { name = "opentelemetry-api", specifier = ">=1.35.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.35.0" }, @@ -1248,6 +1477,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.15.0" }, { name = "uvicorn", specifier = ">=0.37.0,<0.43.0" }, ] +provides-extras = ["memoryhub"] [package.metadata.requires-dev] dev = [ @@ -1259,6 +1489,24 @@ dev = [ { name = "ruff", specifier = ">=0.15.0" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "litellm" version = "1.83.0" @@ -1504,6 +1752,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "memoryhub" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6e/7fbe781c5f5bdb564c0dfb7bd0ccb285891c45a59f0d80a605dbdb4cc9e3/memoryhub-0.7.0.tar.gz", hash = "sha256:3cf3bea4f186c6bf6c8e3ae306540dbe9578f6c6c752d6cc4287eb60e15e3600", size = 43298, upload-time = "2026-04-29T17:45:35.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d3/abc4ac35b21899963c5bd075a3e33c339c089cf2159dfa9ffd3106ded85f/memoryhub-0.7.0-py3-none-any.whl", hash = "sha256:e635bc18b88c4420f68aca88c0cb7227a61db5c8aecf735531d61248f25b701d", size = 23970, upload-time = "2026-04-29T17:45:34.773Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1649,6 +1922,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" }, ] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.40.0" @@ -1840,6 +2125,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathable" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2013,6 +2316,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + [[package]] name = "pyasn1" version = "0.6.3" @@ -2058,6 +2386,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.41.5" @@ -2192,6 +2525,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pyrefly" version = "0.58.0" @@ -2287,6 +2629,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2488,6 +2839,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0" @@ -2621,6 +2985,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -2865,6 +3242,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/68/35c1d87e608940badbcfeb630347aa0509897284684f61fab6423d02b253/uncalled_for-0.3.1.tar.gz", hash = "sha256:5e412ac6708f04b56bef5867b5dcf6690ebce4eb7316058d9c50787492bb4bca", size = 49693, upload-time = "2026-04-07T13:05:06.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/e1/7ec67882ad8fc9f86384bef6421fa252c9cbe5744f8df6ce77afc9eca1f5/uncalled_for-0.3.1-py3-none-any.whl", hash = "sha256:074cdc92da8356278f93d0ded6f2a66dd883dbecaf9bc89437646ee2289cc200", size = 11361, upload-time = "2026-04-07T13:05:05.341Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -2887,6 +3273,152 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "wikipedia-api" version = "0.8.1" diff --git a/apps/adk-server/tests/conftest.py b/apps/adk-server/tests/conftest.py index cc2e4efc8..45b91826d 100644 --- a/apps/adk-server/tests/conftest.py +++ b/apps/adk-server/tests/conftest.py @@ -34,7 +34,15 @@ class Configuration(BaseSettings): @computed_field @property - def llm_provider_type(self) -> ModelProviderType: + def llm_provider_type(self) -> ModelProviderType | None: + # Returns None when llm_model is empty so that pytest_configure's + # model_dump() does not crash during collection on fork PRs where + # vars.OPENAI_MODEL is not exposed to the workflow. The integration + # and e2e jobs are gated to skip on fork PRs in ci.yaml; this is + # defense-in-depth so a stray run produces a clear skip rather than + # an INTERNALERROR. + if not self.llm_model: + return None return ModelProviderType(self.llm_model.split(":", maxsplit=1)[0]) @model_validator(mode="after") diff --git a/apps/adk-server/tests/e2e/examples/agent-integration/memoryhub/test_memoryhub_recall.py b/apps/adk-server/tests/e2e/examples/agent-integration/memoryhub/test_memoryhub_recall.py new file mode 100644 index 000000000..bd31520db --- /dev/null +++ b/apps/adk-server/tests/e2e/examples/agent-integration/memoryhub/test_memoryhub_recall.py @@ -0,0 +1,160 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +# Maintainer note: this E2E test requires a reachable MemoryHub instance. +# Add the following repository secrets so CI runs the test: +# MEMORYHUB_E2E_URL (required) — public MemoryHub MCP URL +# MEMORYHUB_E2E_API_KEY (one of) — static API key +# MEMORYHUB_E2E_AUTH_URL (or…) +# MEMORYHUB_E2E_CLIENT_ID +# MEMORYHUB_E2E_CLIENT_SECRET — OAuth 2.1 credentials +# When neither credential set is configured the test skips cleanly so +# contributor forks don't fail on a missing secret. +# +# URL drift handling: the configured backend can move (URL changes, +# certificate expires, credentials rotate) without anyone updating the +# repo secrets. A pre-flight probe distinguishes that drift from real +# test logic regressions and converts it into an xfail with a clear +# reason, instead of a mysterious assertion failure deep inside the +# example agent's task stream. + +from __future__ import annotations + +import os + +import httpx +import pytest +from a2a.client.helpers import create_text_message_object +from a2a.types import SendMessageRequest, TaskState +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionClient, + MemoryHubExtensionSpec, + MemoryHubFulfillment, +) +from pydantic import SecretStr + +# The pre-flight URL drift probe needs the memoryhub SDK in the test +# environment. The SDK is an optional extra of kagenti-adk and is not +# installed by adk-server's dev deps, so collect-skip if it isn't there +# rather than break the whole adk-server e2e suite. +pytest.importorskip("memoryhub") + +from memoryhub.client import MemoryHubClient +from memoryhub.exceptions import ( + AuthenticationError, + ConnectionFailedError, + MemoryHubError, +) + +from tests.e2e.examples.conftest import run_example + +pytestmark = pytest.mark.e2e + + +def _fulfillment_from_secrets() -> MemoryHubFulfillment | None: + url = os.environ.get("MEMORYHUB_E2E_URL") + if not url: + return None + api_key = os.environ.get("MEMORYHUB_E2E_API_KEY") + if api_key: + return MemoryHubFulfillment(url=url, api_key=SecretStr(api_key)) + auth_url = os.environ.get("MEMORYHUB_E2E_AUTH_URL") + client_id = os.environ.get("MEMORYHUB_E2E_CLIENT_ID") + client_secret = os.environ.get("MEMORYHUB_E2E_CLIENT_SECRET") + if auth_url and client_id and client_secret: + return MemoryHubFulfillment( + url=url, + auth_url=auth_url, + client_id=client_id, + client_secret=SecretStr(client_secret), + ) + return None + + +def _client_for(fulfillment: MemoryHubFulfillment) -> MemoryHubClient: + if fulfillment.api_key is not None: + return MemoryHubClient( + url=fulfillment.url, + api_key=fulfillment.api_key.get_secret_value(), + ) + return MemoryHubClient( + url=fulfillment.url, + auth_url=fulfillment.auth_url, + client_id=fulfillment.client_id, + client_secret=(fulfillment.client_secret.get_secret_value() if fulfillment.client_secret is not None else None), + ) + + +def _classify_drift(exc: BaseException, url: str) -> str | None: + """Walk the exception's cause chain looking for a drift signal. + + FastMCP wraps transport errors in a generic ``RuntimeError`` at + ``_connect``; the original ``httpx.ConnectError`` / DNS failure + only shows up in ``__cause__``. Walk the chain so DNS failures and + refused-connection cases are still classified as URL drift instead + of leaking as generic errors. + """ + cur: BaseException | None = exc + seen: set[int] = set() + while cur is not None and id(cur) not in seen: + seen.add(id(cur)) + if isinstance(cur, AuthenticationError): + return f"MemoryHub auth drift at {url}: credentials rejected ({cur})" + if isinstance(cur, ConnectionFailedError): + return f"MemoryHub URL drift at {url}: cannot reach backend ({cur})" + if isinstance(cur, (httpx.HTTPError, OSError)): + return f"MemoryHub URL drift at {url}: transport error ({type(cur).__name__}: {cur})" + if isinstance(cur, MemoryHubError): + return f"MemoryHub backend unhealthy at {url}: {type(cur).__name__}: {cur}" + cur = cur.__cause__ or cur.__context__ + return None + + +async def _xfail_on_backend_drift(fulfillment: MemoryHubFulfillment) -> None: + """Probe MemoryHub before we spin up the example. + + The example agent will use these exact credentials to call MemoryHub + once it's running. If the URL has moved, the cert is invalid, or the + credentials no longer authenticate, that surfaces here as a single + xfail with a clear reason — not as a generic + ``task.status.state == FAILED`` assertion error after spending CI + time on the platform deploy. + """ + try: + async with _client_for(fulfillment) as client: + await client.get_session() + except Exception as exc: + reason = _classify_drift(exc, str(fulfillment.url)) + if reason is not None: + pytest.xfail(reason) + raise + + +@pytest.mark.usefixtures("clean_up", "setup_platform_client") +async def test_memoryhub_recall_example(subtests, get_final_task_from_stream, a2a_client_factory): + fulfillment = _fulfillment_from_secrets() + if fulfillment is None: + pytest.skip( + "MemoryHub E2E secrets not configured (set MEMORYHUB_E2E_URL plus MEMORYHUB_E2E_API_KEY or the OAuth trio)." + ) + + await _xfail_on_backend_drift(fulfillment) + + example_path = "agent-integration/memoryhub/memoryhub-recall" + + async with run_example(example_path, a2a_client_factory) as running_example: + spec = MemoryHubExtensionSpec.from_agent_card(running_example.provider.agent_card) + metadata = MemoryHubExtensionClient(spec).fulfillment_metadata(memoryhub_fulfillments={"default": fulfillment}) + + with subtests.test("agent recalls and stores via MemoryHub"): + message = create_text_message_object(content="favorite color is teal") + message.metadata = metadata + message.context_id = running_example.context.id + task = await get_final_task_from_stream( + running_example.client.send_message(SendMessageRequest(message=message)) + ) + + assert task.status.state == TaskState.TASK_STATE_COMPLETED, f"Fail: {task.status.message.parts[0].text}" + text = task.history[-1].parts[0].text + assert "stored 1" in text + assert "recalled" in text diff --git a/apps/adk-server/uv.lock b/apps/adk-server/uv.lock index 74abff952..2d46596a5 100644 --- a/apps/adk-server/uv.lock +++ b/apps/adk-server/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = "==3.14.*" [options] -exclude-newer = "2026-04-10T07:48:38.345321368Z" +exclude-newer = "2026-05-02T12:09:21.660063Z" exclude-newer-span = "P3D" [options.exclude-newer-package] @@ -1320,6 +1320,7 @@ requires-dist = [ { name = "janus", specifier = ">=2.0.0" }, { name = "jsonpatch", specifier = ">=1.33" }, { name = "mcp", specifier = ">=1.12.3" }, + { name = "memoryhub", marker = "extra == 'memoryhub'", specifier = ">=0.7.0" }, { name = "objprint", specifier = ">=0.3.0" }, { name = "opentelemetry-api", specifier = ">=1.35.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.35.0" }, @@ -1333,6 +1334,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.15.0" }, { name = "uvicorn", specifier = ">=0.37.0,<0.43.0" }, ] +provides-extras = ["memoryhub"] [package.metadata.requires-dev] dev = [ diff --git a/docs/development/sdk/memory.mdx b/docs/development/sdk/memory.mdx new file mode 100644 index 000000000..2b4491e14 --- /dev/null +++ b/docs/development/sdk/memory.mdx @@ -0,0 +1,195 @@ +--- +title: "Memory Store" +description: "Give agents durable, cross-session memory backed by a governed knowledge store." +--- + +`MemoryStore` is a complement to `ContextStore`. While `ContextStore` replays the current conversation (per-context, ephemeral), `MemoryStore` provides durable knowledge that persists across sessions — things the agent should remember regardless of which conversation is active. + +The interface is backend-agnostic. The built-in implementation uses [MemoryHub](https://github.com/redhat-ai-americas/memory-hub), an open-source governed memory service for AI agents. + +## Protocol + +Two types make up the abstraction: + +**`MemoryStoreInstance`** — the per-context operations protocol: + +```python +class MemoryStoreInstance(Protocol): + async def search(self, query: str, *, scope: str | None = None, + project_id: str | None = None, max_results: int = 10) -> list[MemoryResult]: ... + async def create(self, content: str, *, scope: str = "user", weight: float = 0.7, + tags: list[str] | None = None, project_id: str | None = None) -> str: ... + async def read(self, memory_id: str) -> MemoryResult | None: ... + async def update(self, memory_id: str, content: str) -> None: ... + async def delete(self, memory_id: str) -> None: ... +``` + +`create()` returns the new `memory_id`, or raises `MemoryRejectionError` if the backend rejected the write. The `scope`, `weight`, `tags` and `project_id` arguments are backend-defined; see the protocol docstrings for the worked MemoryHub mapping. + +Handle rejection like this: + +```python +import logging + +from kagenti_adk.server.store.exceptions import MemoryRejectionError +from kagenti_adk.server.store.memory_store import MemoryStoreInstance + +logger = logging.getLogger(__name__) + + +async def store_user_preference(store: MemoryStoreInstance) -> None: + try: + memory_id = await store.create("user prefers dark mode", scope="user") + except MemoryRejectionError as e: + logger.info("Memory rejected: %s", e.reason) +``` + +**`MemoryResult`** — the value type returned from `search()` and `read()`: + +```python +class MemoryResult(BaseModel): + memory_id: str + content: str + scope: str + weight: float = 0.7 + relevance_score: float | None = None +``` + +## MemoryHub via the A2A service extension + +Add the `memoryhub` extra to your project: + +```bash +uv add 'kagenti-adk[memoryhub]' +``` + +The MemoryHub backend is wired into agents through an A2A service extension. The agent declares a demand; the calling client supplies a `MemoryHubFulfillment` with a URL and credentials in the request metadata. Inside the agent, the extension exposes a per-context `MemoryHubMemoryStoreInstance`. + +### Server side (the agent) + +```python +from typing import Annotated + +from a2a.types import Message +from kagenti_adk.a2a.extensions import MemoryHubExtensionSpec +from kagenti_adk.server import Server +from kagenti_adk.server.context import RunContext +from kagenti_adk.server.store.memoryhub_memory_store import MemoryHubExtensionServer + +server = Server() + + +@server.agent() +async def my_agent( + input: Message, + context: RunContext, + memoryhub: Annotated[MemoryHubExtensionServer, MemoryHubExtensionSpec.single_demand()], +): + store = memoryhub.store(context.context_id) + results = await store.search("user preferences") + await store.create("User prefers concise responses", scope="user", weight=0.8) + yield f"recalled {len(results)} items" +``` + +### Client side (calling the agent) + +```python +from a2a.types import AgentCard +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionClient, + MemoryHubExtensionSpec, + MemoryHubFulfillment, +) +from pydantic import SecretStr + + +def memoryhub_metadata(agent_card: AgentCard): + spec = MemoryHubExtensionSpec.from_agent_card(agent_card) + if spec is None: + raise RuntimeError("agent does not advertise the memoryhub extension") + return MemoryHubExtensionClient(spec).fulfillment_metadata( + memoryhub_fulfillments={ + "default": MemoryHubFulfillment( + url="https://memoryhub.example.com/mcp/", + api_key=SecretStr("..."), + ) + } + ) + + +# attach the returned dict to the outgoing A2A message metadata +``` + +`MemoryHubFulfillment` accepts either an `api_key` (dev/testing path) or the OAuth 2.1 trio `auth_url` + `client_id` + `client_secret`. + +### Server-side environment fallback + +When no fulfillment metadata is provided, the extension falls back to environment variables — useful for local development: + +| Variable | Description | +|---|---| +| `MEMORYHUB_URL` | MemoryHub MCP URL (required) | +| `MEMORYHUB_API_KEY` | Static API key (one path) | +| `MEMORYHUB_AUTH_URL` | OAuth 2.1 token endpoint (other path) | +| `MEMORYHUB_CLIENT_ID` | OAuth client ID | +| `MEMORYHUB_CLIENT_SECRET` | OAuth client secret | + +## Full example + +A runnable agent that searches MemoryHub for the user's input, then stores one fact from it: + +{/* */} +```python +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +import os +from typing import Annotated + +from a2a.types import Message +from a2a.utils.message import get_message_text +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionSpec, +) +from kagenti_adk.a2a.types import AgentMessage +from kagenti_adk.server import Server +from kagenti_adk.server.context import RunContext +from kagenti_adk.server.store.memoryhub_memory_store import MemoryHubExtensionServer + +server = Server() + + +@server.agent() +async def memoryhub_recall_example( + input: Message, + context: RunContext, + memoryhub: Annotated[MemoryHubExtensionServer, MemoryHubExtensionSpec.single_demand()], +): + """Search MemoryHub for the user's query, then store one fact from the input.""" + query = get_message_text(input) + store = memoryhub.store(context.context_id) + + results = await store.search(query, max_results=5) + await store.create( + f"User mentioned: {query}", + scope="project", + project_id="kagenti-tests", + weight=0.7, + ) + + yield AgentMessage(text=f"recalled {len(results)} items, stored 1") + + +def run(): + server.run(host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", 8000))) + + +if __name__ == "__main__": + run() +``` + +The example lives at `examples/agent-integration/memoryhub/memoryhub-recall`. + + +Use `scope="project"` with a `project_id` to share memories across multiple agents working on the same project, rather than scoping them to a single user. + diff --git a/docs/docs.json b/docs/docs.json index cf0cf1d44..7e754663e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -145,7 +145,8 @@ "development/sdk/error", "development/sdk/tool-calls", "development/sdk/canvas", - "development/sdk/observability" + "development/sdk/observability", + "development/sdk/memory" ] }, { diff --git a/examples/agent-integration/memoryhub/memoryhub-recall/pyproject.toml b/examples/agent-integration/memoryhub/memoryhub-recall/pyproject.toml new file mode 100644 index 000000000..0e77c0c04 --- /dev/null +++ b/examples/agent-integration/memoryhub/memoryhub-recall/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "memoryhub-recall" +version = "0.1.0" +description = "Demonstrates cross-session memory recall via the MemoryHub A2A service extension" +authors = [ + { name = "IBM Corp." }, +] +requires-python = ">=3.14,<3.15" +dependencies = [ + "kagenti-adk[memoryhub]>=0.5.3", + "pyyaml>=6.0.2", +] + +[tool.ruff] +line-length = 120 +target-version = "py314" + +[tool.uv.sources] +kagenti-adk = { path = "../../../../apps/adk-py", editable = true } + +[project.scripts] +server = "memoryhub_recall.agent:run" + +[build-system] +requires = ["uv_build>=0.10.0,<0.11.0"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "watchfiles>=1.1.0", +] + +[tool.pyright] +venvPath = "." +venv = ".venv" diff --git a/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/__init__.py b/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/__init__.py new file mode 100644 index 000000000..7abe8ca66 --- /dev/null +++ b/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 diff --git a/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/agent.py b/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/agent.py new file mode 100644 index 000000000..624f8cdfb --- /dev/null +++ b/examples/agent-integration/memoryhub/memoryhub-recall/src/memoryhub_recall/agent.py @@ -0,0 +1,46 @@ +# Copyright 2026 © IBM Corp. +# SPDX-License-Identifier: Apache-2.0 + +import os +from typing import Annotated + +from a2a.types import Message +from a2a.utils.message import get_message_text +from kagenti_adk.a2a.extensions import ( + MemoryHubExtensionSpec, +) +from kagenti_adk.a2a.types import AgentMessage +from kagenti_adk.server import Server +from kagenti_adk.server.context import RunContext +from kagenti_adk.server.store.memoryhub_memory_store import MemoryHubExtensionServer + +server = Server() + + +@server.agent() +async def memoryhub_recall_example( + input: Message, + context: RunContext, + memoryhub: Annotated[MemoryHubExtensionServer, MemoryHubExtensionSpec.single_demand()], +): + """Search MemoryHub for the user's query, then store one fact from the input.""" + query = get_message_text(input) + store = memoryhub.store(context.context_id) + + results = await store.search(query, max_results=5) + await store.create( + f"User mentioned: {query}", + scope="project", + project_id="kagenti-tests", + weight=0.7, + ) + + yield AgentMessage(text=f"recalled {len(results)} items, stored 1") + + +def run(): + server.run(host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", 8000))) + + +if __name__ == "__main__": + run()