diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py new file mode 100644 index 000000000..ee1ae717b --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py @@ -0,0 +1,164 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.sandbox_metric import SandboxMetric +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/sandboxes/{sandbox_id}/metrics", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, list["SandboxMetric"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = SandboxMetric.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 404: + response_404 = cast(Any, None) + return response_404 + if response.status_code == 401: + response_401 = cast(Any, None) + return response_401 + if response.status_code == 500: + response_500 = cast(Any, None) + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, list["SandboxMetric"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + + 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[Union[Any, list['SandboxMetric']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + + 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: + Union[Any, list['SandboxMetric']] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + + 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[Union[Any, list['SandboxMetric']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + + 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: + Union[Any, list['SandboxMetric']] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_metric.py b/packages/python-sdk/e2b/api/client/models/sandbox_metric.py new file mode 100644 index 000000000..5867253ce --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_metric.py @@ -0,0 +1,93 @@ +import datetime +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="SandboxMetric") + + +@_attrs_define +class SandboxMetric: + """Metric entry with timestamp and line + + Attributes: + timestamp (datetime.datetime): Timestamp of the log entry + cpu_pct (float): CPU usage percentage + cpu_count (int): Number of CPU cores + mem_mi_b_used (int): Memory used in MiB + mem_mi_b_total (int): Total memory in MiB + """ + + timestamp: datetime.datetime + cpu_pct: float + cpu_count: int + mem_mi_b_used: int + mem_mi_b_total: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + timestamp = self.timestamp.isoformat() + + cpu_pct = self.cpu_pct + + cpu_count = self.cpu_count + + mem_mi_b_used = self.mem_mi_b_used + + mem_mi_b_total = self.mem_mi_b_total + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "timestamp": timestamp, + "cpuPct": cpu_pct, + "cpuCount": cpu_count, + "memMiBUsed": mem_mi_b_used, + "memMiBTotal": mem_mi_b_total, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T: + d = src_dict.copy() + timestamp = isoparse(d.pop("timestamp")) + + cpu_pct = d.pop("cpuPct") + + cpu_count = d.pop("cpuCount") + + mem_mi_b_used = d.pop("memMiBUsed") + + mem_mi_b_total = d.pop("memMiBTotal") + + sandbox_metric = cls( + timestamp=timestamp, + cpu_pct=cpu_pct, + cpu_count=cpu_count, + mem_mi_b_used=mem_mi_b_used, + mem_mi_b_total=mem_mi_b_total, + ) + + sandbox_metric.additional_properties = d + return sandbox_metric + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index cbf876053..2f96f4510 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -1,7 +1,8 @@ from abc import ABC from dataclasses import dataclass -from typing import Optional, Dict from datetime import datetime +from typing import Dict, Optional + from httpx import Limits @@ -21,6 +22,22 @@ class SandboxInfo: """Sandbox start time.""" +@dataclass +class SandboxMetrics: + """Sandbox resource usage metrics""" + + timestamp: datetime + """Timestamp of the metrics.""" + cpu_pct: float + """CPU usage in percentage.""" + cpu_count: int + """Number of CPU cores.""" + mem_mib_used: int + """Memory usage in bytes.""" + mem_mib_total: int + """Total memory available""" + + class SandboxApiBase(ABC): _limits = Limits( max_keepalive_connections=10, diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 066162d85..740dd661f 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -1,18 +1,17 @@ import logging -import httpx - -from typing import Dict, Optional, TypedDict, overload -from typing_extensions import Unpack +from typing import Dict, List, Optional, TypedDict, overload +import httpx from e2b.connection_config import ConnectionConfig from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception from e2b.exceptions import format_request_timeout_error from e2b.sandbox.main import SandboxSetup from e2b.sandbox.utils import class_method_variant -from e2b.sandbox_async.filesystem.filesystem import Filesystem from e2b.sandbox_async.commands.command import Commands from e2b.sandbox_async.commands.pty import Pty -from e2b.sandbox_async.sandbox_api import SandboxApi +from e2b.sandbox_async.filesystem.filesystem import Filesystem +from e2b.sandbox_async.sandbox_api import SandboxApi, SandboxMetrics +from typing_extensions import Unpack logger = logging.getLogger(__name__) @@ -364,3 +363,20 @@ async def set_timeout( # type: ignore timeout=timeout, **self.connection_config.__dict__, ) + + @class_method_variant("_cls_get_metrics") + async def get_metrics( # type: ignore + self, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + config_dict = self.connection_config.__dict__ + config_dict.pop("access_token", None) + config_dict.pop("api_url", None) + + if request_timeout: + config_dict["request_timeout"] = request_timeout + + return await SandboxApi._cls_get_metrics( + sandbox_id=self.sandbox_id, + **self.connection_config.__dict__, + ) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index ea99105cc..fbdd8b02c 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -1,18 +1,19 @@ -from typing import Optional, Dict, List -from packaging.version import Version +from datetime import datetime +from typing import Dict, List, Optional -from e2b.sandbox.sandbox_api import SandboxInfo, SandboxApiBase -from e2b.exceptions import TemplateException -from e2b.api import AsyncApiClient -from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody +from e2b.api import AsyncApiClient, handle_api_exception from e2b.api.client.api.sandboxes import ( - post_sandboxes_sandbox_id_timeout, - get_sandboxes, delete_sandboxes_sandbox_id, + get_sandboxes, + get_sandboxes_sandbox_id_metrics, post_sandboxes, + post_sandboxes_sandbox_id_timeout, ) +from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody from e2b.connection_config import ConnectionConfig -from e2b.api import handle_api_exception +from e2b.exceptions import TemplateException +from e2b.sandbox.sandbox_api import SandboxApiBase, SandboxInfo, SandboxMetrics +from packaging.version import Version class SandboxApi(SandboxApiBase): @@ -131,6 +132,49 @@ async def _cls_set_timeout( if res.status_code >= 300: raise handle_api_exception(res) + @classmethod + async def _cls_get_metrics( + cls, + sandbox_id: str, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + request_timeout=request_timeout, + ) + + if config.debug: + # Skip getting the metrics in debug mode + return [] + + async with AsyncApiClient(config) as api_client: + res = await get_sandboxes_sandbox_id_metrics.asyncio_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + return [] + + return [ + SandboxMetrics( + timestamp=metric.timestamp, + cpu_pct=metric.cpu_pct, + cpu_count=metric.cpu_count, + mem_mib_used=metric.mem_mi_b_used, + mem_mib_total=metric.mem_mi_b_total, + ) + for metric in res.parsed + ] + @classmethod async def _create_sandbox( cls, diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 2fa54ea86..4d2b9094c 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Optional, overload +from typing import Dict, List, Optional, overload import httpx from e2b.connection_config import ConnectionConfig @@ -7,10 +7,10 @@ from e2b.exceptions import SandboxException, format_request_timeout_error from e2b.sandbox.main import SandboxSetup from e2b.sandbox.utils import class_method_variant -from e2b.sandbox_sync.filesystem.filesystem import Filesystem from e2b.sandbox_sync.commands.command import Commands from e2b.sandbox_sync.commands.pty import Pty -from e2b.sandbox_sync.sandbox_api import SandboxApi +from e2b.sandbox_sync.filesystem.filesystem import Filesystem +from e2b.sandbox_sync.sandbox_api import SandboxApi, SandboxMetrics logger = logging.getLogger(__name__) @@ -355,3 +355,20 @@ def set_timeout( # type: ignore timeout=timeout, **self.connection_config.__dict__, ) + + @class_method_variant("_get_metrics") + async def get_metrics( # type: ignore + self, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + config_dict = self.connection_config.__dict__ + config_dict.pop("access_token", None) + config_dict.pop("api_url", None) + + if request_timeout: + config_dict["request_timeout"] = request_timeout + + return SandboxApi._cls_get_metrics( + sandbox_id=self.sandbox_id, + **self.connection_config.__dict__, + ) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 8e37aab02..1ff476916 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -1,19 +1,19 @@ -from httpx import HTTPTransport -from typing import Optional, Dict, List -from packaging.version import Version +from typing import Dict, List, Optional -from e2b.sandbox.sandbox_api import SandboxInfo, SandboxApiBase -from e2b.exceptions import TemplateException -from e2b.api import ApiClient -from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody +from e2b.api import ApiClient, handle_api_exception from e2b.api.client.api.sandboxes import ( - post_sandboxes_sandbox_id_timeout, - get_sandboxes, delete_sandboxes_sandbox_id, + get_sandboxes, + get_sandboxes_sandbox_id_metrics, post_sandboxes, + post_sandboxes_sandbox_id_timeout, ) +from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody from e2b.connection_config import ConnectionConfig -from e2b.api import handle_api_exception +from e2b.exceptions import TemplateException +from e2b.sandbox.sandbox_api import SandboxApiBase, SandboxInfo, SandboxMetrics +from httpx import HTTPTransport +from packaging.version import Version class SandboxApi(SandboxApiBase): @@ -138,6 +138,49 @@ def _cls_set_timeout( if res.status_code >= 300: raise handle_api_exception(res) + @classmethod + def _cls_get_metrics( + cls, + sandbox_id: str, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + request_timeout=request_timeout, + ) + + if config.debug: + # Skip getting the metrics in debug mode + return [] + + with ApiClient(config) as api_client: + res = get_sandboxes_sandbox_id_metrics.sync_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + return [] + + return [ + SandboxMetrics( + timestamp=metric.timestamp, + cpu_pct=metric.cpu_pct, + cpu_count=metric.cpu_count, + mem_mib_used=metric.mem_mi_b_used, + mem_mib_total=metric.mem_mi_b_total, + ) + for metric in res.parsed + ] + @classmethod def _create_sandbox( cls, diff --git a/packages/python-sdk/tests/async/sandbox_async/test_metrics.py b/packages/python-sdk/tests/async/sandbox_async/test_metrics.py new file mode 100644 index 000000000..57d1fb9ce --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_metrics.py @@ -0,0 +1,12 @@ +import pytest +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_get_metrics(async_sandbox: AsyncSandbox): + metrics = await async_sandbox.get_metrics() + assert len(metrics) > 0 + assert metrics[0].cpu_pct is not None + assert metrics[0].cpu_count is not None + assert metrics[0].mem_mib_used is not None + assert metrics[0].mem_mib_total is not None diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py b/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py new file mode 100644 index 000000000..a2b977cdd --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py @@ -0,0 +1,11 @@ +import pytest + + +@pytest.mark.skip_debug() +async def test_get_metrics(sandbox): + metrics = sandbox.get_metrics() + assert len(metrics) > 0 + assert metrics[0].cpu_pct is not None + assert metrics[0].cpu_count is not None + assert metrics[0].mem_mib_used is not None + assert metrics[0].mem_mib_total is not None diff --git a/spec/openapi.yml b/spec/openapi.yml index 17a1db854..a216b14f5 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -335,6 +335,35 @@ components: - ready - error + SandboxMetric: + description: Metric entry with timestamp and line + required: + - timestamp + - cpuPct + - cpuCount + - memMiBUsed + - memMiBTotal + properties: + timestamp: + type: string + format: date-time + description: Timestamp of the log entry + cpuPct: + type: number + format: float + description: CPU usage percentage + cpuCount: + type: integer + description: Number of CPU cores + memMiBUsed: + type: integer + format: int64 + description: Memory used in MiB + memMiBTotal: + type: integer + format: int64 + description: Total memory in MiB + Error: required: - code @@ -516,6 +545,32 @@ paths: $ref: "#/components/responses/404" "500": $ref: "#/components/responses/500" + format: int32 + + /sandboxes/{sandboxID}/metrics: + get: + description: Get sandbox metrics + tags: [sandboxes] + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/sandboxID" + responses: + "200": + description: Successfully returned the sandbox metrics + content: + application/json: + schema: + type: array + items: + type: object + $ref: "#/components/schemas/SandboxMetric" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" /sandboxes/{sandboxID}/refreshes: post: