diff --git a/pyproject.toml b/pyproject.toml index a9e4a46a..9a8cf7a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.36.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 98195c88..6177df68 100644 --- a/src/sap_cloud_sdk/agent_memory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/__init__.py @@ -5,9 +5,13 @@ Usage:: - from sap_cloud_sdk.agent_memory import create_client + from sap_cloud_sdk.agent_memory import create_client, AccessStrategy - client = create_client() + # Subscriber tenant — strategy and tenant set once, inherited by all calls + client = create_client( + access_strategy=AccessStrategy.SUBSCRIBER, + tenant="my-tenant-subdomain", + ) memories = client.list_memories(agent_id="my-agent", invoker_id="user-123") """ @@ -15,7 +19,11 @@ from sap_cloud_sdk.agent_memory._http_transport import HttpTransport from sap_cloud_sdk.agent_memory.client import AgentMemoryClient -from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig, _load_config_from_env +from sap_cloud_sdk.agent_memory.config import ( + AgentMemoryConfig, + _load_config_for_instance, + _load_config_from_env, +) from sap_cloud_sdk.agent_memory.exceptions import ( AgentMemoryConfigError, AgentMemoryError, @@ -24,6 +32,7 @@ AgentMemoryValidationError, ) from sap_cloud_sdk.agent_memory._models import ( + AccessStrategy, Memory, Message, MessageRole, @@ -33,25 +42,54 @@ from sap_cloud_sdk.agent_memory.utils._odata import FilterDefinition -def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryClient: +def create_client( + *, + config: Optional[AgentMemoryConfig] = None, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER, + tenant: Optional[str] = None, +) -> AgentMemoryClient: """Create an :class:`AgentMemoryClient` with automatic credential detection. + The binding loaded depends on ``access_strategy`` and ``tenant``: + + - ``SUBSCRIBER`` with ``tenant="acme-corp"`` — loads the subscriber + binding from ``/etc/secrets/appfnd/hana-agent-memory/acme-corp/`` (or + ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_*`` env vars). + - ``PROVIDER`` — loads the provider binding from + ``/etc/secrets/appfnd/hana-agent-memory/default/`` (or + ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` env vars). + - Explicit ``config`` — uses the provided configuration directly. + Args: - config: Optional explicit configuration. If ``None``, credentials are - loaded from the mounted volume at - ``/etc/secrets/appfnd/hana-agent-memory/default/`` or from - ``CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_*`` environment variables. + config: Optional explicit configuration. When provided, binding + discovery is skipped. + access_strategy: Tenant access strategy for all operations. + Defaults to ``SUBSCRIBER``. + tenant: Subscriber tenant subdomain. Required when + ``access_strategy=SUBSCRIBER``. Returns: A ready-to-use :class:`AgentMemoryClient`. Raises: AgentMemoryConfigError: If configuration is missing or invalid. + AgentMemoryValidationError: If ``access_strategy=SUBSCRIBER`` and + ``tenant`` is not provided. """ try: - resolved_config = config if config is not None else _load_config_from_env() + if config is not None: + resolved_config = config + elif access_strategy is AccessStrategy.SUBSCRIBER and tenant: + resolved_config = _load_config_for_instance(tenant) + else: + resolved_config = _load_config_from_env() + transport = HttpTransport(resolved_config) - return AgentMemoryClient(transport) + return AgentMemoryClient( + transport, + access_strategy=access_strategy, + tenant=tenant, + ) except AgentMemoryConfigError: raise except Exception as exc: @@ -61,6 +99,7 @@ def create_client(*, config: Optional[AgentMemoryConfig] = None) -> AgentMemoryC __all__ = [ + "AccessStrategy", "AgentMemoryClient", "AgentMemoryConfig", "AgentMemoryError", diff --git a/src/sap_cloud_sdk/agent_memory/_http_transport.py b/src/sap_cloud_sdk/agent_memory/_http_transport.py index e8c80176..9ec4daad 100644 --- a/src/sap_cloud_sdk/agent_memory/_http_transport.py +++ b/src/sap_cloud_sdk/agent_memory/_http_transport.py @@ -1,8 +1,8 @@ """HTTP transport for the Agent Memory service. Handles OAuth2 ``client_credentials`` token acquisition with lazy, -expiry-aware caching. If ``token_url`` is not configured, requests are -sent unauthenticated — expected for local development environments. +expiry-aware caching per tenant subdomain. If ``token_url`` is not configured, +requests are sent unauthenticated — expected for local development environments. """ from __future__ import annotations @@ -31,10 +31,9 @@ class HttpTransport: """Internal HTTP transport for the Agent Memory service. - Manages OAuth2 token lifecycle (lazy acquire + expiry-aware caching) and - attaches the ``Authorization`` header to every request automatically via - ``OAuth2Session``. In no-auth mode (no ``token_url``), a plain - ``requests.Session`` is used instead. + Manages OAuth2 token lifecycle (lazy acquire + expiry-aware caching) per + tenant subdomain and attaches the ``Authorization`` header automatically. + In no-auth mode (no ``token_url``), a plain ``requests.Session`` is used. Args: config: Service configuration. @@ -42,27 +41,34 @@ class HttpTransport: def __init__(self, config: AgentMemoryConfig) -> None: self._config = config - self._oauth: Optional[OAuth2Session] = None + # Keyed by tenant_subdomain (None = provider token) + self._oauth_cache: dict[Optional[str], tuple[OAuth2Session, datetime]] = {} self._plain_session: Optional[requests.Session] = None - self._token_expires_at: Optional[datetime] = None def close(self) -> None: - """Close the underlying HTTP session(s) and release resources.""" - if self._oauth is not None: - self._oauth.close() - self._oauth = None + """Close all underlying HTTP sessions and release resources.""" + for oauth, _ in self._oauth_cache.values(): + oauth.close() + self._oauth_cache.clear() if self._plain_session is not None: self._plain_session.close() self._plain_session = None # ── Public HTTP methods ──────────────────────────────────────────────────── - def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: + def get( + self, + path: str, + params: Optional[dict[str, Any]] = None, + *, + tenant_subdomain: Optional[str] = None, + ) -> dict[str, Any]: """Perform a GET request. Args: path: API path (appended to ``base_url``). params: Optional query parameters. + tenant_subdomain: Subscriber tenant subdomain for token derivation. Returns: Parsed JSON response body. @@ -71,14 +77,23 @@ def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, A AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - return self._request("GET", path, params=params) + return self._request( + "GET", path, params=params, tenant_subdomain=tenant_subdomain + ) - def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: + def post( + self, + path: str, + json: Optional[dict[str, Any]] = None, + *, + tenant_subdomain: Optional[str] = None, + ) -> dict[str, Any]: """Perform a POST request. Args: path: API path (appended to ``base_url``). json: Optional request body dict (serialised to JSON). + tenant_subdomain: Subscriber tenant subdomain for token derivation. Returns: Parsed JSON response body. Returns an empty dict for 204 responses. @@ -87,14 +102,21 @@ def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, An AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - return self._request("POST", path, json=json) - - def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: + return self._request("POST", path, json=json, tenant_subdomain=tenant_subdomain) + + def patch( + self, + path: str, + json: Optional[dict[str, Any]] = None, + *, + tenant_subdomain: Optional[str] = None, + ) -> dict[str, Any]: """Perform a PATCH request. Args: path: API path (appended to ``base_url``). json: Optional request body dict (serialised to JSON). + tenant_subdomain: Subscriber tenant subdomain for token derivation. Returns: Parsed JSON response body. Returns an empty dict for 204 responses. @@ -103,46 +125,51 @@ def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, A AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - return self._request("PATCH", path, json=json) + return self._request( + "PATCH", path, json=json, tenant_subdomain=tenant_subdomain + ) - def delete(self, path: str) -> None: + def delete(self, path: str, *, tenant_subdomain: Optional[str] = None) -> None: """Perform a DELETE request. Args: path: API path (appended to ``base_url``). + tenant_subdomain: Subscriber tenant subdomain for token derivation. Raises: AgentMemoryHttpError: On HTTP errors or network failures. AgentMemoryNotFoundError: If the server returns 404. """ - self._request("DELETE", path) + self._request("DELETE", path, tenant_subdomain=tenant_subdomain) # ── Internal helpers ─────────────────────────────────────────────────────── - def _get_session(self) -> requests.Session: - """Return a session ready to make requests. + def _get_session(self, tenant_subdomain: Optional[str]) -> requests.Session: + """Return a session ready to make requests for the given tenant. In no-auth mode, returns a plain ``requests.Session`` (created once). In OAuth2 mode, returns an ``OAuth2Session`` with a valid token, - fetching or refreshing the token if needed. + fetching or refreshing per-tenant as needed. """ if not self._config.token_url: if self._plain_session is None: self._plain_session = requests.Session() return self._plain_session - if ( - self._oauth is not None - and self._token_expires_at is not None - and datetime.now() < self._token_expires_at - ): - return self._oauth + cached = self._oauth_cache.get(tenant_subdomain) + if cached is not None: + oauth, expires_at = cached + if datetime.now() < expires_at: + return oauth - self._oauth = self._fetch_token() - return self._oauth + return self._fetch_token(tenant_subdomain) - def _fetch_token(self) -> OAuth2Session: - """Acquire a new OAuth2 ``client_credentials`` token. + def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session: + """Acquire a new OAuth2 ``client_credentials`` token for the given tenant. + + When ``tenant_subdomain`` is provided and ``config.identityzone`` is set, + derives the subscriber token URL by replacing the provider identityzone + in ``token_url`` with ``tenant_subdomain``. Returns: An ``OAuth2Session`` with a valid token attached. @@ -150,11 +177,19 @@ def _fetch_token(self) -> OAuth2Session: Raises: AgentMemoryHttpError: If the token endpoint returns an error or is unreachable. """ + token_url = self._config.token_url + if ( + tenant_subdomain is not None + and self._config.identityzone is not None + and token_url is not None + ): + token_url = token_url.replace(self._config.identityzone, tenant_subdomain) + try: client = BackendApplicationClient(client_id=self._config.client_id) oauth = OAuth2Session(client=client) token = oauth.fetch_token( - token_url=self._config.token_url, + token_url=token_url, client_id=self._config.client_id, client_secret=self._config.client_secret, timeout=self._config.timeout, @@ -163,21 +198,32 @@ def _fetch_token(self) -> OAuth2Session: raise AgentMemoryHttpError(f"Failed to obtain OAuth2 token: {exc}") from exc expires_in: int = token.get("expires_in", 3600) - self._token_expires_at = datetime.now() + timedelta( + expires_at = datetime.now() + timedelta( seconds=expires_in - _TOKEN_EXPIRY_BUFFER_SECONDS ) - if self._oauth is not None: - self._oauth.close() + existing = self._oauth_cache.get(tenant_subdomain) + if existing is not None: + existing[0].close() + + self._oauth_cache[tenant_subdomain] = (oauth, expires_at) logger.debug( - "Obtained new Agent Memory OAuth2 token (expires in %ds)", expires_in + "Obtained new Agent Memory OAuth2 token for tenant=%r (expires in %ds)", + tenant_subdomain, + expires_in, ) return oauth - def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: + def _request( + self, + method: str, + path: str, + tenant_subdomain: Optional[str] = None, + **kwargs: Any, + ) -> dict[str, Any]: """Execute an HTTP request using the appropriate session.""" - logger.debug("%s %s", method, path) + logger.debug("%s %s (tenant=%r)", method, path, tenant_subdomain) url = f"{self._config.base_url}{path}" if "params" in kwargs: @@ -185,7 +231,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: if raw_params: url = f"{url}?{urlencode(raw_params, quote_via=quote)}" - session = self._get_session() + session = self._get_session(tenant_subdomain) headers = {"Content-Type": "application/json"} try: diff --git a/src/sap_cloud_sdk/agent_memory/_models.py b/src/sap_cloud_sdk/agent_memory/_models.py index ed41a293..0d9f0862 100644 --- a/src/sap_cloud_sdk/agent_memory/_models.py +++ b/src/sap_cloud_sdk/agent_memory/_models.py @@ -15,6 +15,13 @@ from typing import Any, Optional +class AccessStrategy(str, Enum): + """Access strategy for tenant-scoped Agent Memory operations.""" + + SUBSCRIBER = "SUBSCRIBER" + PROVIDER = "PROVIDER" + + class MessageRole(str, Enum): """Role of the message author.""" diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index f12030fb..9e28ac54 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -9,6 +9,7 @@ from __future__ import annotations +import logging from typing import Any, Optional from sap_cloud_sdk.agent_memory._endpoints import ( @@ -19,6 +20,7 @@ ) from sap_cloud_sdk.agent_memory._http_transport import HttpTransport from sap_cloud_sdk.agent_memory._models import ( + AccessStrategy, Memory, Message, MessageRole, @@ -32,9 +34,13 @@ build_message_filter, extract_value_and_count, ) -from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryValidationError +from sap_cloud_sdk.agent_memory.exceptions import ( + AgentMemoryValidationError, +) from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +logger = logging.getLogger(__name__) + def _require_non_empty(**fields: str) -> None: """Raise AgentMemoryValidationError if any named field is an empty string.""" @@ -71,10 +77,31 @@ class AgentMemoryClient: Do not instantiate directly — use :func:`sap_cloud_sdk.agent_memory.create_client`. Args: - transport: HTTP transport layer (injected by ``create_client``). + transport: HTTP transport loaded from the binding for the configured + access strategy and tenant (resolved once at construction time by + :func:`sap_cloud_sdk.agent_memory.create_client`). + access_strategy: Tenant access strategy for all operations. + Defaults to ``SUBSCRIBER``. + tenant: Subscriber tenant subdomain. Required when + ``access_strategy=SUBSCRIBER``. """ - def __init__(self, transport: HttpTransport) -> None: + def __init__( + self, + transport: HttpTransport, + *, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER, + tenant: Optional[str] = None, + ) -> None: + if access_strategy is AccessStrategy.SUBSCRIBER and not tenant: + raise AgentMemoryValidationError( + "tenant is required when access_strategy=SUBSCRIBER" + ) + if access_strategy is AccessStrategy.PROVIDER: + logger.warning( + "AccessStrategy.PROVIDER is active: no tenant isolation will be applied. " + "Only use this strategy for provider-owned operations." + ) self._transport = transport def close(self) -> None: @@ -160,7 +187,8 @@ def update_memory( Raises: AgentMemoryNotFoundError: If no memory with the given ID exists. - AgentMemoryValidationError: If ``memory_id`` is empty or no fields are provided. + AgentMemoryValidationError: If ``memory_id`` is empty, no fields are provided, + or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) @@ -217,8 +245,8 @@ def list_memories( List of :class:`Memory` objects. Raises: - AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, or a - filter clause is invalid. + AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, a filter + clause is invalid, or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ if limit < 1: @@ -242,9 +270,7 @@ def list_memories( @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_COUNT_MEMORIES) def count_memories( - self, - agent_id: Optional[str] = None, - invoker_id: Optional[str] = None, + self, agent_id: Optional[str] = None, invoker_id: Optional[str] = None ) -> int: """Count memories matching the given filters. @@ -289,9 +315,9 @@ def search_memories( List of :class:`SearchResult` objects. Raises: - AgentMemoryValidationError: If required fields are empty or parameters are + AgentMemoryValidationError: If required fields are empty, parameters are out of range (``query`` must be 5–5000 chars, ``threshold`` 0.0–1.0, - ``limit`` 1–50). + ``limit`` 1–50), or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ _require_non_empty(agent_id=agent_id, invoker_id=invoker_id, query=query) @@ -430,8 +456,8 @@ def list_messages( List of :class:`Message` objects. Raises: - AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, or a - filter clause is invalid. + AgentMemoryValidationError: If ``limit`` < 1, ``offset`` < 0, a filter + clause is invalid, or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ if limit < 1: @@ -461,6 +487,8 @@ def list_messages( def get_retention_config(self) -> RetentionConfig: """Retrieve the data retention configuration (singleton). + Args: + Returns: The current :class:`RetentionConfig`. @@ -490,8 +518,8 @@ def update_retention_config( usage_log_days: How long to keep access and search logs (days). Raises: - AgentMemoryValidationError: If no fields are provided, or any provided - value is negative. + AgentMemoryValidationError: If no fields are provided, any provided value is + negative, or tenant is missing for ``SUBSCRIBER``. AgentMemoryHttpError: If the request fails. """ if message_days is None and memory_days is None and usage_log_days is None: diff --git a/src/sap_cloud_sdk/agent_memory/config.py b/src/sap_cloud_sdk/agent_memory/config.py index e2cbdfa7..37569002 100644 --- a/src/sap_cloud_sdk/agent_memory/config.py +++ b/src/sap_cloud_sdk/agent_memory/config.py @@ -5,8 +5,10 @@ Mount path convention:: - /etc/secrets/appfnd/hana-agent-memory/default/application_url + /etc/secrets/appfnd/hana-agent-memory/default/application_url (provider) + /etc/secrets/appfnd/hana-agent-memory//application_url (subscriber) /etc/secrets/appfnd/hana-agent-memory/default/uaa + /etc/secrets/appfnd/hana-agent-memory//uaa ``application_url`` is the Agent Memory service base URL (plain string). ``uaa`` is a JSON string with OAuth2 credentials containing at minimum: @@ -16,6 +18,8 @@ CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__UAA """ import json @@ -35,6 +39,9 @@ class AgentMemoryConfig: requests are sent without authentication (useful for local development). client_id: The OAuth2 client ID. Optional. client_secret: The OAuth2 client secret. Optional. + identityzone: The provider tenant identity zone subdomain. Required when using + ``SUBSCRIBER`` access strategy so the subscriber token URL + can be derived by replacing this value in ``token_url``. timeout: Timeout in seconds for HTTP requests. Default is 30.0. Example — deployed BTP service:: @@ -44,6 +51,7 @@ class AgentMemoryConfig: token_url="https://.authentication./oauth/token", client_id="", client_secret="", + identityzone="", ) Example — local development (no auth):: @@ -55,6 +63,7 @@ class AgentMemoryConfig: token_url: Optional[str] = None client_id: Optional[str] = None client_secret: Optional[str] = None + identityzone: Optional[str] = None timeout: float = 30.0 def __post_init__(self) -> None: @@ -72,6 +81,10 @@ def __post_init__(self) -> None: raise AgentMemoryConfigError( "client_secret must be a non-empty string when provided" ) + if self.identityzone is not None and not self.identityzone: + raise AgentMemoryConfigError( + "identityzone must be a non-empty string when provided" + ) @dataclass @@ -108,6 +121,7 @@ def extract_config(self) -> AgentMemoryConfig: token_url=uaa_data["url"].rstrip("/") + "/oauth/token", client_id=uaa_data["clientid"], client_secret=uaa_data["clientsecret"], + identityzone=uaa_data.get("identityzone"), ) except KeyError as e: raise AgentMemoryConfigError(f"Missing required field in uaa JSON: {e}") @@ -120,6 +134,29 @@ def _load_config_from_env() -> AgentMemoryConfig: 1. Mount at ``/etc/secrets/appfnd/hana-agent-memory/default/`` 2. Environment variables ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` + Returns: + A validated ``AgentMemoryConfig``. + + Raises: + AgentMemoryConfigError: If configuration cannot be loaded or is incomplete. + """ + return _load_config_for_instance("default") + + +def _load_config_for_instance(instance: str) -> AgentMemoryConfig: + """Load Agent Memory configuration for a named binding instance. + + Uses the secret resolver with fallback order: + 1. Mount at ``/etc/secrets/appfnd/hana-agent-memory/{instance}/`` + 2. Environment variables ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_{INSTANCE}_*`` + + This is used to load tenant-specific bindings when the runtime provisions + a dedicated service instance per subscriber tenant. + + Args: + instance: The binding instance name — ``"default"`` for the provider, + or a tenant subdomain for a subscriber (e.g. ``"acme-corp"``). + Returns: A validated ``AgentMemoryConfig``. @@ -136,7 +173,7 @@ def _load_config_from_env() -> AgentMemoryConfig: base_volume_mount="/etc/secrets/appfnd", base_var_name="CLOUD_SDK_CFG", module="hana-agent-memory", - instance="default", + instance=instance, target=binding, ) binding.validate() @@ -145,5 +182,5 @@ def _load_config_from_env() -> AgentMemoryConfig: raise except Exception as exc: raise AgentMemoryConfigError( - f"Failed to load Agent Memory configuration: {exc}" + f"Failed to load Agent Memory configuration for instance '{instance}': {exc}" ) from exc diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 7eb4d56a..3f611771 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -22,6 +22,11 @@ plain text, and the service makes it searchable by meaning. - [Core Concepts](#core-concepts) - [`agent_id`](#agent_id) - [`invoker_id`](#invoker_id) + - [Multitenancy](#multitenancy) + - [AccessStrategy](#accessstrategy) + - [Configuring at client level](#configuring-at-client-level) + - [SUBSCRIBER (default)](#subscriber-default) + - [PROVIDER](#provider) - [Semantic Search: A Brief Primer](#semantic-search-a-brief-primer) - [Memories](#memories) - [Create a Memory](#create-a-memory) @@ -67,6 +72,7 @@ plain text, and the service makes it searchable by meaning. - [Usage with LangGraph StateGraph](#usage-with-langgraph-stategraph) - [Usage with LangChain create\_agent](#usage-with-langchain-create_agent) - [Thread TTL](#thread-ttl) + - [Exposing TTL as a configurable parameter with `@agent_config`](#exposing-ttl-as-a-configurable-parameter-with-agent_config) ## Installation @@ -164,6 +170,67 @@ the application's auth system. Memories and messages are scoped to the combinati Neither value is validated by the service — they are free-form strings. Consistent use across create, read, and search calls is the implementer's responsibility. +## Multitenancy + +The Agent Memory service runs in a multi-tenant BTP environment. By default, every API +call uses a **subscriber-scoped token** — meaning data is isolated to the subscriber tenant +that your application serves. You control this behaviour with the `access_strategy` and +`tenant` keyword arguments available on every client method. + +### AccessStrategy + +```python +from sap_cloud_sdk.agent_memory import AccessStrategy +``` + +| Value | Description | +| --------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `SUBSCRIBER` (default) | Reads and writes against the subscriber tenant. Requires `tenant`. | +| `PROVIDER` | Reads and writes against the provider tenant. No `tenant` needed. Caution: this provides no tenant isolation. | + +### Configuring at client level + +Pass `access_strategy` and `tenant` to `create_client()` to set defaults for the entire +client instance. Every method call then inherits them, so you do not need to repeat them +on each operation. + +```python +from sap_cloud_sdk.agent_memory import create_client, AccessStrategy + +# Tenant set once — all calls below use it automatically +client = create_client( + access_strategy=AccessStrategy.SUBSCRIBER, + tenant="acme-corp", +) + +memories = client.list_memories(agent_id="hr-assistant", invoker_id="user-42") +count = client.count_memories(agent_id="hr-assistant") +``` + +### SUBSCRIBER (default) + +Configure a subscriber tenant at client creation. All calls will use that tenant context. + +```python +client = create_client( + access_strategy=AccessStrategy.SUBSCRIBER, + tenant="acme-corp", +) +memories = client.list_memories(agent_id="hr-assistant", invoker_id="user-42") +``` + +### PROVIDER + +Configure a provider-only client. No tenant is needed; all calls use the provider binding. + +```python +client = create_client(access_strategy=AccessStrategy.PROVIDER) +memories = client.list_memories(agent_id="hr-assistant", invoker_id="user-42") +``` + +> [!WARNING] +> `PROVIDER` provides **no tenant isolation** — the provider token grants access to data across all subscriber tenants Only use this strategy for provider-owned operations (e.g., admin tasks, shared datasets). Never use it to serve subscriber-specific data. + ## Semantic Search: A Brief Primer Texts with different words — or even different languages — can have the same meaning. @@ -501,9 +568,10 @@ See the [Content and metadata filtering](#content-and-metadata-filtering) note u ### Enums -| Enum | Values | -| ------------- | ------------------------------------- | -| `MessageRole` | `USER`, `ASSISTANT`, `SYSTEM`, `TOOL` | +| Enum | Values | +| ---------------- | -------------------------------------------- | +| `MessageRole` | `USER`, `ASSISTANT`, `SYSTEM`, `TOOL` | +| `AccessStrategy` | `SUBSCRIBER` (default), `PROVIDER` | All models expose a `to_dict()` method that returns a plain dict for logging or forwarding. diff --git a/tests/agent_memory/integration/agentmemory.feature b/tests/agent_memory/integration/agentmemory.feature index e6804f2d..7321c5de 100644 --- a/tests/agent_memory/integration/agentmemory.feature +++ b/tests/agent_memory/integration/agentmemory.feature @@ -89,3 +89,93 @@ Feature: Agent Memory Service Integration (v1 API) Given a message exists with agent "test-agent" invoker "test-user" group "conv-filter" role "USER" content "filter-test-message" and metadata "filter-marker" When I list messages filtered by metadata containing "filter-marker" Then the result should contain the message with content "filter-test-message" + + # ── Subscriber access ──────────────────────────────────────────────────────── + + # ──── Memory CRUD ───────────────────────────────────────────────────────────── + + Scenario: Create a new memory using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + When I create a memory with agent "test-agent" and invoker "test-user" and content "User prefers dark mode" + Then the memory should have a non-empty id + And the memory should have agent_id "test-agent" + And the memory should have invoker_id "test-user" + And the memory should have content "User prefers dark mode" + + Scenario: Get a memory using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "Test memory" + When I get the memory by id + Then the returned memory should match the created memory + + Scenario: Update memory content using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "Original content" + When I update the memory content to "Updated content" + Then the memory should have content "Updated content" + + Scenario: List memories using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "Listed memory" + When I list memories filtered by agent "test-agent" + Then the result should contain at least one memory + And the total count should be a positive number + + Scenario: Delete a memory using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "To be deleted" + When I delete the memory + Then the memory should no longer exist + + # ──── Memory search ─────────────────────────────────────────────────────────── + + Scenario: Search memories using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "The user loves dark mode and dark themes" + When I search for memories with query "dark mode preference" + Then the search result should contain at least one result + And each result should have a non-empty content + + # ──── Message CRUD ──────────────────────────────────────────────────────────── + + Scenario: Create and get a message using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + When I create a message with agent "test-agent" invoker "test-user" group "conv-1" role "USER" content "Hello!" + Then the message should have a non-empty id + And the message should have role "USER" + And the message should have content "Hello!" + + Scenario: List messages using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + And a message exists with agent "test-agent" invoker "test-user" group "conv-list" role "USER" content "Listed message" + When I list messages filtered by agent "test-agent" and group "conv-list" + Then the result should contain at least one message + And the total count should be a positive number + + Scenario: Delete a message using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + And a message exists with agent "test-agent" invoker "test-user" group "conv-del" role "USER" content "To be deleted" + When I delete the message + Then the message should no longer exist + + # ──── Bulk / utility operations ─────────────────────────────────────────────── + + Scenario: Count memories using SUBSCRIBER access strategy + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "Count test memory" + When I count memories for agent "test-agent" and invoker "test-user" + Then the memory count should be a positive number + + # ──── Filter ────────────────────────────────────────────────────────────────── + + Scenario: Filter subscriber memories by content substring + Given I use the configured subscriber tenant + And a memory exists with agent "test-agent" and invoker "test-user" and content "The user prefers dark mode" + When I list memories filtered by content containing "dark mode" + Then the result should contain the memory with content "The user prefers dark mode" + + Scenario: Filter subscriber messages by metadata substring + Given I use the configured subscriber tenant + And a message exists with agent "test-agent" invoker "test-user" group "conv-filter" role "USER" content "filter-test-message" and metadata "filter-marker" + When I list messages filtered by metadata containing "filter-marker" + Then the result should contain the message with content "filter-test-message" diff --git a/tests/agent_memory/integration/conftest.py b/tests/agent_memory/integration/conftest.py index 9b26d5c0..5bc95b69 100644 --- a/tests/agent_memory/integration/conftest.py +++ b/tests/agent_memory/integration/conftest.py @@ -2,29 +2,85 @@ Set the following environment variables before running integration tests: - CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_URL Base URL of the Agent Memory service - CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_AUTH_URL OAuth2 authorization server base URL - CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTID OAuth2 client ID - CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTSECRET OAuth2 client secret +Provider (default) binding: + + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA + +Subscriber binding (one set per tenant, keyed by subdomain in upper-snake-case): + + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__UAA + + e.g. for tenant "acme-corp": + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA + +Subscriber tenant name: + + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT Subscriber tenant subdomain + Required for SUBSCRIBER tests. When absent those tests are skipped. """ +import os from pathlib import Path import pytest from dotenv import load_dotenv -from sap_cloud_sdk.agent_memory import create_client +from sap_cloud_sdk.agent_memory import AccessStrategy, create_client from sap_cloud_sdk.agent_memory.client import AgentMemoryClient +from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError @pytest.fixture(scope="session") def agent_memory_client() -> AgentMemoryClient: - """Create a real AgentMemoryClient from environment variables.""" + """Create a real AgentMemoryClient from environment variables. + + Uses PROVIDER as the default strategy — individual BDD steps override + this per-call to exercise both PROVIDER and SUBSCRIBER scenarios. + """ env_file = Path(__file__).parents[3] / ".env_integration_tests" if env_file.exists(): load_dotenv(env_file, override=True) try: - return create_client() + return create_client(access_strategy=AccessStrategy.PROVIDER) + except AgentMemoryConfigError as e: + pytest.skip(f"Agent Memory credentials not configured — skipping integration tests: {e}") except Exception as e: pytest.fail(f"Failed to create Agent Memory client for integration tests: {e}") + + +@pytest.fixture(scope="session") +def subscriber_tenant() -> str: + """Return the subscriber tenant subdomain, or skip if not configured. + + On this branch, a separate binding must exist for the tenant subdomain: + /etc/secrets/appfnd/hana-agent-memory// + or environment variables: + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__APPLICATION_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY__UAA + """ + from sap_cloud_sdk.agent_memory.config import _load_config_for_instance + + env_file = Path(__file__).parents[3] / ".env_integration_tests" + if env_file.exists(): + load_dotenv(env_file, override=True) + + tenant = os.environ.get("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT", "") + if not tenant: + pytest.skip( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_SUBSCRIBER_TENANT not set — " + "skipping subscriber tenant tests" + ) + + try: + _load_config_for_instance(tenant) + except AgentMemoryConfigError: + pytest.skip( + f"Subscriber binding for tenant '{tenant}' not configured — " + f"skipping subscriber tenant tests" + ) + + return tenant diff --git a/tests/agent_memory/integration/test_agentmemory_bdd.py b/tests/agent_memory/integration/test_agentmemory_bdd.py index 41460b68..b0caab21 100644 --- a/tests/agent_memory/integration/test_agentmemory_bdd.py +++ b/tests/agent_memory/integration/test_agentmemory_bdd.py @@ -14,13 +14,15 @@ """ import pytest -from pytest_bdd import given, scenario, then, when +from pytest_bdd import given, parsers, scenario, then, when +from sap_cloud_sdk.agent_memory import AccessStrategy, FilterDefinition, MessageRole from sap_cloud_sdk.agent_memory.client import AgentMemoryClient -from sap_cloud_sdk.agent_memory import MessageRole # -- Scenarios ----------------------------------------------------------------- +# ── Provider scenarios ──────────────────────────────────────────────────────── + @scenario("agentmemory.feature", "Create a new memory") def test_add_memory(): @@ -92,12 +94,78 @@ def test_filter_messages_by_metadata(): pass +# ── Subscriber scenarios ────────────────────────────────────────────────────── + + +@scenario("agentmemory.feature", "Create a new memory using SUBSCRIBER access strategy") +def test_add_memory_subscriber(): + pass + + +@scenario("agentmemory.feature", "Get a memory using SUBSCRIBER access strategy") +def test_get_memory_subscriber(): + pass + + +@scenario("agentmemory.feature", "Update memory content using SUBSCRIBER access strategy") +def test_update_memory_subscriber(): + pass + + +@scenario("agentmemory.feature", "List memories using SUBSCRIBER access strategy") +def test_list_memories_subscriber(): + pass + + +@scenario("agentmemory.feature", "Delete a memory using SUBSCRIBER access strategy") +def test_delete_memory_subscriber(): + pass + + +@scenario("agentmemory.feature", "Search memories using SUBSCRIBER access strategy") +def test_search_memories_subscriber(): + pass + + +@scenario("agentmemory.feature", "Create and get a message using SUBSCRIBER access strategy") +def test_add_message_subscriber(): + pass + + +@scenario("agentmemory.feature", "List messages using SUBSCRIBER access strategy") +def test_list_messages_subscriber(): + pass + + +@scenario("agentmemory.feature", "Delete a message using SUBSCRIBER access strategy") +def test_delete_message_subscriber(): + pass + + +@scenario("agentmemory.feature", "Count memories using SUBSCRIBER access strategy") +def test_count_memories_subscriber(): + pass + + +@scenario("agentmemory.feature", "Filter subscriber memories by content substring") +def test_filter_memories_by_content_subscriber(): + pass + + +@scenario("agentmemory.feature", "Filter subscriber messages by metadata substring") +def test_filter_messages_by_metadata_subscriber(): + pass + + # -- Fixtures / state --------------------------------------------------------- @pytest.fixture def context(): - return {} + return { + "access_strategy": AccessStrategy.PROVIDER, + "tenant": None, + } # -- Given steps --------------------------------------------------------------- @@ -108,91 +176,46 @@ def configured_client(context, agent_memory_client): context["client"] = agent_memory_client -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "Test memory"' -) -def memory_exists_test(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "Test memory", - ) +@given("I use the configured subscriber tenant") +def use_configured_subscriber_tenant(context, subscriber_tenant): + context["access_strategy"] = AccessStrategy.SUBSCRIBER + context["tenant"] = subscriber_tenant @given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "Original content"' -) -def memory_exists_original(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "Original content", + parsers.parse( + 'a memory exists with agent "{agent_id}" and invoker "{invoker_id}" and content "{content}"' ) - - -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "Listed memory"' -) -def memory_exists_listed(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "Listed memory", - ) - - -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "To be deleted"' ) -def memory_exists_delete(context, agent_memory_client): +def memory_exists(context, agent_memory_client, agent_id, invoker_id, content): context["client"] = agent_memory_client context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "To be deleted", + agent_id, invoker_id, content, ) @given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "The user loves dark mode and dark themes"' -) -def memory_exists_search(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "The user loves dark mode and dark themes", + parsers.parse( + 'a message exists with agent "{agent_id}" invoker "{invoker_id}" group "{group}" role "{role}" content "{content}"' ) - - -@given( - 'a message exists with agent "test-agent" invoker "test-user" group "conv-list" role "USER" content "Listed message"' ) -def message_exists_list(context, agent_memory_client): +def message_exists(context, agent_memory_client, agent_id, invoker_id, group, role, content): context["client"] = agent_memory_client context["message"] = agent_memory_client.add_message( - "test-agent", - "test-user", - "conv-list", - "USER", - "Listed message", + agent_id, invoker_id, group, role, content, ) @given( - 'a message exists with agent "test-agent" invoker "test-user" group "conv-del" role "USER" content "To be deleted"' + parsers.parse( + 'a message exists with agent "{agent_id}" invoker "{invoker_id}" group "{group}" role "{role}" content "{content}" and metadata "{metadata_value}"' + ) ) -def message_exists_delete(context, agent_memory_client): +def message_exists_with_metadata(context, agent_memory_client, agent_id, invoker_id, group, role, content, metadata_value): context["client"] = agent_memory_client context["message"] = agent_memory_client.add_message( - "test-agent", - "test-user", - "conv-del", - "USER", - "To be deleted", + agent_id, invoker_id, group, role, content, + metadata={"tag": metadata_value}, ) @@ -200,76 +223,90 @@ def message_exists_delete(context, agent_memory_client): @when( - 'I create a memory with agent "test-agent" and invoker "test-user" and content "User prefers dark mode"' + parsers.parse( + 'I create a memory with agent "{agent_id}" and invoker "{invoker_id}" and content "{content}"' + ) ) -def add_memory(context): +def add_memory(context, agent_id, invoker_id, content): client: AgentMemoryClient = context["client"] context["memory"] = client.add_memory( - "test-agent", - "test-user", - "User prefers dark mode", + agent_id, invoker_id, content, ) @when("I get the memory by id") def get_memory(context): client: AgentMemoryClient = context["client"] - context["fetched_memory"] = client.get_memory(context["memory"].id) + context["fetched_memory"] = client.get_memory( + context["memory"].id, + ) -@when('I update the memory content to "Updated content"') -def update_memory(context): +@when(parsers.parse('I update the memory content to "{content}"')) +def update_memory(context, content): client: AgentMemoryClient = context["client"] - client.update_memory(context["memory"].id, content="Updated content") - context["memory"] = client.get_memory(context["memory"].id) + client.update_memory( + context["memory"].id, content=content, + ) + context["memory"] = client.get_memory( + context["memory"].id, + ) -@when('I list memories filtered by agent "test-agent"') -def list_memories(context): +@when(parsers.parse('I list memories filtered by agent "{agent_id}"')) +def list_memories(context, agent_id): client: AgentMemoryClient = context["client"] - context["memories"] = client.list_memories(agent_id="test-agent") - context["total"] = client.count_memories(agent_id="test-agent") + context["memories"] = client.list_memories( + agent_id=agent_id, + ) + context["total"] = client.count_memories( + agent_id=agent_id, + ) @when("I delete the memory") def delete_memory(context): client: AgentMemoryClient = context["client"] - client.delete_memory(context["memory"].id) + client.delete_memory( + context["memory"].id, + ) context["deleted_memory_id"] = context["memory"].id -@when('I search for memories with query "dark mode preference"') -def search_memories(context): +@when(parsers.parse('I search for memories with query "{query}"')) +def search_memories(context, query): client: AgentMemoryClient = context["client"] context["search_results"] = client.search_memories( agent_id="test-agent", invoker_id="test-user", - query="dark mode preference", + query=query, threshold=0.5, limit=10, ) @when( - 'I create a message with agent "test-agent" invoker "test-user" group "conv-1" role "USER" content "Hello!"' + parsers.parse( + 'I create a message with agent "{agent_id}" invoker "{invoker_id}" group "{group}" role "{role}" content "{content}"' + ) ) -def add_message(context): +def add_message(context, agent_id, invoker_id, group, role, content): client: AgentMemoryClient = context["client"] context["message"] = client.add_message( - "test-agent", - "test-user", - "conv-1", - MessageRole.USER, - "Hello!", + agent_id, invoker_id, group, MessageRole(role), content, ) -@when('I list messages filtered by agent "test-agent" and group "conv-list"') -def list_messages(context): +@when( + parsers.parse( + 'I list messages filtered by agent "{agent_id}" and group "{group}"' + ) +) +def list_messages(context, agent_id, group): client: AgentMemoryClient = context["client"] context["messages"] = client.list_messages( - agent_id="test-agent", - message_group="conv-list", + agent_id=agent_id, + message_group=group, ) context["total"] = len(context["messages"]) @@ -277,10 +314,63 @@ def list_messages(context): @when("I delete the message") def delete_message(context): client: AgentMemoryClient = context["client"] - client.delete_message(context["message"].id) + client.delete_message( + context["message"].id, + ) context["deleted_message_id"] = context["message"].id +@when("I get the retention config") +def get_retention_config(context): + client: AgentMemoryClient = context["client"] + context["retention_config"] = client.get_retention_config( + ) + + +@when("I update the retention config with message_days 30 and memory_days 90") +def update_retention_config(context): + client: AgentMemoryClient = context["client"] + client.update_retention_config( + message_days=30, memory_days=90, + ) + context["retention_config"] = client.get_retention_config( + ) + + +@when( + parsers.parse( + 'I count memories for agent "{agent_id}" and invoker "{invoker_id}"' + ) +) +def count_memories(context, agent_id, invoker_id): + client: AgentMemoryClient = context["client"] + context["memory_count"] = client.count_memories( + agent_id=agent_id, + invoker_id=invoker_id, + ) + + +@when(parsers.parse('I list memories filtered by content containing "{substring}"')) +def list_memories_by_content(context, substring): + client: AgentMemoryClient = context["client"] + context["memories"] = client.list_memories( + agent_id="test-agent", + invoker_id="test-user", + filters=[FilterDefinition(target="content", contains=substring)], + ) + + +@when(parsers.parse('I list messages filtered by metadata containing "{substring}"')) +def list_messages_by_metadata(context, substring): + client: AgentMemoryClient = context["client"] + context["messages"] = client.list_messages( + agent_id="test-agent", + invoker_id="test-user", + message_group="conv-filter", + filters=[FilterDefinition(target="metadata", contains=substring)], + ) + + # -- Then steps ---------------------------------------------------------------- @@ -289,24 +379,19 @@ def check_memory_id(context): assert context["memory"].id != "" -@then('the memory should have agent_id "test-agent"') -def check_memory_agent_id(context): - assert context["memory"].agent_id == "test-agent" +@then(parsers.parse('the memory should have agent_id "{agent_id}"')) +def check_memory_agent_id(context, agent_id): + assert context["memory"].agent_id == agent_id -@then('the memory should have invoker_id "test-user"') -def check_memory_invoker_id(context): - assert context["memory"].invoker_id == "test-user" +@then(parsers.parse('the memory should have invoker_id "{invoker_id}"')) +def check_memory_invoker_id(context, invoker_id): + assert context["memory"].invoker_id == invoker_id -@then('the memory should have content "User prefers dark mode"') -def check_memory_content_dark(context): - assert context["memory"].content == "User prefers dark mode" - - -@then('the memory should have content "Updated content"') -def check_memory_content_updated(context): - assert context["memory"].content == "Updated content" +@then(parsers.parse('the memory should have content "{content}"')) +def check_memory_content(context, content): + assert context["memory"].content == content @then("the returned memory should match the created memory") @@ -332,7 +417,9 @@ def check_memory_deleted(context): client: AgentMemoryClient = context["client"] with pytest.raises(AgentMemoryNotFoundError): - client.get_memory(context["deleted_memory_id"]) + client.get_memory( + context["deleted_memory_id"], + ) @then("the search result should contain at least one result") @@ -356,9 +443,9 @@ def check_message_role(context): assert context["message"].role == "USER" -@then('the message should have content "Hello!"') -def check_message_content(context): - assert context["message"].content == "Hello!" +@then(parsers.parse('the message should have content "{content}"')) +def check_message_content(context, content): + assert context["message"].content == content @then("the result should contain at least one message") @@ -372,23 +459,9 @@ def check_message_deleted(context): client: AgentMemoryClient = context["client"] with pytest.raises(AgentMemoryNotFoundError): - client.get_message(context["deleted_message_id"]) - - -# -- Admin: Retention Config steps --------------------------------------------- - - -@when("I get the retention config") -def get_retention_config(context): - client: AgentMemoryClient = context["client"] - context["retention_config"] = client.get_retention_config() - - -@when("I update the retention config with message_days 30 and memory_days 90") -def update_retention_config(context): - client: AgentMemoryClient = context["client"] - client.update_retention_config(message_days=30, memory_days=90) - context["retention_config"] = client.get_retention_config() + client.get_message( + context["deleted_message_id"], + ) @then("the retention config should have a non-empty id") @@ -406,97 +479,18 @@ def check_retention_memory_days(context): assert context["retention_config"].memory_days == 90 -# -- Bulk / utility steps ------------------------------------------------------- - - -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "Count test memory"' -) -def memory_exists_count(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "Count test memory", - ) - - -@when('I count memories for agent "test-agent" and invoker "test-user"') -def count_memories(context): - client: AgentMemoryClient = context["client"] - context["memory_count"] = client.count_memories( - agent_id="test-agent", - invoker_id="test-user", - ) - - @then("the memory count should be a positive number") def check_memory_count_positive(context): assert context["memory_count"] >= 1 -# -- Filter steps --------------------------------------------------------------- - - -@given( - 'a memory exists with agent "test-agent" and invoker "test-user" and content "The user prefers dark mode"' -) -def memory_exists_dark_mode(context, agent_memory_client): - context["client"] = agent_memory_client - context["memory"] = agent_memory_client.add_memory( - "test-agent", - "test-user", - "The user prefers dark mode", - ) - - -@given( - 'a message exists with agent "test-agent" invoker "test-user" group "conv-filter" role "USER" content "filter-test-message" and metadata "filter-marker"' -) -def message_exists_filter(context, agent_memory_client): - context["client"] = agent_memory_client - context["message"] = agent_memory_client.add_message( - "test-agent", - "test-user", - "conv-filter", - "USER", - "filter-test-message", - metadata={"tag": "filter-marker"}, - ) - - -@when('I list memories filtered by content containing "dark mode"') -def list_memories_by_content(context): - from sap_cloud_sdk.agent_memory import FilterDefinition - - client: AgentMemoryClient = context["client"] - context["memories"] = client.list_memories( - agent_id="test-agent", - invoker_id="test-user", - filters=[FilterDefinition(target="content", contains="dark mode")], - ) - - -@when('I list messages filtered by metadata containing "filter-marker"') -def list_messages_by_metadata(context): - from sap_cloud_sdk.agent_memory import FilterDefinition - - client: AgentMemoryClient = context["client"] - context["messages"] = client.list_messages( - agent_id="test-agent", - invoker_id="test-user", - message_group="conv-filter", - filters=[FilterDefinition(target="metadata", contains="filter-marker")], - ) - - -@then('the result should contain the memory with content "The user prefers dark mode"') -def check_memory_content_in_results(context): +@then(parsers.parse('the result should contain the memory with content "{content}"')) +def check_memory_content_in_results(context, content): contents = [m.content for m in context["memories"]] - assert "The user prefers dark mode" in contents + assert content in contents -@then('the result should contain the message with content "filter-test-message"') -def check_message_content_in_results(context): +@then(parsers.parse('the result should contain the message with content "{content}"')) +def check_message_content_in_results(context, content): contents = [m.content for m in context["messages"]] - assert "filter-test-message" in contents + assert content in contents diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index b3dbbdd3..959ed315 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -11,6 +11,7 @@ ) from sap_cloud_sdk.agent_memory._http_transport import HttpTransport from sap_cloud_sdk.agent_memory._models import ( + AccessStrategy, Memory, Message, MessageRole, @@ -20,13 +21,29 @@ from sap_cloud_sdk.agent_memory.client import AgentMemoryClient from sap_cloud_sdk.agent_memory import create_client, FilterDefinition from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig -from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryValidationError +from sap_cloud_sdk.agent_memory.exceptions import ( + AgentMemoryConfigError, + AgentMemoryValidationError, +) def _make_client() -> tuple[AgentMemoryClient, MagicMock]: - """Return an AgentMemoryClient with a mocked transport layer.""" + """Return an AgentMemoryClient with a mocked provider transport.""" + transport = MagicMock(spec=HttpTransport) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) + return client, transport + + +def _make_subscriber_client( + tenant: str = "default-sub", +) -> tuple[AgentMemoryClient, MagicMock]: + """Return a client with SUBSCRIBER default and a pre-warmed mock transport.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport) + client = AgentMemoryClient( + transport, + access_strategy=AccessStrategy.SUBSCRIBER, + tenant=tenant, + ) return client, transport @@ -36,26 +53,94 @@ def _make_client() -> tuple[AgentMemoryClient, MagicMock]: class TestCreateClient: def test_uses_provided_config(self): - """Factory accepts an explicit config object.""" + """Factory with explicit config creates a client successfully.""" config = AgentMemoryConfig(base_url="http://localhost:3000") with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) - client = create_client(config=config) + client = create_client(config=config, access_strategy=AccessStrategy.PROVIDER) + assert isinstance(client, AgentMemoryClient) + assert client._transport is not None + + def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): + """Factory with SUBSCRIBER loads the tenant binding.""" + import json + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL", + "http://acme.memory.example.com", + ) + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA", + json.dumps({"url": "http://acme.auth.example.com", "clientid": "c", "clientsecret": "s"}), + ) + with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: + MockTransport.return_value = MagicMock(spec=HttpTransport) + client = create_client( + access_strategy=AccessStrategy.SUBSCRIBER, + tenant="acme-corp", + ) assert isinstance(client, AgentMemoryClient) + assert client._transport is not None - def test_reads_env_when_no_config_provided(self, monkeypatch): - """Factory falls back to environment variables when no config given.""" + def test_provider_strategy_loads_default_binding(self, monkeypatch): + """Factory with PROVIDER loads the default binding.""" import json - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", "http://memory.example.com") - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", json.dumps({ - "url": "http://auth.example.com", - "clientid": "client-id", - "clientsecret": "client-secret", - })) + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", + "http://memory.example.com", + ) + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", + json.dumps({"url": "http://auth.example.com", "clientid": "c", "clientsecret": "s"}), + ) with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) - client = create_client() + client = create_client(access_strategy=AccessStrategy.PROVIDER) assert isinstance(client, AgentMemoryClient) + assert client._transport is not None + + +# ── Access strategy and per-tenant transport routing ───────────────────────── + + +class TestAccessStrategy: + + # ── Construction-time validation ─────────────────────────────────────────── + + def test_subscriber_without_tenant_raises_at_construction(self): + """SUBSCRIBER without tenant raises AgentMemoryValidationError at construction.""" + transport = MagicMock(spec=HttpTransport) + with pytest.raises(AgentMemoryValidationError, match="tenant"): + AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER) + + def test_subscriber_with_tenant_constructs_successfully(self): + """SUBSCRIBER with tenant constructs without error.""" + client, _ = _make_subscriber_client("acme") + assert client._transport is not None + + def test_provider_constructs_without_tenant(self): + """PROVIDER constructs without tenant.""" + client, _ = _make_client() + assert client._transport is not None + + # ── Transport routing ────────────────────────────────────────────────────── + + def test_client_default_subscriber_uses_subscriber_transport(self): + """Client with SUBSCRIBER default uses the provided transport.""" + client, sub_transport = _make_subscriber_client("acme") + sub_transport.post.return_value = { + "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", + } + client.add_memory("a", "u", "x") + sub_transport.post.assert_called_once() + + def test_provider_only_uses_provider_transport(self): + """PROVIDER uses the provided transport.""" + client, provider_transport = _make_client() + provider_transport.post.return_value = { + "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", + } + client.add_memory("a", "u", "x") + provider_transport.post.assert_called_once() # ── Memory CRUD operations ──────────────────────────────────────────────────── @@ -749,7 +834,7 @@ def test_close_delegates_to_transport(self): def test_context_manager_closes_on_exit(self): """Using the client as a context manager closes it on __exit__.""" transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport) + client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) with client: pass diff --git a/tests/agent_memory/unit/test_config.py b/tests/agent_memory/unit/test_config.py index d3e895a1..ce297514 100644 --- a/tests/agent_memory/unit/test_config.py +++ b/tests/agent_memory/unit/test_config.py @@ -1,4 +1,4 @@ -"""Unit tests for AgentMemoryConfig, BindingData, and _load_config_from_env.""" +"""Unit tests for AgentMemoryConfig, BindingData, _load_config_from_env, and _load_config_for_instance.""" import json from unittest.mock import patch @@ -8,6 +8,7 @@ from sap_cloud_sdk.agent_memory.config import ( AgentMemoryConfig, BindingData, + _load_config_for_instance, _load_config_from_env, ) from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError @@ -51,6 +52,14 @@ def test_timeout_default(self): config = AgentMemoryConfig(base_url="http://localhost:8080") assert config.timeout == 30.0 + def test_raises_when_identityzone_empty_string(self): + with pytest.raises(AgentMemoryConfigError, match="identityzone"): + AgentMemoryConfig(base_url="http://localhost", identityzone="") + + def test_identityzone_defaults_to_none(self): + config = AgentMemoryConfig(base_url="http://localhost:8080") + assert config.identityzone is None + def test_valid_config_with_all_fields_does_not_raise(self): AgentMemoryConfig( base_url="https://memory.example.com", @@ -102,6 +111,20 @@ def test_extract_config_raises_on_missing_json_key(self): with pytest.raises(AgentMemoryConfigError, match="Missing required field in uaa JSON"): BindingData(application_url="https://memory.example.com", uaa=uaa).extract_config() + def test_extract_config_maps_identityzone_when_present(self): + uaa = json.dumps({ + "url": "https://my-zone.authentication.eu12.hana.ondemand.com", + "clientid": "c", + "clientsecret": "s", + "identityzone": "my-zone", + }) + config = BindingData(application_url="https://memory.example.com", uaa=uaa).extract_config() + assert config.identityzone == "my-zone" + + def test_extract_config_identityzone_is_none_when_absent(self): + config = BindingData(application_url="https://memory.example.com", uaa=_VALID_UAA).extract_config() + assert config.identityzone is None + def test_extract_config_ignores_extra_uaa_fields(self): uaa = json.dumps({ "apiurl": "https://api.authentication.eu12.hana.ondemand.com", @@ -119,6 +142,7 @@ def test_extract_config_ignores_extra_uaa_fields(self): assert config.token_url == "https://auth.example.com/oauth/token" assert config.client_id == "my-client" assert config.client_secret == "my-secret" + assert config.identityzone == "my-zone" def test_extract_config_raises_on_empty_uaa_object(self): with pytest.raises(AgentMemoryConfigError, match="Missing required field in uaa JSON"): @@ -187,3 +211,77 @@ def test_raises_config_error_when_uaa_json_invalid(self, monkeypatch): with patch("os.stat", side_effect=FileNotFoundError("no mount")): with pytest.raises(AgentMemoryConfigError, match="Failed to parse uaa JSON"): _load_config_from_env() + + +# ── _load_config_for_instance ───────────────────────────────────────────────── + + +def _fill_binding_for_instance(instance_name: str): + """Return a side_effect that fills binding only when instance matches.""" + def _fill(**kwargs): + assert kwargs["instance"] == instance_name + kwargs["target"].application_url = f"https://{instance_name}.memory.example.com" + kwargs["target"].uaa = json.dumps({ + "url": f"https://{instance_name}.auth.example.com", + "clientid": f"{instance_name}-client", + "clientsecret": "secret", + }) + return _fill + + +class TestLoadConfigForInstance: + + def test_loads_named_instance_binding(self): + """Loads config from the specified instance name (not 'default').""" + with patch(_RESOLVER, side_effect=_fill_binding_for_instance("acme-corp")): + config = _load_config_for_instance("acme-corp") + + assert config.base_url == "https://acme-corp.memory.example.com" + assert config.token_url == "https://acme-corp.auth.example.com/oauth/token" + assert config.client_id == "acme-corp-client" + + def test_calls_resolver_with_correct_instance(self): + """Resolver receives the exact instance name passed (not 'default').""" + with patch(_RESOLVER, side_effect=_fill_binding_for_instance("beta-tenant")) as mock_resolver: + _load_config_for_instance("beta-tenant") + + _, kwargs = mock_resolver.call_args + assert kwargs["module"] == "hana-agent-memory" + assert kwargs["instance"] == "beta-tenant" + + def test_default_instance_is_equivalent_to_load_config_from_env(self): + """_load_config_for_instance('default') produces the same result as _load_config_from_env.""" + with patch(_RESOLVER, side_effect=_fill_binding_for_instance("default")): + config_instance = _load_config_for_instance("default") + with patch(_RESOLVER, side_effect=_fill_binding_for_instance("default")): + config_env = _load_config_from_env() + + assert config_instance.base_url == config_env.base_url + assert config_instance.token_url == config_env.token_url + + def test_raises_with_instance_name_in_message_when_binding_missing(self): + """Error message includes the instance name when the binding cannot be loaded.""" + with patch(_RESOLVER, side_effect=RuntimeError("secrets not found")): + with pytest.raises(AgentMemoryConfigError, match="acme-corp"): + _load_config_for_instance("acme-corp") + + def test_loads_from_env_vars_for_named_instance(self, monkeypatch): + """Subscriber binding loaded from env vars keyed by tenant name.""" + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_APPLICATION_URL", + "https://acme-corp.memory.example.com", + ) + monkeypatch.setenv( + "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA", + json.dumps({ + "url": "https://acme-corp.auth.example.com", + "clientid": "acme-client", + "clientsecret": "secret", + }), + ) + + with patch("os.stat", side_effect=FileNotFoundError("no mount")): + config = _load_config_for_instance("acme-corp") + + assert config.base_url == "https://acme-corp.memory.example.com" + assert config.client_id == "acme-client" diff --git a/tests/agent_memory/unit/test_http_transport.py b/tests/agent_memory/unit/test_http_transport.py index 282abe37..74008c44 100644 --- a/tests/agent_memory/unit/test_http_transport.py +++ b/tests/agent_memory/unit/test_http_transport.py @@ -13,13 +13,18 @@ from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryHttpError, AgentMemoryNotFoundError -def _config(with_auth: bool = True) -> AgentMemoryConfig: +def _config( + with_auth: bool = True, + identityzone: str | None = None, + token_url: str = "http://auth.example.com/oauth/token", +) -> AgentMemoryConfig: if with_auth: return AgentMemoryConfig( base_url="http://localhost:8080", - token_url="http://localhost:8080/oauth/token", + token_url=token_url, client_id="client-id", client_secret="client-secret", + identityzone=identityzone, ) return AgentMemoryConfig(base_url="http://localhost:8080") @@ -74,7 +79,7 @@ def test_uses_plain_session_when_no_token_url(self): class TestTokenAcquisition: def test_token_is_fetched_and_cached(self): - """fetch_token is called only once across multiple requests.""" + """fetch_token is called only once across multiple requests with the same tenant.""" with patch( "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" ) as MockOAuth, patch( @@ -110,11 +115,11 @@ def test_expired_token_triggers_refetch(self): mock_oauth.request.return_value = _mock_response(200, {}) transport = HttpTransport(_config()) - transport._token_expires_at = datetime.now() - timedelta(seconds=1) - transport.get("/test") + # Force the cache to have an expired entry + past = datetime.now() - timedelta(seconds=1) + transport._oauth_cache[None] = (mock_oauth, past) transport.get("/test") - # Two fetches: one for each request since we started with an expired timestamp assert mock_oauth.fetch_token.call_count >= 1 def test_token_expiry_uses_buffer(self): @@ -135,11 +140,11 @@ def test_token_expiry_uses_buffer(self): transport = HttpTransport(_config()) transport.get("/test") + _, expires_at = transport._oauth_cache[None] expected_max = datetime.now() + timedelta( seconds=3600 - _TOKEN_EXPIRY_BUFFER_SECONDS + 5 ) - assert transport._token_expires_at is not None - assert transport._token_expires_at < expected_max + assert expires_at < expected_max def test_token_fetch_failure_raises_http_error(self): """Failed token fetch raises AgentMemoryHttpError.""" @@ -157,6 +162,110 @@ def test_token_fetch_failure_raises_http_error(self): transport.get("/test") +# ── Per-tenant token derivation ─────────────────────────────────────────────── + + +class TestTenantTokenDerivation: + + def test_subscriber_token_url_replaces_identityzone(self): + """When tenant_subdomain is provided, identityzone is replaced in the token URL.""" + token_url = "http://provider-zone.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="provider-zone", token_url=token_url) + + captured_urls = [] + + def fake_fetch_token(**kwargs): + captured_urls.append(kwargs["token_url"]) + return {"access_token": "tok", "expires_in": 3600} + + with patch( + "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" + ) as MockOAuth, patch( + "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" + ): + mock_oauth = MagicMock() + MockOAuth.return_value = mock_oauth + mock_oauth.fetch_token.side_effect = fake_fetch_token + mock_oauth.request.return_value = _mock_response(200, {}) + + transport = HttpTransport(cfg) + transport.get("/test", tenant_subdomain="subscriber-zone") + + assert len(captured_urls) == 1 + assert "subscriber-zone" in captured_urls[0] + assert "provider-zone" not in captured_urls[0] + + def test_provider_token_url_unchanged_when_no_tenant(self): + """Without tenant_subdomain, the provider token URL is used as-is.""" + token_url = "http://provider-zone.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="provider-zone", token_url=token_url) + + captured_urls = [] + + def fake_fetch_token(**kwargs): + captured_urls.append(kwargs["token_url"]) + return {"access_token": "tok", "expires_in": 3600} + + with patch( + "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" + ) as MockOAuth, patch( + "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" + ): + mock_oauth = MagicMock() + MockOAuth.return_value = mock_oauth + mock_oauth.fetch_token.side_effect = fake_fetch_token + mock_oauth.request.return_value = _mock_response(200, {}) + + transport = HttpTransport(cfg) + transport.get("/test") # no tenant_subdomain → None + + assert captured_urls[0] == token_url + + def test_tokens_cached_independently_per_tenant(self): + """Provider and subscriber tokens are cached under separate keys.""" + token_url = "http://prov.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="prov", token_url=token_url) + + with patch( + "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" + ) as MockOAuth, patch( + "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" + ): + mock_oauth = MagicMock() + MockOAuth.return_value = mock_oauth + mock_oauth.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} + mock_oauth.request.return_value = _mock_response(200, {}) + + transport = HttpTransport(cfg) + transport.get("/test") # provider (None) + transport.get("/test", tenant_subdomain="sub") # subscriber + + assert None in transport._oauth_cache + assert "sub" in transport._oauth_cache + assert mock_oauth.fetch_token.call_count == 2 + + def test_subscriber_token_reused_on_second_call(self): + """Subscriber token is cached and not re-fetched on a second call.""" + token_url = "http://prov.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="prov", token_url=token_url) + + with patch( + "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" + ) as MockOAuth, patch( + "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" + ): + mock_oauth = MagicMock() + MockOAuth.return_value = mock_oauth + mock_oauth.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} + mock_oauth.request.return_value = _mock_response(200, {}) + + transport = HttpTransport(cfg) + transport.get("/test", tenant_subdomain="sub") + transport.get("/test", tenant_subdomain="sub") + + assert mock_oauth.fetch_token.call_count == 1 + + # ── HTTP methods ────────────────────────────────────────────────────────────── @@ -242,8 +351,8 @@ def test_server_error_raises_http_error(self): class TestClose: - def test_close_clears_oauth_session(self): - """close() clears the OAuth session.""" + def test_close_clears_all_oauth_sessions(self): + """close() closes all cached OAuth sessions.""" with patch( "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" ) as MockOAuth, patch( @@ -254,12 +363,15 @@ def test_close_clears_oauth_session(self): mock_oauth.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} mock_oauth.request.return_value = _mock_response(200, {}) - transport = HttpTransport(_config()) - transport.get("/test") + token_url = "http://prov.auth.example.com/oauth/token" + cfg = _config(with_auth=True, identityzone="prov", token_url=token_url) + transport = HttpTransport(cfg) + transport.get("/test") # provider + transport.get("/test", tenant_subdomain="sub") # subscriber transport.close() - mock_oauth.close.assert_called_once() - assert transport._oauth is None + assert mock_oauth.close.call_count == 2 + assert len(transport._oauth_cache) == 0 def test_close_clears_plain_session(self): """close() clears the plain session in no-auth mode.""" diff --git a/uv.lock b/uv.lock index 92e88dcb..18fcbff8 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -597,8 +597,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -3685,7 +3685,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.36.0" source = { editable = "." } dependencies = [ { name = "grpcio" },