From d2c57506740ddac557dbddd7fb532c2bc3e77f70 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Tue, 5 May 2026 21:17:50 -0500 Subject: [PATCH] feat: Implement delete graph functionality with immediate and period-end options --- .../api/graph_operations/op_delete_graph.py | 104 ++++--- .../cancel_repository_subscription.py | 257 ++++++++++++++++++ robosystems_client/clients/graph_client.py | 76 +++++- robosystems_client/models/delete_graph_op.py | 28 +- tests/test_graph_client.py | 60 ++++ 5 files changed, 478 insertions(+), 47 deletions(-) create mode 100644 robosystems_client/api/subscriptions/cancel_repository_subscription.py diff --git a/robosystems_client/api/graph_operations/op_delete_graph.py b/robosystems_client/api/graph_operations/op_delete_graph.py index de586c1..5581e00 100644 --- a/robosystems_client/api/graph_operations/op_delete_graph.py +++ b/robosystems_client/api/graph_operations/op_delete_graph.py @@ -112,11 +112,11 @@ def sync_detailed( ) -> Response[ErrorResponse | HTTPValidationError | OperationEnvelope]: """Delete Graph - Permanently destroys a user graph: cancels its subscription immediately and queues fast-path - deprovisioning (LadybugDB database removed, DynamoDB slot freed, PG records cleaned). Requires - `confirm` to equal the URL `graph_id`. Caller must be admin on the graph. Not allowed on shared - repositories. Deprovisioning typically completes within ~10 minutes; poll the graph status to - verify. + Permanently destroys a user graph and cancels its subscription. Two modes via the `at_period_end` + body flag: omit it (or pass `false`) to tear down immediately (~10 min); pass `true` to keep the + graph usable through the current billing period and tear it down at the period boundary via the + existing suspend → deprovision pipeline. Requires `confirm` to equal the URL `graph_id`. Caller must + be admin on the graph. Not allowed on shared repositories. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -126,10 +126,18 @@ def sync_detailed( idempotency_key (None | str | Unset): body (DeleteGraphOp): Body for the delete-graph operation. - Permanently destroys the graph: cancels its subscription immediately, then - triggers fast-path deprovisioning (LadybugDB database removed, DynamoDB slot - freed, PG records cleaned). Requires `confirm` to equal the URL `graph_id` - as a guard against accidental destructive calls. + Permanently destroys the graph and cancels its subscription. Two modes: + + - **Immediate** (default): subscription canceled now (`ends_at = now`) and + fast-path deprovisioning fires within ~10 minutes. Use when you want + the data gone and the slot freed right away. + - **At period end** (`at_period_end=true`): subscription canceled but + `ends_at = current_period_end` so the graph stays usable through the + paid period. The existing suspend → deprovision sensor pipeline tears + it down after the retention window once the period closes. + + Requires `confirm` to equal the URL `graph_id` as a guard against + accidental destructive calls. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -161,11 +169,11 @@ def sync( ) -> ErrorResponse | HTTPValidationError | OperationEnvelope | None: """Delete Graph - Permanently destroys a user graph: cancels its subscription immediately and queues fast-path - deprovisioning (LadybugDB database removed, DynamoDB slot freed, PG records cleaned). Requires - `confirm` to equal the URL `graph_id`. Caller must be admin on the graph. Not allowed on shared - repositories. Deprovisioning typically completes within ~10 minutes; poll the graph status to - verify. + Permanently destroys a user graph and cancels its subscription. Two modes via the `at_period_end` + body flag: omit it (or pass `false`) to tear down immediately (~10 min); pass `true` to keep the + graph usable through the current billing period and tear it down at the period boundary via the + existing suspend → deprovision pipeline. Requires `confirm` to equal the URL `graph_id`. Caller must + be admin on the graph. Not allowed on shared repositories. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -175,10 +183,18 @@ def sync( idempotency_key (None | str | Unset): body (DeleteGraphOp): Body for the delete-graph operation. - Permanently destroys the graph: cancels its subscription immediately, then - triggers fast-path deprovisioning (LadybugDB database removed, DynamoDB slot - freed, PG records cleaned). Requires `confirm` to equal the URL `graph_id` - as a guard against accidental destructive calls. + Permanently destroys the graph and cancels its subscription. Two modes: + + - **Immediate** (default): subscription canceled now (`ends_at = now`) and + fast-path deprovisioning fires within ~10 minutes. Use when you want + the data gone and the slot freed right away. + - **At period end** (`at_period_end=true`): subscription canceled but + `ends_at = current_period_end` so the graph stays usable through the + paid period. The existing suspend → deprovision sensor pipeline tears + it down after the retention window once the period closes. + + Requires `confirm` to equal the URL `graph_id` as a guard against + accidental destructive calls. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -205,11 +221,11 @@ async def asyncio_detailed( ) -> Response[ErrorResponse | HTTPValidationError | OperationEnvelope]: """Delete Graph - Permanently destroys a user graph: cancels its subscription immediately and queues fast-path - deprovisioning (LadybugDB database removed, DynamoDB slot freed, PG records cleaned). Requires - `confirm` to equal the URL `graph_id`. Caller must be admin on the graph. Not allowed on shared - repositories. Deprovisioning typically completes within ~10 minutes; poll the graph status to - verify. + Permanently destroys a user graph and cancels its subscription. Two modes via the `at_period_end` + body flag: omit it (or pass `false`) to tear down immediately (~10 min); pass `true` to keep the + graph usable through the current billing period and tear it down at the period boundary via the + existing suspend → deprovision pipeline. Requires `confirm` to equal the URL `graph_id`. Caller must + be admin on the graph. Not allowed on shared repositories. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -219,10 +235,18 @@ async def asyncio_detailed( idempotency_key (None | str | Unset): body (DeleteGraphOp): Body for the delete-graph operation. - Permanently destroys the graph: cancels its subscription immediately, then - triggers fast-path deprovisioning (LadybugDB database removed, DynamoDB slot - freed, PG records cleaned). Requires `confirm` to equal the URL `graph_id` - as a guard against accidental destructive calls. + Permanently destroys the graph and cancels its subscription. Two modes: + + - **Immediate** (default): subscription canceled now (`ends_at = now`) and + fast-path deprovisioning fires within ~10 minutes. Use when you want + the data gone and the slot freed right away. + - **At period end** (`at_period_end=true`): subscription canceled but + `ends_at = current_period_end` so the graph stays usable through the + paid period. The existing suspend → deprovision sensor pipeline tears + it down after the retention window once the period closes. + + Requires `confirm` to equal the URL `graph_id` as a guard against + accidental destructive calls. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -252,11 +276,11 @@ async def asyncio( ) -> ErrorResponse | HTTPValidationError | OperationEnvelope | None: """Delete Graph - Permanently destroys a user graph: cancels its subscription immediately and queues fast-path - deprovisioning (LadybugDB database removed, DynamoDB slot freed, PG records cleaned). Requires - `confirm` to equal the URL `graph_id`. Caller must be admin on the graph. Not allowed on shared - repositories. Deprovisioning typically completes within ~10 minutes; poll the graph status to - verify. + Permanently destroys a user graph and cancels its subscription. Two modes via the `at_period_end` + body flag: omit it (or pass `false`) to tear down immediately (~10 min); pass `true` to keep the + graph usable through the current billing period and tear it down at the period boundary via the + existing suspend → deprovision pipeline. Requires `confirm` to equal the URL `graph_id`. Caller must + be admin on the graph. Not allowed on shared repositories. **Idempotency**: supply an `Idempotency-Key` header to make safe retries; replays within 24 hours return the same envelope. Reusing the key with a different body returns HTTP 409 Conflict. @@ -266,10 +290,18 @@ async def asyncio( idempotency_key (None | str | Unset): body (DeleteGraphOp): Body for the delete-graph operation. - Permanently destroys the graph: cancels its subscription immediately, then - triggers fast-path deprovisioning (LadybugDB database removed, DynamoDB slot - freed, PG records cleaned). Requires `confirm` to equal the URL `graph_id` - as a guard against accidental destructive calls. + Permanently destroys the graph and cancels its subscription. Two modes: + + - **Immediate** (default): subscription canceled now (`ends_at = now`) and + fast-path deprovisioning fires within ~10 minutes. Use when you want + the data gone and the slot freed right away. + - **At period end** (`at_period_end=true`): subscription canceled but + `ends_at = current_period_end` so the graph stays usable through the + paid period. The existing suspend → deprovision sensor pipeline tears + it down after the retention window once the period closes. + + Requires `confirm` to equal the URL `graph_id` as a guard against + accidental destructive calls. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/robosystems_client/api/subscriptions/cancel_repository_subscription.py b/robosystems_client/api/subscriptions/cancel_repository_subscription.py new file mode 100644 index 0000000..2db2047 --- /dev/null +++ b/robosystems_client/api/subscriptions/cancel_repository_subscription.py @@ -0,0 +1,257 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.cancel_subscription_request import CancelSubscriptionRequest +from ...models.error_response import ErrorResponse +from ...models.graph_subscription_response import GraphSubscriptionResponse +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + graph_id: str, + *, + body: CancelSubscriptionRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v1/graphs/{graph_id}/subscriptions/cancel".format( + graph_id=quote(str(graph_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ErrorResponse | GraphSubscriptionResponse | HTTPValidationError | None: + if response.status_code == 200: + response_200 = GraphSubscriptionResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = ErrorResponse.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = ErrorResponse.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = ErrorResponse.from_dict(response.json()) + + return response_404 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = ErrorResponse.from_dict(response.json()) + + return response_429 + + if response.status_code == 500: + response_500 = ErrorResponse.from_dict(response.json()) + + return response_500 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ErrorResponse | GraphSubscriptionResponse | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: CancelSubscriptionRequest, +) -> Response[ErrorResponse | GraphSubscriptionResponse | HTTPValidationError]: + """Cancel Repository Subscription + + Cancel a shared repository subscription. Two modes via the `immediate` flag: omit it (default + `false`) to cancel at period end (access stays until the period closes); pass `true` with + `confirm=` to stop access right away. For user graphs, use `POST + /v1/graphs/{graph_id}/operations/delete-graph` — this endpoint rejects user graphs. Requires org + owner role. + + Args: + graph_id (str): Repository name (e.g., 'sec', 'industry') + body (CancelSubscriptionRequest): Request to cancel a subscription. + + Default behavior cancels at period end (soft cancel). Pass `immediate=True` + to terminate the subscription right away — this requires `confirm` to equal + the subscription's `resource_id` (e.g. the graph_id) as a guard against + accidental destructive calls. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | GraphSubscriptionResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + graph_id: str, + *, + client: AuthenticatedClient, + body: CancelSubscriptionRequest, +) -> ErrorResponse | GraphSubscriptionResponse | HTTPValidationError | None: + """Cancel Repository Subscription + + Cancel a shared repository subscription. Two modes via the `immediate` flag: omit it (default + `false`) to cancel at period end (access stays until the period closes); pass `true` with + `confirm=` to stop access right away. For user graphs, use `POST + /v1/graphs/{graph_id}/operations/delete-graph` — this endpoint rejects user graphs. Requires org + owner role. + + Args: + graph_id (str): Repository name (e.g., 'sec', 'industry') + body (CancelSubscriptionRequest): Request to cancel a subscription. + + Default behavior cancels at period end (soft cancel). Pass `immediate=True` + to terminate the subscription right away — this requires `confirm` to equal + the subscription's `resource_id` (e.g. the graph_id) as a guard against + accidental destructive calls. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | GraphSubscriptionResponse | HTTPValidationError + """ + + return sync_detailed( + graph_id=graph_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + graph_id: str, + *, + client: AuthenticatedClient, + body: CancelSubscriptionRequest, +) -> Response[ErrorResponse | GraphSubscriptionResponse | HTTPValidationError]: + """Cancel Repository Subscription + + Cancel a shared repository subscription. Two modes via the `immediate` flag: omit it (default + `false`) to cancel at period end (access stays until the period closes); pass `true` with + `confirm=` to stop access right away. For user graphs, use `POST + /v1/graphs/{graph_id}/operations/delete-graph` — this endpoint rejects user graphs. Requires org + owner role. + + Args: + graph_id (str): Repository name (e.g., 'sec', 'industry') + body (CancelSubscriptionRequest): Request to cancel a subscription. + + Default behavior cancels at period end (soft cancel). Pass `immediate=True` + to terminate the subscription right away — this requires `confirm` to equal + the subscription's `resource_id` (e.g. the graph_id) as a guard against + accidental destructive calls. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[ErrorResponse | GraphSubscriptionResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + graph_id=graph_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + graph_id: str, + *, + client: AuthenticatedClient, + body: CancelSubscriptionRequest, +) -> ErrorResponse | GraphSubscriptionResponse | HTTPValidationError | None: + """Cancel Repository Subscription + + Cancel a shared repository subscription. Two modes via the `immediate` flag: omit it (default + `false`) to cancel at period end (access stays until the period closes); pass `true` with + `confirm=` to stop access right away. For user graphs, use `POST + /v1/graphs/{graph_id}/operations/delete-graph` — this endpoint rejects user graphs. Requires org + owner role. + + Args: + graph_id (str): Repository name (e.g., 'sec', 'industry') + body (CancelSubscriptionRequest): Request to cancel a subscription. + + Default behavior cancels at period end (soft cancel). Pass `immediate=True` + to terminate the subscription right away — this requires `confirm` to equal + the subscription's `resource_id` (e.g. the graph_id) as a guard against + accidental destructive calls. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + ErrorResponse | GraphSubscriptionResponse | HTTPValidationError + """ + + return ( + await asyncio_detailed( + graph_id=graph_id, + client=client, + body=body, + ) + ).parsed diff --git a/robosystems_client/clients/graph_client.py b/robosystems_client/clients/graph_client.py index e3fd555..cd12d39 100644 --- a/robosystems_client/clients/graph_client.py +++ b/robosystems_client/clients/graph_client.py @@ -676,18 +676,80 @@ def _wait_with_polling( raise TimeoutError(f"Graph creation timed out after {timeout}s") - def delete_graph(self, graph_id: str) -> None: + def delete_graph( + self, + graph_id: str, + at_period_end: bool = False, + on_progress: Optional[Callable[[str], None]] = None, + ) -> Dict[str, Any]: """ - Delete a graph. + Permanently delete a graph by canceling its subscription and triggering + deprovisioning. + + Two modes: + + - **Immediate** (default, ``at_period_end=False``): subscription canceled + now and fast-path deprovisioning fires within ~10 minutes. + - **At period end** (``at_period_end=True``): graph stays usable through + the current billing period; suspend → deprovision pipeline tears it + down at the period boundary. + + Args: + graph_id: Graph database identifier (must be a user graph — shared + repositories cannot be deleted) + at_period_end: If True, defer cancellation and teardown to the end + of the current billing period. + on_progress: Optional callback for progress updates. + + Returns: + The ``result`` field of the operation envelope. For immediate: + ``{"graph_id", "subscription_id", "status": "deprovisioning_queued", + "message"}``. For period-end: includes ``ends_at`` instead. - Note: This method is not yet available as the delete_graph endpoint - is not included in the generated SDK. + Raises: + RuntimeError: If the deletion request fails. """ - raise NotImplementedError( - "Graph deletion is not yet available. " - "The delete_graph endpoint needs to be added to the API specification." + from ..api.graph_operations.op_delete_graph import ( + sync_detailed as op_delete_graph, + ) + from ..models.delete_graph_op import DeleteGraphOp + + client = self._get_authenticated_client() + + if on_progress: + mode = "at period end" if at_period_end else "immediately" + on_progress(f"Deleting graph {graph_id} ({mode})...") + + response = op_delete_graph( + graph_id=graph_id, + client=client, + body=DeleteGraphOp(confirm=graph_id, at_period_end=at_period_end), ) + if response.status_code not in (200, 202) or not response.parsed: + error_msg = f"Graph deletion failed: HTTP {response.status_code}" + if hasattr(response, "content"): + try: + error_data = json.loads(response.content) + error_msg = error_data.get("detail", error_msg) + except Exception: + pass + raise RuntimeError(error_msg) + + envelope = response.parsed + result = getattr(envelope, "result", None) + if isinstance(result, dict): + result_dict = result + elif hasattr(result, "additional_properties"): + result_dict = dict(result.additional_properties) + else: + result_dict = {} + + if on_progress: + on_progress(result_dict.get("message", "Graph deletion submitted.")) + + return result_dict + def close(self): """Clean up resources.""" pass diff --git a/robosystems_client/models/delete_graph_op.py b/robosystems_client/models/delete_graph_op.py index 7958d26..58d5780 100644 --- a/robosystems_client/models/delete_graph_op.py +++ b/robosystems_client/models/delete_graph_op.py @@ -6,6 +6,8 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field +from ..types import UNSET, Unset + T = TypeVar("T", bound="DeleteGraphOp") @@ -13,21 +15,34 @@ class DeleteGraphOp: """Body for the delete-graph operation. - Permanently destroys the graph: cancels its subscription immediately, then - triggers fast-path deprovisioning (LadybugDB database removed, DynamoDB slot - freed, PG records cleaned). Requires `confirm` to equal the URL `graph_id` - as a guard against accidental destructive calls. + Permanently destroys the graph and cancels its subscription. Two modes: + + - **Immediate** (default): subscription canceled now (`ends_at = now`) and + fast-path deprovisioning fires within ~10 minutes. Use when you want + the data gone and the slot freed right away. + - **At period end** (`at_period_end=true`): subscription canceled but + `ends_at = current_period_end` so the graph stays usable through the + paid period. The existing suspend → deprovision sensor pipeline tears + it down after the retention window once the period closes. + + Requires `confirm` to equal the URL `graph_id` as a guard against + accidental destructive calls. Attributes: confirm (str): Must equal the graph_id in the URL — confirms the caller intends to destroy this specific graph. + at_period_end (bool | Unset): If true, defer cancellation and teardown to the end of the current billing period + (graph stays usable until then). If false (default), cancel and tear down immediately. Default: False. """ confirm: str + at_period_end: bool | Unset = False additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: confirm = self.confirm + at_period_end = self.at_period_end + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -35,6 +50,8 @@ def to_dict(self) -> dict[str, Any]: "confirm": confirm, } ) + if at_period_end is not UNSET: + field_dict["at_period_end"] = at_period_end return field_dict @@ -43,8 +60,11 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) confirm = d.pop("confirm") + at_period_end = d.pop("at_period_end", UNSET) + delete_graph_op = cls( confirm=confirm, + at_period_end=at_period_end, ) delete_graph_op.additional_properties = d diff --git a/tests/test_graph_client.py b/tests/test_graph_client.py index 1944ff8..dc5fed6 100644 --- a/tests/test_graph_client.py +++ b/tests/test_graph_client.py @@ -110,6 +110,66 @@ def test_close_method(self, mock_config): # Should not raise any exceptions client.close() + @patch("robosystems_client.api.graph_operations.op_delete_graph.sync_detailed") + def test_delete_graph_immediate(self, mock_op, mock_config): + """delete_graph (default mode) submits immediate cancellation.""" + envelope = MagicMock() + envelope.result = { + "graph_id": "kg_x", + "subscription_id": "sub_1", + "status": "deprovisioning_queued", + "message": "ok", + } + response = MagicMock() + response.status_code = 202 + response.parsed = envelope + mock_op.return_value = response + + client = GraphClient(mock_config) + result = client.delete_graph("kg_x") + + assert result["status"] == "deprovisioning_queued" + body = mock_op.call_args.kwargs["body"] + assert body.confirm == "kg_x" + assert body.at_period_end is False + + @patch("robosystems_client.api.graph_operations.op_delete_graph.sync_detailed") + def test_delete_graph_at_period_end(self, mock_op, mock_config): + """delete_graph(at_period_end=True) defers cancellation to period end.""" + envelope = MagicMock() + envelope.result = { + "graph_id": "kg_x", + "subscription_id": "sub_1", + "status": "scheduled_for_deprovision", + "ends_at": "2026-06-01T00:00:00+00:00", + "message": "ok", + } + response = MagicMock() + response.status_code = 202 + response.parsed = envelope + mock_op.return_value = response + + client = GraphClient(mock_config) + result = client.delete_graph("kg_x", at_period_end=True) + + assert result["status"] == "scheduled_for_deprovision" + assert result["ends_at"] == "2026-06-01T00:00:00+00:00" + body = mock_op.call_args.kwargs["body"] + assert body.at_period_end is True + + @patch("robosystems_client.api.graph_operations.op_delete_graph.sync_detailed") + def test_delete_graph_raises_on_failure(self, mock_op, mock_config): + """Non-2xx response raises RuntimeError with the API error detail.""" + response = MagicMock() + response.status_code = 400 + response.parsed = None + response.content = b'{"detail":"`confirm` must equal the graph_id"}' + mock_op.return_value = response + + client = GraphClient(mock_config) + with pytest.raises(RuntimeError, match="confirm"): + client.delete_graph("kg_x") + @pytest.mark.unit class TestProcessSSEEvent: