diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..6b52cb6 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,32 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build tooling + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build distribution artifacts + run: python -m build + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5874277 --- /dev/null +++ b/.gitignore @@ -0,0 +1,268 @@ +tests/assets/video-downloaded.webm + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pdm +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +.idea/ + +# Test results +results.csv diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7155a9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +MIT License + +Copyright (c) 2025 UCloud Technology Co., Ltd. + +This project is based on E2B (https://github.com/e2b-dev/e2b), originally developed by FoundryLabs, Inc. +Original E2B License: MIT License, Copyright (c) 2025 FOUNDRYLABS, INC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ee791c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../..) + +generate-api: + python $(ROOT_DIR)/spec/remove_extra_tags.py sandboxes templates + openapi-python-client generate --output-path $(ROOT_DIR)/packages/python-sdk/e2b/api/api --overwrite --path $(ROOT_DIR)/spec/openapi_generated.yml + rm -rf e2b/api/client + mv e2b/api/api/e2b_api_client e2b/api/client + rm -rf e2b/api/api + ruff format . + +generate-envd: + if [ ! -f "/go/bin/protoc-gen-connect-python" ]; then \ + $(MAKE) -C $(ROOT_DIR)/packages/connect-python build; \ + fi + + cd $(ROOT_DIR)/spec/envd && pwd && buf generate --template buf-python.gen.yaml + ./scripts/fix-python-pb.sh + + ruff format . + +generate: generate-api generate-envd generate-mcp + +init: + pip install openapi-python-client datamodel-code-generator + +lint: + ruff check . + +format: + ruff format . + +generate-mcp: + datamodel-codegen \ + --input ../../spec/mcp-server.json \ + --input-file-type jsonschema \ + --output e2b/sandbox/mcp.py \ + --output-model-type typing.TypedDict \ + --target-python-version 3.9 \ + --class-name McpServer \ + --use-field-description \ + --disable-timestamp \ + --extra-fields forbid + +.PHONY: setup +setup: + poetry install + +.PHONY: test +test: setup + poetry run pytest --verbose --numprocesses=4 diff --git a/README.md b/README.md index e69de29..570f962 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,44 @@ +# UCloud Sandbox Python SDK + +UCloud Sandbox Python SDK 提供云端沙箱环境,用于安全运行 AI 生成的代码。 + +## 安装 + +```bash +pip install ucloud_sandbox +``` + +## 快速开始 + +### 1. 获取 API Key + +1. 访问 [UCloud Sandbox](https://sandbox.ucloudai.com) 注册账号 +2. 在控制台获取 API Key +3. 设置环境变量: + +```bash +export UCLOUD_SANDBOX_API_KEY=your_api_key +``` + +### 2. 运行代码 + +```python +from ucloud_sandbox import Sandbox + +with Sandbox.create() as sandbox: + sandbox.run_code("x = 1") + execution = sandbox.run_code("x += 1; x") + print(execution.text) # 输出: 2 +``` + +## 文档 +TODO +访问 [Sandbox 文档](https://docs.sandbox.ucloudai.com) 获取更多信息。 + +## 致谢 + +本项目基于 [E2B](https://github.com/e2b-dev/e2b) 开源项目开发,感谢 E2B 团队的贡献。 + +## 许可证 + +MIT License - 详见 [LICENSE](./LICENSE) 文件 diff --git a/e2b_connect/__init__.py b/e2b_connect/__init__.py new file mode 100644 index 0000000..6f31c8c --- /dev/null +++ b/e2b_connect/__init__.py @@ -0,0 +1 @@ +from .client import Client, GzipCompressor, ConnectException, Code # noqa: F401 diff --git a/e2b_connect/client.py b/e2b_connect/client.py new file mode 100644 index 0000000..55587fa --- /dev/null +++ b/e2b_connect/client.py @@ -0,0 +1,493 @@ +import gzip +import inspect +import json +import struct +import typing + +from httpcore import ( + ConnectionPool, + AsyncConnectionPool, + RemoteProtocolError, + Response, +) +from enum import Flag, Enum +from typing import Callable, Optional, Dict, Any, Generator, Tuple +from google.protobuf import json_format + + +class EnvelopeFlags(Flag): + compressed = 0b00000001 + end_stream = 0b00000010 + + +class Code(Enum): + canceled = "canceled" + unknown = "unknown" + invalid_argument = "invalid_argument" + deadline_exceeded = "deadline_exceeded" + not_found = "not_found" + already_exists = "already_exists" + permission_denied = "permission_denied" + resource_exhausted = "resource_exhausted" + failed_precondition = "failed_precondition" + aborted = "aborted" + out_of_range = "out_of_range" + unimplemented = "unimplemented" + internal = "internal" + unavailable = "unavailable" + data_loss = "data_loss" + unauthenticated = "unauthenticated" + + +def make_error_from_http_code(http_code: int): + error_code_map = { + 400: Code.invalid_argument, + 401: Code.unauthenticated, + 403: Code.permission_denied, + 404: Code.not_found, + 409: Code.already_exists, + 413: Code.resource_exhausted, + 429: Code.resource_exhausted, + 499: Code.canceled, + 500: Code.internal, + 501: Code.unimplemented, + 502: Code.unavailable, + 503: Code.unavailable, + 504: Code.deadline_exceeded, + 505: Code.unimplemented, + } + + return error_code_map.get(http_code, Code.unknown) + + +class ConnectException(Exception): + def __init__(self, status: Code, message: str): + self.status = status + self.message = message + + +envelope_header_length = 5 +envelope_header_pack = ">BI" + + +def encode_envelope(*, flags: EnvelopeFlags, data): + return encode_envelope_header(flags=flags.value, data=data) + data + + +def encode_envelope_header(*, flags, data): + return struct.pack(envelope_header_pack, flags, len(data)) + + +def decode_envelope_header(header): + flags, data_len = struct.unpack(envelope_header_pack, header) + return EnvelopeFlags(flags), data_len + + +def error_for_response(http_resp: Response): + try: + error = json.loads(http_resp.content) + return make_error(error) + except (json.decoder.JSONDecodeError, KeyError): + error = {"code": http_resp.status, "message": http_resp.content.decode("utf-8")} + return make_error(error) + + +def make_error(error): + status = None + try: + code_value = error.get("code") + # return error code from http status code + if isinstance(code_value, int): + status = make_error_from_http_code(code_value) + else: + status = Code(code_value) + except (KeyError, ValueError): + status = Code.unknown + + return ConnectException(status, error.get("message", "")) + + +def _sync_retry(func, exc, retries): + def retry(*args, **kwargs): + for _ in range(retries): + try: + return func(*args, **kwargs) + except exc: + continue + + return func(*args, **kwargs) + + return retry + + +def _async_retry(func, exc, retries): + async def retry(*args, **kwargs): + for _ in range(retries): + try: + return await func(*args, **kwargs) + except exc: + continue + + return await func(*args, **kwargs) + + return retry + + +def _retry(exc: typing.Type[Exception], retries: int): + def decorator(func): + if inspect.iscoroutinefunction(func): + return _async_retry(func, exc, retries) + + return _sync_retry(func, exc, retries) + + return decorator + + +class GzipCompressor: + name = "gzip" + decompress = gzip.decompress + compress = gzip.compress + + +class JSONCodec: + content_type = "json" + + @staticmethod + def encode(msg): + return json_format.MessageToJson(msg).encode("utf8") + + @staticmethod + def decode(data, *, msg_type): + msg = msg_type() + json_format.Parse(data.decode("utf8"), msg, ignore_unknown_fields=True) + return msg + + +class ProtobufCodec: + content_type = "proto" + + @staticmethod + def encode(msg): + return msg.SerializeToString() + + @staticmethod + def decode(data, *, msg_type): + msg = msg_type() + msg.ParseFromString(data) + return msg + + +class Client: + def __init__( + self, + *, + pool: Optional[ConnectionPool] = None, + async_pool: Optional[AsyncConnectionPool] = None, + url: str, + response_type, + compressor=None, + json: Optional[bool] = False, + headers: Optional[Dict[str, str]] = None, + ): + if headers is None: + headers = {} + + self.pool = pool + self.async_pool = async_pool + self.url = url + self._codec = JSONCodec if json else ProtobufCodec + self._response_type = response_type + self._compressor = compressor + self._headers = headers + self._connection_retries = 3 + + def _prepare_unary_request( + self, + req, + request_timeout=None, + headers: Optional[dict] = None, + **opts, + ) -> dict: + data = self._codec.encode(req) + + if self._compressor is not None: + data = self._compressor.compress(data) + + if headers is None: + headers = {} + + extensions = ( + None + if request_timeout is None + else { + "timeout": { + "connect": request_timeout, + "pool": request_timeout, + "read": request_timeout, + "write": request_timeout, + } + } + ) + + return { + "method": "POST", + "url": self.url, + "content": data, + "extensions": extensions, + "headers": { + **self._headers, + **headers, + **opts.get("headers", {}), + "connect-protocol-version": "1", + "content-encoding": ( + "identity" if self._compressor is None else self._compressor.name + ), + "content-type": f"application/{self._codec.content_type}", + }, + } + + def _process_unary_response( + self, + http_resp: Response, + ): + if http_resp.status != 200: + raise error_for_response(http_resp) + + content = http_resp.content + + if self._compressor is not None: + content = self._compressor.decompress(content) + + return self._codec.decode( + content, + msg_type=self._response_type, + ) + + @_retry(RemoteProtocolError, 3) + async def acall_unary( + self, + req, + request_timeout=None, + headers: Optional[dict] = None, + **opts, + ): + if self.async_pool is None: + raise ValueError("async_pool is required") + + req_data = self._prepare_unary_request( + req, + request_timeout, + headers, + **opts, + ) + + res = await self.async_pool.request(**req_data) + return self._process_unary_response(res) + + @_retry(RemoteProtocolError, 3) + def call_unary( + self, + req, + request_timeout=None, + headers: Optional[dict] = None, + **opts, + ): + if self.pool is None: + raise ValueError("pool is required") + + req_data = self._prepare_unary_request( + req, + request_timeout, + headers, + **opts, + ) + + res = self.pool.request(**req_data) + return self._process_unary_response(res) + + def _create_stream_timeout(self, timeout: Optional[int]): + if timeout: + return {"connect-timeout-ms": str(timeout * 1000)} + return {} + + def _prepare_server_stream_request( + self, + req, + request_timeout=None, + timeout=None, + headers: Optional[dict] = None, + **opts, + ) -> dict: + headers = headers or {} + data = self._codec.encode(req) + flags = EnvelopeFlags(0) + + extensions = ( + None + if request_timeout is None + else {"timeout": {"connect": request_timeout, "pool": request_timeout}} + ) + + if self._compressor is not None: + data = self._compressor.compress(data) + flags |= EnvelopeFlags.compressed + + stream_timeout = self._create_stream_timeout(timeout) + + return { + "method": "POST", + "url": self.url, + "content": encode_envelope( + flags=flags, + data=data, + ), + "extensions": extensions, + "headers": { + **self._headers, + **headers, + **opts.get("headers", {}), + **stream_timeout, + "connect-protocol-version": "1", + "connect-content-encoding": ( + "identity" if self._compressor is None else self._compressor.name + ), + "content-type": f"application/connect+{self._codec.content_type}", + }, + } + + @_retry(RemoteProtocolError, 3) + async def acall_server_stream( + self, + req, + request_timeout=None, + timeout=None, + headers: Optional[dict] = None, + **opts, + ): + if self.async_pool is None: + raise ValueError("async_pool is required") + + req_data = self._prepare_server_stream_request( + req, + request_timeout, + timeout, + headers, + **opts, + ) + + parser = ServerStreamParser( + decode=self._codec.decode, + response_type=self._response_type, + ) + + async with self.async_pool.stream(**req_data) as http_resp: + if http_resp.status != 200: + await http_resp.aread() + raise error_for_response(http_resp) + + async for chunk in http_resp.aiter_stream(): + for parsed in parser.parse(chunk): + yield parsed + + @_retry(RemoteProtocolError, 3) + def call_server_stream( + self, + req, + request_timeout=None, + timeout=None, + headers: Optional[dict] = None, + **opts, + ): + if self.pool is None: + raise ValueError("pool is required") + + req_data = self._prepare_server_stream_request( + req, + request_timeout, + timeout, + headers, + **opts, + ) + + parser = ServerStreamParser( + decode=self._codec.decode, + response_type=self._response_type, + ) + + with self.pool.stream(**req_data) as http_resp: + if http_resp.status != 200: + http_resp.read() + raise error_for_response(http_resp) + + for chunk in http_resp.iter_stream(): + for parsed in parser.parse(chunk): + yield parsed + + def call_client_stream(self, req, **opts): + raise NotImplementedError("client stream not supported") + + def acall_client_stream(self, req, **opts): + raise NotImplementedError("client stream not supported") + + def call_bidi_stream(self, req, **opts): + raise NotImplementedError("bidi stream not supported") + + def acall_bidi_stream(self, req, **opts): + raise NotImplementedError("bidi stream not supported") + + +DataLen = int + + +class ServerStreamParser: + def __init__( + self, + decode: Callable, + response_type: Any, + ): + self.decode = decode + self.response_type = response_type + + self.buffer: bytes = b"" + self._header: Optional[tuple[EnvelopeFlags, DataLen]] = None + + def shift_buffer(self, size: int): + buffer = self.buffer[:size] + self.buffer = self.buffer[size:] + return buffer + + @property + def header(self) -> Tuple[EnvelopeFlags, DataLen]: + if self._header: + return self._header + + header_data = self.shift_buffer(envelope_header_length) + self._header = decode_envelope_header(header_data) + + return self._header + + @header.deleter + def header(self): + self._header = None + + def parse(self, chunk: bytes) -> Generator[Any, None, None]: + self.buffer += chunk + + while len(self.buffer) >= envelope_header_length: + flags, data_len = self.header + + if data_len > len(self.buffer): + break + + data = self.shift_buffer(data_len) + + if EnvelopeFlags.end_stream in flags: + data = json.loads(data) + + if "error" in data: + raise make_error(data["error"]) + + return + + yield self.decode(data, msg_type=self.response_type) + del self.header diff --git a/example.py b/example.py new file mode 100644 index 0000000..6f8daa6 --- /dev/null +++ b/example.py @@ -0,0 +1,18 @@ +import asyncio +import logging +from e2b import AsyncSandbox + +import dotenv + +dotenv.load_dotenv() + +logging.basicConfig(level=logging.ERROR) + + +async def main(): + sbx = await AsyncSandbox.create(timeout=10) + await sbx.set_timeout(20) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/package.json b/package.json new file mode 100644 index 0000000..0d62799 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "ucloud-sandbox-sdk", + "private": true, + "version": "1.0.0", + "scripts": { + "example": "poetry run python example.py", + "test": "poetry run pytest -n 4 --verbose -x", + "postVersion": "poetry version $(pnpm pkg get version --workspaces=false | tr -d \\\")", + "postPublish": "poetry build && poetry config pypi-token.pypi ${PYPI_TOKEN} && poetry publish --skip-existing", + "pretest": "poetry install", + "generate-ref": "poetry install && ./scripts/generate_sdk_ref.sh", + "lint": "poetry run make lint", + "format": "poetry run make format" + } +} \ No newline at end of file diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..c3ef72a --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1802 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.11.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0)"] + +[[package]] +name = "argcomplete" +version = "3.6.2" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591"}, + {file = "argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + +[[package]] +name = "black" +version = "25.9.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"}, + {file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"}, + {file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"}, + {file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"}, + {file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"}, + {file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"}, + {file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"}, + {file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"}, + {file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"}, + {file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"}, + {file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"}, + {file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"}, + {file = "black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175"}, + {file = "black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f"}, + {file = "black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831"}, + {file = "black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357"}, + {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, + {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, + {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, + {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, + {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, + {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +pytokens = ">=0.1.10" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "bracex" +version = "2.6" +description = "Bash style brace expander." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952"}, + {file = "bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7"}, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, + {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "databind" +version = "4.5.2" +description = "Databind is a library inspired by jackson-databind to de-/serialize Python dataclasses. The `databind` package will install the full suite of databind packages. Compatible with Python 3.8 and newer." +optional = false +python-versions = "<4.0.0,>=3.8.0" +groups = ["dev"] +files = [ + {file = "databind-4.5.2-py3-none-any.whl", hash = "sha256:b9c3a03c0414aa4567f095d7218ac904bd2b267b58e3763dac28e83d64b69770"}, + {file = "databind-4.5.2.tar.gz", hash = "sha256:0a8aa0ff130a0306581c559388f5ef65e0fae7ef4b86412eacb1f4a0420006c4"}, +] + +[package.dependencies] +Deprecated = ">=1.2.12,<2.0.0" +nr-date = ">=2.0.0,<3.0.0" +nr-stream = ">=1.0.0,<2.0.0" +setuptools = {version = ">=40.8.0", markers = "python_version < \"3.10\""} +typeapi = ">=2.0.1,<3" +typing-extensions = ">=3.10.0,<5" + +[[package]] +name = "databind-core" +version = "4.5.2" +description = "Databind is a library inspired by jackson-databind to de-/serialize Python dataclasses. Compatible with Python 3.8 and newer. Deprecated, use `databind` package." +optional = false +python-versions = "<4.0.0,>=3.8.0" +groups = ["dev"] +files = [ + {file = "databind.core-4.5.2-py3-none-any.whl", hash = "sha256:a1dd1c6bd8ca9907d1292d8df9ec763ce91543e27f7eda4268e4a1a84fcd1c42"}, + {file = "databind.core-4.5.2.tar.gz", hash = "sha256:b8ac8127bc5d6b239a2a81aeddb268b0c4cadd53fbce7e8b2c7a9ef6413bccb3"}, +] + +[package.dependencies] +databind = ">=4.5.2,<5.0.0" + +[[package]] +name = "databind-json" +version = "4.5.2" +description = "De-/serialize Python dataclasses to or from JSON payloads. Compatible with Python 3.8 and newer. Deprecated, use `databind` module instead." +optional = false +python-versions = "<4.0.0,>=3.8.0" +groups = ["dev"] +files = [ + {file = "databind.json-4.5.2-py3-none-any.whl", hash = "sha256:a803bf440634685984361cb2a5a975887e487c854ed48d81ff7aaf3a1ed1e94c"}, + {file = "databind.json-4.5.2.tar.gz", hash = "sha256:6cc9b5c6fddaebd49b2433932948eb3be8a41633b90aa37998d7922504b8f165"}, +] + +[package.dependencies] +databind = ">=4.5.2,<5.0.0" + +[[package]] +name = "datamodel-code-generator" +version = "0.34.0" +description = "Datamodel Code Generator" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "datamodel_code_generator-0.34.0-py3-none-any.whl", hash = "sha256:74d1aaf2ab27e21b6d6e28b5236f27271b8404b7fd0e856be95c2f7562d694ff"}, + {file = "datamodel_code_generator-0.34.0.tar.gz", hash = "sha256:4695bdd2c9e85049db4bdf5791f68647518d98fd589d30bd8525e941e628acf7"}, +] + +[package.dependencies] +argcomplete = ">=2.10.1,<4" +black = ">=19.10b0" +genson = ">=1.2.1,<2" +inflect = ">=4.1,<8" +isort = ">=4.3.21,<7" +jinja2 = ">=2.10.1,<4" +packaging = "*" +pydantic = ">=1.5" +pyyaml = ">=6.0.1" +tomli = {version = ">=2.2.1,<3", markers = "python_version <= \"3.11\""} + +[package.extras] +all = ["graphql-core (>=3.2.3)", "httpx (>=0.24.1)", "openapi-spec-validator (>=0.2.8,<0.7)", "prance (>=0.18.2)", "pysnooper (>=0.4.1,<2)", "ruff (>=0.9.10)"] +debug = ["pysnooper (>=0.4.1,<2)"] +graphql = ["graphql-core (>=3.2.3)"] +http = ["httpx (>=0.24.1)"] +ruff = ["ruff (>=0.9.10)"] +validation = ["openapi-spec-validator (>=0.2.8,<0.7)", "prance (>=0.18.2)"] + +[[package]] +name = "deprecated" +version = "1.2.18" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +groups = ["dev"] +files = [ + {file = "Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec"}, + {file = "deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] + +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +description = "Python library for Dockerfile manipulation" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc"}, + {file = "dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6"}, +] + +[[package]] +name = "docspec" +version = "2.2.1" +description = "Docspec is a JSON object specification for representing API documentation of programming languages." +optional = false +python-versions = ">=3.7,<4.0" +groups = ["dev"] +files = [ + {file = "docspec-2.2.1-py3-none-any.whl", hash = "sha256:7538f750095a9688c6980ff9a4e029a823a500f64bd00b6b4bdb27951feb31cb"}, + {file = "docspec-2.2.1.tar.gz", hash = "sha256:4854e77edc0e2de40e785e57e95880f7095a05fe978f8b54cef7a269586e15ff"}, +] + +[package.dependencies] +"databind.core" = ">=4.2.6,<5.0.0" +"databind.json" = ">=4.2.6,<5.0.0" +Deprecated = ">=1.2.12,<2.0.0" + +[[package]] +name = "docspec-python" +version = "2.2.2" +description = "A parser based on lib2to3 producing docspec data from Python source code." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "docspec_python-2.2.2-py3-none-any.whl", hash = "sha256:caa32dc1e8c470af8a5ecad67cca614e68c1563ac01dab0c0486c4d7f709d6b1"}, + {file = "docspec_python-2.2.2.tar.gz", hash = "sha256:429be834d09549461b95bf45eb53c16859f3dfb3e9220408b3bfb12812ccb3fb"}, +] + +[package.dependencies] +black = ">=24.8.0" +docspec = "2.2.1" +nr-util = ">=0.8.12" + +[[package]] +name = "docstring-parser" +version = "0.11" +description = "\"Parse Python docstrings in reST, Google and Numpydoc format\"" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb"}, +] + +[package.extras] +test = ["black", "pytest"] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "execnet" +version = "2.1.1" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, + {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + +[[package]] +name = "genson" +version = "1.3.0" +description = "GenSON is a powerful, user-friendly JSON Schema generator." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7"}, + {file = "genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37"}, +] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version == \"3.9\"" +files = [ + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "inflect" +version = "7.5.0" +description = "Correctly generate plurals, singular nouns, ordinals, indefinite articles" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344"}, + {file = "inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f"}, +] + +[package.dependencies] +more_itertools = ">=8.5.0" +typeguard = ">=4.0.1" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["pygments", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "isort" +version = "6.1.0" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784"}, + {file = "isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.6.0", markers = "python_version < \"3.10\""} + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, + {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nr-date" +version = "2.1.0" +description = "" +optional = false +python-versions = ">=3.6,<4.0" +groups = ["dev"] +files = [ + {file = "nr_date-2.1.0-py3-none-any.whl", hash = "sha256:bd672a9dfbdcf7c4b9289fea6750c42490eaee08036a72059dcc78cb236ed568"}, + {file = "nr_date-2.1.0.tar.gz", hash = "sha256:0643aea13bcdc2a8bc56af9d5e6a89ef244c9744a1ef00cdc735902ba7f7d2e6"}, +] + +[[package]] +name = "nr-stream" +version = "1.1.5" +description = "" +optional = false +python-versions = ">=3.6,<4.0" +groups = ["dev"] +files = [ + {file = "nr_stream-1.1.5-py3-none-any.whl", hash = "sha256:47e12150b331ad2cb729cfd9d2abd281c9949809729ba461c6aa87dd9927b2d4"}, + {file = "nr_stream-1.1.5.tar.gz", hash = "sha256:eb0216c6bfc61a46d4568dba3b588502c610ec8ddef4ac98f3932a2bd7264f65"}, +] + +[[package]] +name = "nr-util" +version = "0.8.12" +description = "General purpose Python utility library." +optional = false +python-versions = ">=3.7,<4.0" +groups = ["dev"] +files = [ + {file = "nr.util-0.8.12-py3-none-any.whl", hash = "sha256:91da02ac9795eb8e015372275c1efe54bac9051231ee9b0e7e6f96b0b4e7d2bb"}, + {file = "nr.util-0.8.12.tar.gz", hash = "sha256:a4549c2033d99d2f0379b3f3d233fd2a8ade286bbf0b3ad0cc7cea16022214f4"}, +] + +[package.dependencies] +deprecated = ">=1.2.0,<2.0.0" +typing-extensions = ">=3.0.0" + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, + {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "protobuf" +version = "6.33.0" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "protobuf-6.33.0-cp310-abi3-win32.whl", hash = "sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035"}, + {file = "protobuf-6.33.0-cp310-abi3-win_amd64.whl", hash = "sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee"}, + {file = "protobuf-6.33.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455"}, + {file = "protobuf-6.33.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90"}, + {file = "protobuf-6.33.0-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298"}, + {file = "protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef"}, + {file = "protobuf-6.33.0-cp39-cp39-win32.whl", hash = "sha256:cd33a8e38ea3e39df66e1bbc462b076d6e5ba3a4ebbde58219d777223a7873d3"}, + {file = "protobuf-6.33.0-cp39-cp39-win_amd64.whl", hash = "sha256:c963e86c3655af3a917962c9619e1a6b9670540351d7af9439d06064e3317cc9"}, + {file = "protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995"}, + {file = "protobuf-6.33.0.tar.gz", hash = "sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954"}, +] + +[[package]] +name = "pydantic" +version = "2.12.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pydantic-2.12.2-py3-none-any.whl", hash = "sha256:25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae"}, + {file = "pydantic-2.12.2.tar.gz", hash = "sha256:7b8fa15b831a4bbde9d5b84028641ac3080a4ca2cbd4a621a661687e741624fd"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e"}, + {file = "pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9"}, + {file = "pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57"}, + {file = "pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc"}, + {file = "pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80"}, + {file = "pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db"}, + {file = "pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887"}, + {file = "pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8"}, + {file = "pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746"}, + {file = "pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89"}, + {file = "pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1"}, + {file = "pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0"}, + {file = "pydantic_core-2.41.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062"}, + {file = "pydantic_core-2.41.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8"}, + {file = "pydantic_core-2.41.4-cp39-cp39-win32.whl", hash = "sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb"}, + {file = "pydantic_core-2.41.4-cp39-cp39-win_amd64.whl", hash = "sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f"}, + {file = "pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pydoc-markdown" +version = "4.8.2" +description = "Create Python API documentation in Markdown format." +optional = false +python-versions = ">=3.7,<4.0" +groups = ["dev"] +files = [ + {file = "pydoc_markdown-4.8.2-py3-none-any.whl", hash = "sha256:203f74119e6bb2f9deba43d452422de7c8ec31955b61e0620fa4dd8c2611715f"}, + {file = "pydoc_markdown-4.8.2.tar.gz", hash = "sha256:fb6c927e31386de17472d42f9bd3d3be2905977d026f6216881c65145aa67f0b"}, +] + +[package.dependencies] +click = ">=7.1,<9.0" +"databind.core" = ">=4.4.0,<5.0.0" +"databind.json" = ">=4.4.0,<5.0.0" +docspec = ">=2.2.1,<3.0.0" +docspec-python = ">=2.2.1,<3.0.0" +docstring-parser = ">=0.11,<0.12" +jinja2 = ">=3.0.0,<4.0.0" +"nr.util" = ">=0.7.5,<1.0.0" +PyYAML = ">=5.0,<7.0" +requests = ">=2.23.0,<3.0.0" +tomli = ">=2.0.0,<3.0.0" +tomli_w = ">=1.0.0,<2.0.0" +watchdog = "*" +yapf = ">=0.30.0" + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-dotenv" +version = "0.5.2" +description = "A py.test plugin that parses environment files before running tests" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732"}, + {file = "pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f"}, +] + +[package.dependencies] +pytest = ">=5.0.0" +python-dotenv = ">=0.9.1" + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, + {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, + {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.1.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pytokens" +version = "0.2.0" +description = "A Fast, spec compliant Python 3.13+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8"}, + {file = "pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "14.2.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "ruff" +version = "0.11.13" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46"}, + {file = "ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48"}, + {file = "ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b"}, + {file = "ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a"}, + {file = "ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc"}, + {file = "ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629"}, + {file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933"}, + {file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165"}, + {file = "ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71"}, + {file = "ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9"}, + {file = "ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc"}, + {file = "ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7"}, + {file = "ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432"}, + {file = "ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492"}, + {file = "ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250"}, + {file = "ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3"}, + {file = "ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b"}, + {file = "ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514"}, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version == \"3.9\"" +files = [ + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +description = "A lil' TOML writer" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, + {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, +] + +[[package]] +name = "typeapi" +version = "2.2.4" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "typeapi-2.2.4-py3-none-any.whl", hash = "sha256:bd6d5e5907fa47e0303bf254e7cc8712d4be4eb26d7ffaedb67c9e7844c53bb8"}, + {file = "typeapi-2.2.4.tar.gz", hash = "sha256:daa80767520c0957a320577e4f729c0ba6921c708def31f4c6fd8d611908fd7b"}, +] + +[package.dependencies] +typing-extensions = ">=3.0.0" + +[[package]] +name = "typeguard" +version = "4.4.4" +description = "Run-time type checker for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e"}, + {file = "typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74"}, +] + +[package.dependencies] +importlib_metadata = {version = ">=3.6", markers = "python_version < \"3.10\""} +typing_extensions = ">=4.14.0" + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "urllib3" +version = "2.6.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, + {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "watchdog" +version = "6.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, + {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, + {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, + {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, + {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "wcmatch" +version = "10.1" +description = "Wildcard/glob file name matcher." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a"}, + {file = "wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af"}, +] + +[package.dependencies] +bracex = ">=2.1.1" + +[[package]] +name = "wrapt" +version = "1.17.3" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, + {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, + {file = "wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c"}, + {file = "wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775"}, + {file = "wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd"}, + {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05"}, + {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418"}, + {file = "wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390"}, + {file = "wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6"}, + {file = "wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f"}, + {file = "wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311"}, + {file = "wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1"}, + {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5"}, + {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2"}, + {file = "wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89"}, + {file = "wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77"}, + {file = "wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd"}, + {file = "wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828"}, + {file = "wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9"}, + {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396"}, + {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc"}, + {file = "wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe"}, + {file = "wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c"}, + {file = "wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7"}, + {file = "wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277"}, + {file = "wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d"}, + {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa"}, + {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050"}, + {file = "wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8"}, + {file = "wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb"}, + {file = "wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c"}, + {file = "wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b"}, + {file = "wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa"}, + {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7"}, + {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4"}, + {file = "wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10"}, + {file = "wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6"}, + {file = "wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454"}, + {file = "wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e"}, + {file = "wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f"}, + {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056"}, + {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804"}, + {file = "wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977"}, + {file = "wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116"}, + {file = "wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6"}, + {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:70d86fa5197b8947a2fa70260b48e400bf2ccacdcab97bb7de47e3d1e6312225"}, + {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df7d30371a2accfe4013e90445f6388c570f103d61019b6b7c57e0265250072a"}, + {file = "wrapt-1.17.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:caea3e9c79d5f0d2c6d9ab96111601797ea5da8e6d0723f77eabb0d4068d2b2f"}, + {file = "wrapt-1.17.3-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:758895b01d546812d1f42204bd443b8c433c44d090248bf22689df673ccafe00"}, + {file = "wrapt-1.17.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56"}, + {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:656873859b3b50eeebe6db8b1455e99d90c26ab058db8e427046dbc35c3140a5"}, + {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a9a2203361a6e6404f80b99234fe7fb37d1fc73487b5a78dc1aa5b97201e0f22"}, + {file = "wrapt-1.17.3-cp38-cp38-win32.whl", hash = "sha256:55cbbc356c2842f39bcc553cf695932e8b30e30e797f961860afb308e6b1bb7c"}, + {file = "wrapt-1.17.3-cp38-cp38-win_amd64.whl", hash = "sha256:ad85e269fe54d506b240d2d7b9f5f2057c2aa9a2ea5b32c66f8902f768117ed2"}, + {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc"}, + {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9"}, + {file = "wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d"}, + {file = "wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a"}, + {file = "wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139"}, + {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df"}, + {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b"}, + {file = "wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81"}, + {file = "wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f"}, + {file = "wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f"}, + {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, + {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, +] + +[[package]] +name = "yapf" +version = "0.43.0" +description = "A formatter for Python code" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca"}, + {file = "yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e"}, +] + +[package.dependencies] +platformdirs = ">=3.5.1" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version == \"3.9\"" +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.9" +content-hash = "3ea6aa999964f818bac76ba9932381e0ba66b5d7baa0b83969a6859ce195b67b" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a2ad82a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[tool.poetry] +name = "ucloud_sandbox" +version = "1.0.0" +description = "UCloud Sandbox SDK - Cloud sandbox environments for AI agents" +authors = ["UCloud "] +license = "MIT" +readme = "README.md" +homepage = "https://ucloud.cn/" +repository = "https://github.com/ucloud/ucloud-sandbox-sdk" +packages = [{ include = "ucloud_sandbox" }, { include = "e2b_connect" }] + +[tool.poetry.dependencies] +python = "^3.9" +python-dateutil = ">=2.8.2" +wcmatch = "^10.1" +protobuf = ">=4.21.0" +httpcore = "^1.0.5" +httpx = ">=0.27.0, <1.0.0" +attrs = ">=23.2.0" +packaging = ">=24.1" +typing-extensions = ">=4.1.0" +dockerfile-parse = "^2.0.1" +rich = ">=14.0.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.4.0" +pytest-xdist = "^3.3.1" +python-dotenv = "^1.0.0" +pytest-dotenv = "^0.5.2" +pytest-asyncio = "^0.23.7" +pydoc-markdown = "^4.8.2" +datamodel-code-generator = "^0.34.0" +ruff = "^0.11.12" +pytest-timeout = "^2.4.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.urls] +"Bug Tracker" = "https://github.com/ucloud/ucloud-sandbox-sdk/issues" + +[tool.ruff] +exclude = [ + "ucloud_sandbox/envd/filesystem/filesystem_pb2.py" +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..ec8e70d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +# content of pytest.ini +[pytest] +markers = + skip_debug: skip test if E2B_DEBUG is set. + +asyncio_mode=auto +addopts = "--import-mode=importlib" +timeout = 300 diff --git a/scripts/fix-python-pb.sh b/scripts/fix-python-pb.sh new file mode 100755 index 0000000..7bdea38 --- /dev/null +++ b/scripts/fix-python-pb.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +rm -rf e2b/envd/__pycache__ +rm -rf e2b/envd/filesystem/__pycache__ +rm -rf e2b/envd/process/__pycache__ + +sed -i.bak 's/from\ process\ import/from e2b.envd.process import/g' e2b/envd/process/* e2b/envd/filesystem/* +sed -i.bak 's/from\ filesystem\ import/from e2b.envd.filesystem import/g' e2b/envd/process/* e2b/envd/filesystem/* + +rm -f e2b/envd/process/*.bak +rm -f e2b/envd/filesystem/*.bak diff --git a/scripts/generate_sdk_ref.sh b/scripts/generate_sdk_ref.sh new file mode 100755 index 0000000..76d0521 --- /dev/null +++ b/scripts/generate_sdk_ref.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This script generates the python SDK reference markdown files +# Run it in the `python-sdk/` directory + +PKG_VERSION="v$(node -p "require('./package.json').version")" + +packages=("sandbox_sync" "sandbox_async" "exceptions" "template" "template_sync" "template_async") +template_submodules=("logger" "readycmd") + +mkdir -p ../../apps/web/src/app/\(docs\)/docs/sdk-reference/python-sdk/${PKG_VERSION} + +mkdir -p sdk_ref + +# Function to process generated markdown files +process_mdx() { + local file=$1 + # remove package path display + sed -i'' -e '/]*>.*<\/a>/d' "${file}" + # remove empty hyperlinks + sed -i'' -e '/^# /d' "${file}" + # remove " Objects" from lines starting with "##" + sed -i'' -e '/^## / s/ Objects$//' "${file}" + # replace lines starting with "####" with "###" + sed -i'' -e 's/^####/###/' "${file}" +} + +for package in "${packages[@]}"; do + # generate raw SDK reference markdown file + poetry run pydoc-markdown -p e2b."${package}" >sdk_ref/"${package}".mdx + # process the generated markdown + process_mdx "sdk_ref/${package}.mdx" + # move to docs + mkdir -p "../../apps/web/src/app/(docs)/docs/sdk-reference/python-sdk/${PKG_VERSION}/${package}" + mv "sdk_ref/${package}.mdx" "../../apps/web/src/app/(docs)/docs/sdk-reference/python-sdk/${PKG_VERSION}/${package}/page.mdx" +done + +# Generate documentation for template submodules and place them under both template_sync and template_async +for submodule in "${template_submodules[@]}"; do + # generate raw SDK reference markdown file + poetry run pydoc-markdown -p e2b.template."${submodule}" >sdk_ref/"${submodule}".mdx + # process the generated markdown + process_mdx "sdk_ref/${submodule}.mdx" + + # Copy to template_sync + mkdir -p "../../apps/web/src/app/(docs)/docs/sdk-reference/python-sdk/${PKG_VERSION}/template_sync/${submodule}" + cp "sdk_ref/${submodule}.mdx" "../../apps/web/src/app/(docs)/docs/sdk-reference/python-sdk/${PKG_VERSION}/template_sync/${submodule}/page.mdx" + + # Copy to template_async + mkdir -p "../../apps/web/src/app/(docs)/docs/sdk-reference/python-sdk/${PKG_VERSION}/template_async/${submodule}" + mv "sdk_ref/${submodule}.mdx" "../../apps/web/src/app/(docs)/docs/sdk-reference/python-sdk/${PKG_VERSION}/template_async/${submodule}/page.mdx" +done + +rm -rf sdk_ref diff --git a/tests/async/api_async/test_sbx_info.py b/tests/async/api_async/test_sbx_info.py new file mode 100644 index 0000000..878a582 --- /dev/null +++ b/tests/async/api_async/test_sbx_info.py @@ -0,0 +1,9 @@ +import pytest + +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_get_info(async_sandbox: AsyncSandbox): + info = await AsyncSandbox.get_info(async_sandbox.sandbox_id) + assert info.sandbox_id == async_sandbox.sandbox_id diff --git a/tests/async/api_async/test_sbx_kill.py b/tests/async/api_async/test_sbx_kill.py new file mode 100644 index 0000000..ace0def --- /dev/null +++ b/tests/async/api_async/test_sbx_kill.py @@ -0,0 +1,21 @@ +import pytest + +from e2b import AsyncSandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +async def test_kill_existing_sandbox(async_sandbox: AsyncSandbox, sandbox_test_id: str): + assert await AsyncSandbox.kill(async_sandbox.sandbox_id) + + paginator = AsyncSandbox.list( + query=SandboxQuery( + state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id} + ) + ) + sandboxes = await paginator.next_items() + assert async_sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] + + +@pytest.mark.skip_debug() +async def test_kill_non_existing_sandbox(): + assert not await AsyncSandbox.kill("non-existing-sandbox") diff --git a/tests/async/api_async/test_sbx_list.py b/tests/async/api_async/test_sbx_list.py new file mode 100644 index 0000000..cc19fd1 --- /dev/null +++ b/tests/async/api_async/test_sbx_list.py @@ -0,0 +1,194 @@ +import time + +import pytest + +from e2b import AsyncSandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +async def test_list_sandboxes(async_sandbox: AsyncSandbox, sandbox_test_id: str): + paginator = AsyncSandbox.list( + query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id}) + ) + sandboxes = await paginator.next_items() + assert len(sandboxes) >= 1 + assert async_sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes] + + +@pytest.mark.skip_debug() +async def test_list_sandboxes_with_filter(sandbox_test_id: str, async_sandbox_factory): + unique_id = str(int(time.time())) + extra_sbx = await async_sandbox_factory(metadata={"unique_id": unique_id}) + + paginator = AsyncSandbox.list(query=SandboxQuery(metadata={"unique_id": unique_id})) + sandboxes = await paginator.next_items() + assert len(sandboxes) == 1 + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + + +@pytest.mark.skip_debug() +async def test_list_running_sandboxes( + async_sandbox: AsyncSandbox, sandbox_test_id: str +): + paginator = AsyncSandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.RUNNING] + ) + ) + sandboxes = await paginator.next_items() + assert len(sandboxes) >= 1 + + # Verify our running sandbox is in the list + assert any( + s.sandbox_id == async_sandbox.sandbox_id and s.state == SandboxState.RUNNING + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +async def test_list_paused_sandboxes(async_sandbox: AsyncSandbox, sandbox_test_id: str): + await async_sandbox.beta_pause() + + paginator = AsyncSandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.PAUSED] + ) + ) + sandboxes = await paginator.next_items() + assert len(sandboxes) >= 1 + + # Verify our paused sandbox is in the list + paused_sandbox_id = async_sandbox.sandbox_id.split("-")[0] + assert any( + s.sandbox_id.startswith(paused_sandbox_id) and s.state == SandboxState.PAUSED + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +async def test_paginate_running_sandboxes(sandbox_test_id: str, async_sandbox_factory): + sbx1 = await async_sandbox_factory() + sbx2 = await async_sandbox_factory() + + # Test pagination with limit + paginator = AsyncSandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, + state=[SandboxState.RUNNING], + ), + limit=1, + ) + sandboxes = await paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.RUNNING + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id == sbx2.sandbox_id + + # Get second page + sandboxes2 = await paginator.next_items() + + # Check second page + assert len(sandboxes2) == 1 + assert sandboxes2[0].state == SandboxState.RUNNING + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes2[0].sandbox_id == sbx1.sandbox_id + + +@pytest.mark.skip_debug() +async def test_paginate_paused_sandboxes( + async_sandbox: AsyncSandbox, sandbox_test_id: str, async_sandbox_factory +): + sandbox_id = async_sandbox.sandbox_id.split("-")[0] + await async_sandbox.beta_pause() + + # create another paused sandbox + extra_sbx = await async_sandbox_factory( + metadata={"sandbox_test_id": sandbox_test_id} + ) + extra_sbx_id = extra_sbx.sandbox_id.split("-")[0] + await extra_sbx.beta_pause() + + # Test pagination with limit + paginator = AsyncSandbox.list( + query=SandboxQuery( + state=[SandboxState.PAUSED], + metadata={"sandbox_test_id": sandbox_test_id}, + ), + limit=1, + ) + sandboxes = await paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id.startswith(extra_sbx_id) is True + + # Get second page + sandboxes2 = await paginator.next_items() + + # Check second page + assert len(sandboxes2) == 1 + assert sandboxes2[0].state == SandboxState.PAUSED + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes2[0].sandbox_id.startswith(sandbox_id) is True + + +@pytest.mark.skip_debug() +async def test_paginate_running_and_paused_sandboxes( + async_sandbox: AsyncSandbox, sandbox_test_id: str, async_sandbox_factory +): + # Create extra paused sandbox + extra_sbx = await async_sandbox_factory( + metadata={"sandbox_test_id": sandbox_test_id} + ) + extra_sbx_id = extra_sbx.sandbox_id.split("-")[0] + await extra_sbx.beta_pause() + + # Test pagination with limit + paginator = AsyncSandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, + state=[SandboxState.RUNNING, SandboxState.PAUSED], + ), + limit=1, + ) + sandboxes = await paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id.startswith(extra_sbx_id) is True + + # Get second page + sandboxes2 = await paginator.next_items() + + # Check second page + assert len(sandboxes2) == 1 + assert sandboxes2[0].state == SandboxState.RUNNING + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes2[0].sandbox_id == async_sandbox.sandbox_id + + +@pytest.mark.skip_debug() +async def test_paginate_iterator(async_sandbox: AsyncSandbox, sandbox_test_id: str): + paginator = AsyncSandbox.list( + query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id}) + ) + sandboxes_list = [] + + while paginator.has_next: + sandboxes = await paginator.next_items() + sandboxes_list.extend(sandboxes) + + assert len(sandboxes_list) > 0 + assert async_sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes_list] diff --git a/tests/async/api_async/test_sbx_snapshot.py b/tests/async/api_async/test_sbx_snapshot.py new file mode 100644 index 0000000..0fa2a34 --- /dev/null +++ b/tests/async/api_async/test_sbx_snapshot.py @@ -0,0 +1,19 @@ +import pytest +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_pause_sandbox(async_sandbox: AsyncSandbox): + await AsyncSandbox.beta_pause(async_sandbox.sandbox_id) + assert not await async_sandbox.is_running() + + +@pytest.mark.skip_debug() +async def test_resume_sandbox(async_sandbox: AsyncSandbox): + # pause + await AsyncSandbox.beta_pause(async_sandbox.sandbox_id) + assert not await async_sandbox.is_running() + + # resume + await AsyncSandbox.connect(async_sandbox.sandbox_id) + assert await async_sandbox.is_running() diff --git a/tests/async/sandbox_async/commands/test_cmd_connect.py b/tests/async/sandbox_async/commands/test_cmd_connect.py new file mode 100644 index 0000000..5b08350 --- /dev/null +++ b/tests/async/sandbox_async/commands/test_cmd_connect.py @@ -0,0 +1,18 @@ +import pytest + +from e2b import NotFoundException, AsyncSandbox + + +async def test_connect_to_process(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("sleep 10", background=True) + pid = cmd.pid + + process_info = await async_sandbox.commands.connect(pid) + assert process_info.pid == pid + + +async def test_connect_to_non_existing_process(async_sandbox: AsyncSandbox): + non_existing_pid = 999999 + + with pytest.raises(NotFoundException): + await async_sandbox.commands.connect(non_existing_pid) diff --git a/tests/async/sandbox_async/commands/test_cmd_kill.py b/tests/async/sandbox_async/commands/test_cmd_kill.py new file mode 100644 index 0000000..a92f9b5 --- /dev/null +++ b/tests/async/sandbox_async/commands/test_cmd_kill.py @@ -0,0 +1,19 @@ +import pytest + +from e2b import AsyncSandbox, CommandExitException + + +async def test_kill_process(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("sleep 10", background=True) + pid = cmd.pid + + await async_sandbox.commands.kill(pid) + + with pytest.raises(CommandExitException): + await async_sandbox.commands.run(f"kill -0 {pid}") + + +async def test_kill_non_existing_process(async_sandbox: AsyncSandbox): + non_existing_pid = 999999 + + assert not await async_sandbox.commands.kill(non_existing_pid) diff --git a/tests/async/sandbox_async/commands/test_cmd_list.py b/tests/async/sandbox_async/commands/test_cmd_list.py new file mode 100644 index 0000000..b7906a6 --- /dev/null +++ b/tests/async/sandbox_async/commands/test_cmd_list.py @@ -0,0 +1,13 @@ +from e2b import AsyncSandbox + + +async def test_kill_process(async_sandbox: AsyncSandbox): + c1 = await async_sandbox.commands.run("sleep 10", background=True) + c2 = await async_sandbox.commands.run("sleep 10", background=True) + + processes = await async_sandbox.commands.list() + + assert len(processes) >= 2 + pids = [p.pid for p in processes] + assert c1.pid in pids + assert c2.pid in pids diff --git a/tests/async/sandbox_async/commands/test_env_vars.py b/tests/async/sandbox_async/commands/test_env_vars.py new file mode 100644 index 0000000..9563cac --- /dev/null +++ b/tests/async/sandbox_async/commands/test_env_vars.py @@ -0,0 +1,35 @@ +import pytest + +from e2b import AsyncSandbox + + +async def test_command_envs(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("echo $FOO", envs={"FOO": "bar"}) + assert cmd.stdout.strip() == "bar" + + +@pytest.mark.skip_debug() +async def test_sandbox_envs(async_sandbox_factory): + sbx = await async_sandbox_factory(envs={"FOO": "bar"}) + + cmd = await sbx.commands.run("echo $FOO") + assert cmd.stdout.strip() == "bar" + + +async def test_bash_command_scoped_env_vars(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run("echo $FOO", envs={"FOO": "bar"}) + assert cmd.exit_code == 0 + assert cmd.stdout.strip() == "bar" + + # test that it is secure and not accessible to subsequent commands + cmd2 = await async_sandbox.commands.run('sudo echo "$FOO"') + assert cmd2.exit_code == 0 + assert cmd2.stdout.strip() == "" + + +async def test_python_command_scoped_env_vars(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run( + "python3 -c \"import os; print(os.environ['FOO'])\"", envs={"FOO": "bar"} + ) + assert cmd.exit_code == 0 + assert cmd.stdout.strip() == "bar" diff --git a/tests/async/sandbox_async/commands/test_run.py b/tests/async/sandbox_async/commands/test_run.py new file mode 100644 index 0000000..f871ebd --- /dev/null +++ b/tests/async/sandbox_async/commands/test_run.py @@ -0,0 +1,53 @@ +import pytest + +from e2b import AsyncSandbox, TimeoutException + + +async def test_run(async_sandbox: AsyncSandbox): + text = "Hello, World!" + + cmd = await async_sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +async def test_run_with_special_characters(async_sandbox: AsyncSandbox): + text = "!@#$%^&*()_+" + + cmd = await async_sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + + +# assert cmd.stdout == f"{text}\n" + + +async def test_run_with_broken_utf8(async_sandbox: AsyncSandbox): + # Create a string with 8191 'a' characters followed by the problematic byte 0xe2 + long_str = "a" * 8191 + "\\xe2" + result = await async_sandbox.commands.run(f'printf "{long_str}"') + assert result.exit_code == 0 + + # The broken UTF-8 bytes should be replaced with the Unicode replacement character + assert result.stdout == ("a" * 8191 + "\ufffd") + + +async def test_run_with_multiline_string(async_sandbox: AsyncSandbox): + text = "Hello,\nWorld!" + + cmd = await async_sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +async def test_run_with_timeout(async_sandbox: AsyncSandbox): + cmd = await async_sandbox.commands.run('echo "Hello, World!"', timeout=10) + + assert cmd.exit_code == 0 + + +async def test_run_with_too_short_timeout(async_sandbox: AsyncSandbox): + with pytest.raises(TimeoutException): + await async_sandbox.commands.run("sleep 10", timeout=2) diff --git a/tests/async/sandbox_async/commands/test_send_stdin.py b/tests/async/sandbox_async/commands/test_send_stdin.py new file mode 100644 index 0000000..4db1022 --- /dev/null +++ b/tests/async/sandbox_async/commands/test_send_stdin.py @@ -0,0 +1,51 @@ +import asyncio + +from e2b import AsyncSandbox + + +async def test_send_stdin_to_process(async_sandbox: AsyncSandbox): + ev = asyncio.Event() + + def handle_event(stdout: str): + ev.set() + + cmd = await async_sandbox.commands.run( + "cat", background=True, on_stdout=handle_event, stdin=True + ) + await async_sandbox.commands.send_stdin(cmd.pid, "Hello, World!") + + await ev.wait() + + assert cmd.stdout == "Hello, World!" + + +async def test_send_special_characters_to_process(async_sandbox: AsyncSandbox): + ev = asyncio.Event() + + def handle_event(stdout: str): + ev.set() + + cmd = await async_sandbox.commands.run( + "cat", background=True, on_stdout=handle_event, stdin=True + ) + await async_sandbox.commands.send_stdin(cmd.pid, "!@#$%^&*()_+") + + await ev.wait() + + assert cmd.stdout == "!@#$%^&*()_+" + + +async def test_send_multiline_string_to_process(async_sandbox: AsyncSandbox): + ev = asyncio.Event() + + def handle_event(stdout: str): + ev.set() + + cmd = await async_sandbox.commands.run( + "cat", background=True, on_stdout=handle_event, stdin=True + ) + await async_sandbox.commands.send_stdin(cmd.pid, "Hello,\nWorld!") + + await ev.wait() + + assert cmd.stdout == "Hello,\nWorld!" diff --git a/tests/async/sandbox_async/files/test_exists.py b/tests/async/sandbox_async/files/test_exists.py new file mode 100644 index 0000000..203dd19 --- /dev/null +++ b/tests/async/sandbox_async/files/test_exists.py @@ -0,0 +1,8 @@ +from e2b import AsyncSandbox + + +async def test_exists(async_sandbox: AsyncSandbox): + filename = "test_exists.txt" + + await async_sandbox.files.write(filename, "test") + assert await async_sandbox.files.exists(filename) diff --git a/tests/async/sandbox_async/files/test_files_list.py b/tests/async/sandbox_async/files/test_files_list.py new file mode 100644 index 0000000..bc876b1 --- /dev/null +++ b/tests/async/sandbox_async/files/test_files_list.py @@ -0,0 +1,247 @@ +import uuid + +from e2b import AsyncSandbox, FileType + + +async def test_list_directory(async_sandbox: AsyncSandbox): + home_dir_name = "/home/user" + parent_dir_name = f"test_directory_{uuid.uuid4()}" + + await async_sandbox.files.make_dir(parent_dir_name) + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir1") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir2") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_1") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_2") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_1") + await async_sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_2") + await async_sandbox.files.write(f"{parent_dir_name}/file1.txt", "Hello, world!") + + test_cases = [ + { + "name": "default depth (1)", + "depth": None, + "expected_len": 3, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir2", + ], + }, + { + "name": "explicit depth 1", + "depth": 1, + "expected_len": 3, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir2", + ], + }, + { + "name": "explicit depth 2", + "depth": 2, + "expected_len": 7, + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + ], + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir1_1", + "subdir1_2", + "subdir2", + "subdir2_1", + "subdir2_2", + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2", + f"{home_dir_name}/{parent_dir_name}/subdir2", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2", + ], + }, + { + "name": "explicit depth 3 (should be the same as depth 2)", + "depth": 3, + "expected_len": 7, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir1_1", + "subdir1_2", + "subdir2", + "subdir2_1", + "subdir2_2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2", + f"{home_dir_name}/{parent_dir_name}/subdir2", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2", + ], + }, + ] + + for test_case in test_cases: + files = await async_sandbox.files.list( + parent_dir_name, + depth=test_case["depth"] if test_case["depth"] is not None else None, + ) + + assert len(files) == test_case["expected_len"] + + for i in range(len(test_case["expected_file_names"])): + assert files[i].name == test_case["expected_file_names"][i] + assert files[i].path == test_case["expected_file_paths"][i] + assert files[i].type == test_case["expected_file_types"][i] + + await async_sandbox.files.remove(parent_dir_name) + + +async def test_list_directory_error_cases(async_sandbox: AsyncSandbox): + parent_dir_name = f"test_directory_{uuid.uuid4()}" + await async_sandbox.files.make_dir(parent_dir_name) + + expected_error_message = "depth should be at least 1" + try: + await async_sandbox.files.list(parent_dir_name, depth=-1) + assert False, "Expected error but none was thrown" + except Exception as err: + assert expected_error_message in str(err), ( + f'expected error message to include "{expected_error_message}"' + ) + + await async_sandbox.files.remove(parent_dir_name) + + +async def test_file_entry_details(async_sandbox: AsyncSandbox): + test_dir = "test-file-entry" + file_path = f"{test_dir}/test.txt" + content = "Hello, World!" + + await async_sandbox.files.make_dir(test_dir) + await async_sandbox.files.write(file_path, content) + + files = await async_sandbox.files.list(test_dir, depth=1) + assert len(files) == 1 + + file_entry = files[0] + assert file_entry.name == "test.txt" + assert file_entry.path == f"/home/user/{file_path}" + assert file_entry.type == FileType.FILE + assert file_entry.mode == 0o644 + assert file_entry.permissions == "-rw-r--r--" + assert file_entry.owner == "user" + assert file_entry.group == "user" + assert file_entry.size == len(content) + assert file_entry.modified_time is not None + assert file_entry.symlink_target is None + + await async_sandbox.files.remove(test_dir) + + +async def test_directory_entry_details(async_sandbox: AsyncSandbox): + test_dir = "test-entry-info" + sub_dir = f"{test_dir}/subdir" + + await async_sandbox.files.make_dir(test_dir) + await async_sandbox.files.make_dir(sub_dir) + + files = await async_sandbox.files.list(test_dir, depth=1) + assert len(files) == 1 + + dir_entry = files[0] + assert dir_entry.name == "subdir" + assert dir_entry.path == f"/home/user/{sub_dir}" + assert dir_entry.type == FileType.DIR + assert dir_entry.mode == 0o755 + assert dir_entry.permissions == "drwxr-xr-x" + assert dir_entry.owner == "user" + assert dir_entry.group == "user" + assert dir_entry.modified_time is not None + assert dir_entry.symlink_target is None + + await async_sandbox.files.remove(test_dir) + + +async def test_mixed_entries(async_sandbox: AsyncSandbox): + test_dir = "test-mixed-entries" + sub_dir = f"{test_dir}/subdir" + file_path = f"{test_dir}/test.txt" + content = "Hello, World!" + + await async_sandbox.files.make_dir(test_dir) + await async_sandbox.files.make_dir(sub_dir) + await async_sandbox.files.write(file_path, content) + + files = await async_sandbox.files.list(test_dir, depth=1) + assert len(files) == 2 + + # Create a dictionary of entries by name for easier verification + entries = {entry.name: entry for entry in files} + + # Verify directory entry + dir_entry = entries.get("subdir") + assert dir_entry is not None + assert dir_entry.path == f"/home/user/{sub_dir}" + assert dir_entry.type == FileType.DIR + assert dir_entry.mode == 0o755 + assert dir_entry.permissions == "drwxr-xr-x" + assert dir_entry.owner == "user" + assert dir_entry.group == "user" + assert dir_entry.modified_time is not None + + # Verify file entry + file_entry = entries.get("test.txt") + assert file_entry is not None + assert file_entry.path == f"/home/user/{file_path}" + assert file_entry.type == FileType.FILE + assert file_entry.mode == 0o644 + assert file_entry.permissions == "-rw-r--r--" + assert file_entry.owner == "user" + assert file_entry.group == "user" + assert file_entry.size == len(content) + assert file_entry.modified_time is not None + + await async_sandbox.files.remove(test_dir) diff --git a/tests/async/sandbox_async/files/test_info.py b/tests/async/sandbox_async/files/test_info.py new file mode 100644 index 0000000..383643d --- /dev/null +++ b/tests/async/sandbox_async/files/test_info.py @@ -0,0 +1,77 @@ +import pytest +from e2b.exceptions import NotFoundException +from e2b import AsyncSandbox, FileType + + +@pytest.mark.asyncio +async def test_get_info_of_file(async_sandbox: AsyncSandbox): + filename = "test_file.txt" + + await async_sandbox.files.write(filename, "test") + info = await async_sandbox.files.get_info(filename) + current_path = await async_sandbox.commands.run("pwd") + + assert info.name == filename + assert info.type == FileType.FILE + assert info.path == f"{current_path.stdout.strip()}/{filename}" + assert info.size == 4 + assert info.mode == 0o644 + assert info.permissions == "-rw-r--r--" + assert info.owner == "user" + assert info.group == "user" + assert info.modified_time is not None + + +@pytest.mark.asyncio +async def test_get_info_of_nonexistent_file(async_sandbox: AsyncSandbox): + filename = "test_does_not_exist.txt" + + with pytest.raises(NotFoundException): + await async_sandbox.files.get_info(filename) + + +@pytest.mark.asyncio +async def test_get_info_of_directory(async_sandbox: AsyncSandbox): + dirname = "test_dir" + + await async_sandbox.files.make_dir(dirname) + info = await async_sandbox.files.get_info(dirname) + current_path = await async_sandbox.commands.run("pwd") + + assert info.name == dirname + assert info.type == FileType.DIR + assert info.path == f"{current_path.stdout.strip()}/{dirname}" + assert info.size > 0 + assert info.mode == 0o755 + assert info.permissions == "drwxr-xr-x" + assert info.owner == "user" + assert info.group == "user" + assert info.modified_time is not None + + +@pytest.mark.asyncio +async def test_get_info_of_nonexistent_directory(async_sandbox: AsyncSandbox): + dirname = "test_does_not_exist_dir" + + with pytest.raises(NotFoundException): + await async_sandbox.files.get_info(dirname) + + +async def test_file_symlink(async_sandbox: AsyncSandbox): + test_dir = "test-simlink-entry" + file_name = "test.txt" + content = "Hello, World!" + + await async_sandbox.files.make_dir(test_dir) + await async_sandbox.files.write(f"{test_dir}/{file_name}", content) + + symlink_name = "symlink_to_test.txt" + await async_sandbox.commands.run(f"ln -s {file_name} {symlink_name}", cwd=test_dir) + + file = await async_sandbox.files.get_info(f"{test_dir}/{symlink_name}") + + pwd = await async_sandbox.commands.run("pwd") + assert file.type == FileType.FILE + assert file.symlink_target == f"{pwd.stdout.strip()}/{test_dir}/{file_name}" + + await async_sandbox.files.remove(test_dir) diff --git a/tests/async/sandbox_async/files/test_make_dir.py b/tests/async/sandbox_async/files/test_make_dir.py new file mode 100644 index 0000000..d5c7230 --- /dev/null +++ b/tests/async/sandbox_async/files/test_make_dir.py @@ -0,0 +1,29 @@ +import uuid + +from e2b import AsyncSandbox + + +async def test_make_directory(async_sandbox: AsyncSandbox): + dir_name = f"test_directory_{uuid.uuid4()}" + + await async_sandbox.files.make_dir(dir_name) + exists = await async_sandbox.files.exists(dir_name) + assert exists + + +async def test_make_directory_already_exists(async_sandbox: AsyncSandbox): + dir_name = f"test_directory_{uuid.uuid4()}" + + created = await async_sandbox.files.make_dir(dir_name) + assert created + + created = await async_sandbox.files.make_dir(dir_name) + assert not created + + +async def test_make_nested_directory(async_sandbox: AsyncSandbox): + nested_dir_name = f"test_directory_{uuid.uuid4()}/nested_directory" + + await async_sandbox.files.make_dir(nested_dir_name) + exists = await async_sandbox.files.exists(nested_dir_name) + assert exists diff --git a/tests/async/sandbox_async/files/test_read.py b/tests/async/sandbox_async/files/test_read.py new file mode 100644 index 0000000..5aec6b8 --- /dev/null +++ b/tests/async/sandbox_async/files/test_read.py @@ -0,0 +1,28 @@ +import pytest + +from e2b import NotFoundException, AsyncSandbox + + +async def test_read_file(async_sandbox: AsyncSandbox): + filename = "test_read.txt" + content = "Hello, world!" + + await async_sandbox.files.write(filename, content) + read_content = await async_sandbox.files.read(filename) + assert read_content == content + + +async def test_read_non_existing_file(async_sandbox: AsyncSandbox): + filename = "non_existing_file.txt" + + with pytest.raises(NotFoundException): + await async_sandbox.files.read(filename) + + +async def test_read_empty_file(async_sandbox: AsyncSandbox): + filename = "empty_file.txt" + content = "" + + await async_sandbox.commands.run(f"touch {filename}") + read_content = await async_sandbox.files.read(filename) + assert read_content == content diff --git a/tests/async/sandbox_async/files/test_remove.py b/tests/async/sandbox_async/files/test_remove.py new file mode 100644 index 0000000..07cf2cf --- /dev/null +++ b/tests/async/sandbox_async/files/test_remove.py @@ -0,0 +1,18 @@ +from e2b import AsyncSandbox + + +async def test_remove_file(async_sandbox: AsyncSandbox): + filename = "test_remove.txt" + content = "This file will be removed." + + await async_sandbox.files.write(filename, content) + + await async_sandbox.files.remove(filename) + + exists = await async_sandbox.files.exists(filename) + assert not exists + + +async def test_remove_non_existing_file(async_sandbox: AsyncSandbox): + filename = "non_existing_file.txt" + await async_sandbox.files.remove(filename) diff --git a/tests/async/sandbox_async/files/test_rename.py b/tests/async/sandbox_async/files/test_rename.py new file mode 100644 index 0000000..6238cf6 --- /dev/null +++ b/tests/async/sandbox_async/files/test_rename.py @@ -0,0 +1,29 @@ +import pytest + +from e2b import NotFoundException, AsyncSandbox + + +async def test_rename_file(async_sandbox: AsyncSandbox): + old_filename = "test_rename_old.txt" + new_filename = "test_rename_new.txt" + content = "This file will be renamed." + + await async_sandbox.files.write(old_filename, content) + info = await async_sandbox.files.rename(old_filename, new_filename) + assert info.path == f"/home/user/{new_filename}" + + exists_old = await async_sandbox.files.exists(old_filename) + exists_new = await async_sandbox.files.exists(new_filename) + assert not exists_old + assert exists_new + + read_content = await async_sandbox.files.read(new_filename) + assert read_content == content + + +async def test_rename_non_existing_file(async_sandbox: AsyncSandbox): + old_filename = "non_existing_file.txt" + new_filename = "new_non_existing_file.txt" + + with pytest.raises(NotFoundException): + await async_sandbox.files.rename(old_filename, new_filename) diff --git a/tests/async/sandbox_async/files/test_secured.py b/tests/async/sandbox_async/files/test_secured.py new file mode 100644 index 0000000..2f904d0 --- /dev/null +++ b/tests/async/sandbox_async/files/test_secured.py @@ -0,0 +1,60 @@ +import urllib.request +import urllib.error +import json +import pytest + +from e2b.sandbox_async.main import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_download_url_with_signing(async_sandbox: AsyncSandbox): + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + await async_sandbox.files.write(file_path, file_content) + signed_url = async_sandbox.download_url(file_path, "user") + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + + +@pytest.mark.skip_debug() +async def test_download_url_with_signing_and_expiration(async_sandbox: AsyncSandbox): + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + await async_sandbox.files.write(file_path, file_content) + signed_url = async_sandbox.download_url(file_path, "user", 120) + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + + +@pytest.mark.skip_debug() +async def test_download_url_with_expired_signing(async_sandbox: AsyncSandbox): + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + await async_sandbox.files.write(file_path, file_content) + + signed_url = async_sandbox.download_url( + file_path, "user", use_signature_expiration=-120 + ) + + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(signed_url) + + err = exc_info.value + assert err.code == 401, f"Unexpected status {err.code}" + + error_json_str = err.read().decode() # bytes ➜ str + error_payload = json.loads(error_json_str) # str ➜ dict + + expected_payload = {"code": 401, "message": "signature is already expired"} + assert error_payload == expected_payload diff --git a/tests/async/sandbox_async/files/test_watch.py b/tests/async/sandbox_async/files/test_watch.py new file mode 100644 index 0000000..fd0f2f0 --- /dev/null +++ b/tests/async/sandbox_async/files/test_watch.py @@ -0,0 +1,119 @@ +import pytest + +from asyncio import Event + +from e2b import ( + NotFoundException, + AsyncSandbox, + FilesystemEvent, + FilesystemEventType, + SandboxException, +) + + +async def test_watch_directory_changes(async_sandbox: AsyncSandbox): + dirname = "test_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + new_content = "This file has been modified." + + await async_sandbox.files.make_dir(dirname) + await async_sandbox.files.write(f"{dirname}/{filename}", content) + + event_triggered = Event() + + def handle_event(e: FilesystemEvent): + if e.type == FilesystemEventType.WRITE and e.name == filename: + event_triggered.set() + + handle = await async_sandbox.files.watch_dir(dirname, on_event=handle_event) + + await async_sandbox.files.write(f"{dirname}/{filename}", new_content) + + await event_triggered.wait() + + await handle.stop() + + +async def test_watch_recursive_directory_changes(async_sandbox: AsyncSandbox): + dirname = "test_recursive_watch_dir" + nested_dirname = "test_nested_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + + await async_sandbox.files.remove(dirname) + await async_sandbox.files.make_dir(f"{dirname}/{nested_dirname}") + + event_triggered = Event() + + expected_filename = f"{nested_dirname}/{filename}" + + def handle_event(e: FilesystemEvent): + if e.type == FilesystemEventType.WRITE and e.name == expected_filename: + event_triggered.set() + + handle = await async_sandbox.files.watch_dir( + dirname, on_event=handle_event, recursive=True + ) + + await async_sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content) + + await event_triggered.wait() + + await handle.stop() + + +async def test_watch_recursive_directory_after_nested_folder_addition( + async_sandbox: AsyncSandbox, +): + dirname = "test_recursive_watch_dir_add" + nested_dirname = "test_nested_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + + await async_sandbox.files.remove(dirname) + await async_sandbox.files.make_dir(dirname) + + event_triggered_file = Event() + event_triggered_folder = Event() + + expected_filename = f"{nested_dirname}/{filename}" + + def handle_event(e: FilesystemEvent): + if e.type == FilesystemEventType.WRITE and e.name == expected_filename: + event_triggered_file.set() + return + if e.type == FilesystemEventType.CREATE and e.name == nested_dirname: + event_triggered_folder.set() + + handle = await async_sandbox.files.watch_dir( + dirname, on_event=handle_event, recursive=True + ) + + await async_sandbox.files.make_dir(f"{dirname}/{nested_dirname}") + await event_triggered_folder.wait() + + await async_sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content) + await event_triggered_file.wait() + + await handle.stop() + + +async def test_watch_non_existing_directory(async_sandbox: AsyncSandbox): + dirname = "non_existing_watch_dir" + + with pytest.raises(NotFoundException): + await async_sandbox.files.watch_dir(dirname, on_event=lambda e: None) + + +async def test_watch_file(async_sandbox: AsyncSandbox): + filename = "test_watch.txt" + await async_sandbox.files.write(filename, "This file will be watched.") + + with pytest.raises(SandboxException): + await async_sandbox.files.watch_dir(filename, on_event=lambda e: None) + + +async def test_watch_file_with_secured_envd(async_sandbox): + await async_sandbox.files.watch_dir("/home/user/", on_event=lambda e: None) + await async_sandbox.files.write("test_watch.txt", "This file will be watched.") diff --git a/tests/async/sandbox_async/files/test_write.py b/tests/async/sandbox_async/files/test_write.py new file mode 100644 index 0000000..35395d4 --- /dev/null +++ b/tests/async/sandbox_async/files/test_write.py @@ -0,0 +1,119 @@ +import io +import uuid + +from e2b import AsyncSandbox +from e2b.sandbox.filesystem.filesystem import WriteEntry +from e2b.sandbox_async.filesystem.filesystem import WriteInfo + + +async def test_write_text_file(async_sandbox: AsyncSandbox): + filename = "test_write.txt" + content = "This is a test file." + + info = await async_sandbox.files.write(filename, content) + assert info.path == f"/home/user/{filename}" + + exists = await async_sandbox.files.exists(filename) + assert exists + + read_content = await async_sandbox.files.read(filename) + assert read_content == content + + +async def test_write_binary_file(async_sandbox: AsyncSandbox): + filename = "test_write.bin" + text = "This is a test binary file." + # equivalent to `open("path/to/local/file", "rb")` + content = io.BytesIO(text.encode("utf-8")) + + info = await async_sandbox.files.write(filename, content) + assert info.path == f"/home/user/{filename}" + + exists = await async_sandbox.files.exists(filename) + assert exists + + read_content = await async_sandbox.files.read(filename) + assert read_content == text + + +async def test_write_multiple_files(async_sandbox: AsyncSandbox): + # Attempt to write with empty files array + empty_info = await async_sandbox.files.write_files([]) + assert isinstance(empty_info, list) + assert len(empty_info) == 0 + + # Attempt to write with one file in array + info = await async_sandbox.files.write_files( + [WriteEntry(path="one_test_file.txt", data="This is a test file.")] + ) + assert isinstance(info, list) + assert len(info) == 1 + info = info[0] + assert isinstance(info, WriteInfo) + assert info.path == "/home/user/one_test_file.txt" + exists = await async_sandbox.files.exists(info.path) + assert exists + + read_content = await async_sandbox.files.read(info.path) + assert read_content == "This is a test file." + + # Attempt to write with multiple files in array + files = [] + for i in range(10): + path = f"test_write_{i}.txt" + content = f"This is a test file {i}." + files.append(WriteEntry(path=path, data=content)) + + infos = await async_sandbox.files.write_files(files) + assert isinstance(infos, list) + assert len(infos) == len(files) + for i, info in enumerate(infos): + assert isinstance(info, WriteInfo) + assert info.path == f"/home/user/test_write_{i}.txt" + exists = await async_sandbox.files.exists(path) + assert exists + + read_content = await async_sandbox.files.read(info.path) + assert read_content == files[i]["data"] + + +async def test_overwrite_file(async_sandbox: AsyncSandbox): + filename = "test_overwrite.txt" + initial_content = "Initial content." + new_content = "New content." + + await async_sandbox.files.write(filename, initial_content) + await async_sandbox.files.write(filename, new_content) + read_content = await async_sandbox.files.read(filename) + assert read_content == new_content + + +async def test_write_to_non_existing_directory(async_sandbox: AsyncSandbox): + filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" + content = "This should succeed too." + + await async_sandbox.files.write(filename, content) + exists = await async_sandbox.files.exists(filename) + assert exists + + read_content = await async_sandbox.files.read(filename) + assert read_content == content + + +async def test_write_with_secured_envd(async_sandbox_factory): + filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" + content = "This should succeed too." + + sbx = await async_sandbox_factory(timeout=30, secure=True) + + assert await sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + await sbx.files.write(filename, content) + + exists = await sbx.files.exists(filename) + assert exists + + read_content = await sbx.files.read(filename) + assert read_content == content diff --git a/tests/async/sandbox_async/pty/test_pty_create.py b/tests/async/sandbox_async/pty/test_pty_create.py new file mode 100644 index 0000000..eec044d --- /dev/null +++ b/tests/async/sandbox_async/pty/test_pty_create.py @@ -0,0 +1,21 @@ +from e2b import AsyncSandbox +from e2b.sandbox.commands.command_handle import PtySize + + +async def test_pty_create(async_sandbox: AsyncSandbox): + output = [] + + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + terminal = await async_sandbox.pty.create( + PtySize(80, 24), on_data=lambda x: append_data(output, x), envs={"ABC": "123"} + ) + + await async_sandbox.pty.send_stdin(terminal.pid, b"echo $ABC\n") + await async_sandbox.pty.send_stdin(terminal.pid, b"exit\n") + + await terminal.wait() + assert terminal.exit_code == 0 + + assert "123" in "".join(output) diff --git a/tests/async/sandbox_async/pty/test_resize.py b/tests/async/sandbox_async/pty/test_resize.py new file mode 100644 index 0000000..ca91ff6 --- /dev/null +++ b/tests/async/sandbox_async/pty/test_resize.py @@ -0,0 +1,34 @@ +from e2b import AsyncSandbox +from e2b.sandbox.commands.command_handle import PtySize + + +async def test_resize(async_sandbox: AsyncSandbox): + output = [] + + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + terminal = await async_sandbox.pty.create( + PtySize(cols=80, rows=24), on_data=lambda x: append_data(output, x) + ) + + await async_sandbox.pty.send_stdin(terminal.pid, b"tput cols\n") + await async_sandbox.pty.send_stdin(terminal.pid, b"exit\n") + await terminal.wait() + assert terminal.exit_code == 0 + + assert "80" in "".join(output) + + output = [] + + terminal = await async_sandbox.pty.create( + PtySize(cols=80, rows=24), on_data=lambda x: append_data(output, x) + ) + + await async_sandbox.pty.resize(terminal.pid, PtySize(cols=100, rows=24)) + await async_sandbox.pty.send_stdin(terminal.pid, b"tput cols\n") + await async_sandbox.pty.send_stdin(terminal.pid, b"exit\n") + + await terminal.wait() + assert terminal.exit_code == 0 + assert "100" in "".join(output) diff --git a/tests/async/sandbox_async/pty/test_send_input.py b/tests/async/sandbox_async/pty/test_send_input.py new file mode 100644 index 0000000..d54eeaa --- /dev/null +++ b/tests/async/sandbox_async/pty/test_send_input.py @@ -0,0 +1,11 @@ +from e2b import AsyncSandbox +from e2b.sandbox.commands.command_handle import PtySize + + +async def test_send_input(async_sandbox: AsyncSandbox): + terminal = await async_sandbox.pty.create( + PtySize(cols=80, rows=24), on_data=lambda x: print(x) + ) + await async_sandbox.pty.send_stdin(terminal.pid, b"exit\n") + await terminal.wait() + assert terminal.exit_code == 0 diff --git a/tests/async/sandbox_async/test_connect.py b/tests/async/sandbox_async/test_connect.py new file mode 100644 index 0000000..d4387ba --- /dev/null +++ b/tests/async/sandbox_async/test_connect.py @@ -0,0 +1,73 @@ +import uuid +import pytest + +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_connect(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=10) + + assert await sbx.is_running() + + sbx_connection = await AsyncSandbox.connect(sbx.sandbox_id) + assert await sbx_connection.is_running() + + +@pytest.mark.skip_debug() +async def test_connect_with_secure(async_sandbox_factory): + dir_name = f"test_directory_{uuid.uuid4()}" + + sbx = await async_sandbox_factory(timeout=10, secure=True) + assert await sbx.is_running() + + sbx_connection = await AsyncSandbox.connect(sbx.sandbox_id) + + await sbx_connection.files.make_dir(dir_name) + files = await sbx_connection.files.list(dir_name) + assert len(files) == 0 + assert await sbx_connection.is_running() + + +@pytest.mark.skip_debug() +async def test_connect_does_not_shorten_timeout_on_running_sandbox(template): + # Create sandbox with a 300 second timeout + sbx = await AsyncSandbox.create(template, timeout=300) + try: + assert await sbx.is_running() + + # Get initial info to check end_at + info_before = await AsyncSandbox.get_info(sbx.sandbox_id) + + # Connect with a shorter timeout (10 seconds) + await AsyncSandbox.connect(sbx.sandbox_id, timeout=10) + + # Get info after connection + info_after = await AsyncSandbox.get_info(sbx.sandbox_id) + + # The end_at time should not have been shortened. It should be the same + assert info_after.end_at == info_before.end_at, ( + f"Timeout was changed: before={info_before.end_at}, after={info_after.end_at}" + ) + finally: + await sbx.kill() + + +@pytest.mark.skip_debug() +async def test_connect_extends_timeout_on_running_sandbox(async_sandbox): + # Create sandbox with a short timeout + assert await async_sandbox.is_running() + + # Get initial info to check end_at + info_before = await async_sandbox.get_info() + + # Connect with a longer timeout + await AsyncSandbox.connect(async_sandbox.sandbox_id, timeout=600) + + # Get info after connection + info_after = await AsyncSandbox.get_info(async_sandbox.sandbox_id) + + # The end_at time should have been extended + assert info_after.end_at > info_before.end_at, ( + f"Timeout was not extended: before={info_before.end_at}, after={info_after.end_at}" + ) diff --git a/tests/async/sandbox_async/test_create.py b/tests/async/sandbox_async/test_create.py new file mode 100644 index 0000000..95f000d --- /dev/null +++ b/tests/async/sandbox_async/test_create.py @@ -0,0 +1,27 @@ +import pytest + +from e2b import AsyncSandbox, SandboxQuery + + +@pytest.mark.skip_debug() +async def test_start(async_sandbox): + assert await async_sandbox.is_running() + assert async_sandbox._envd_version is not None + + +@pytest.mark.skip_debug() +async def test_metadata(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=5, metadata={"test-key": "test-value"}) + + paginator = AsyncSandbox.list( + query=SandboxQuery(metadata={"test-key": "test-value"}) + ) + sandboxes = await paginator.next_items() + + for sbx_info in sandboxes: + if sbx.sandbox_id == sbx_info.sandbox_id: + assert sbx_info.metadata is not None + assert sbx_info.metadata["test-key"] == "test-value" + break + else: + assert False, "Sandbox not found" diff --git a/tests/async/sandbox_async/test_host.py b/tests/async/sandbox_async/test_host.py new file mode 100644 index 0000000..9b3c90d --- /dev/null +++ b/tests/async/sandbox_async/test_host.py @@ -0,0 +1,30 @@ +import asyncio + +import httpx + +from e2b import AsyncSandbox + + +async def test_ping_server(async_sandbox: AsyncSandbox, debug, helpers): + cmd = await async_sandbox.commands.run( + "python -m http.server 8000", + background=True, + ) + + disable = helpers.catch_cmd_exit_error_in_background(cmd) + + try: + host = async_sandbox.get_host(8000) + + status_code = None + async with httpx.AsyncClient() as client: + for _ in range(20): + res = await client.get(f"{'http' if debug else 'https'}://{host}") + status_code = res.status_code + if res.status_code == 200: + break + await asyncio.sleep(0.5) + assert status_code == 200 + disable() + finally: + await cmd.kill() diff --git a/tests/async/sandbox_async/test_internet_access.py b/tests/async/sandbox_async/test_internet_access.py new file mode 100644 index 0000000..3a48249 --- /dev/null +++ b/tests/async/sandbox_async/test_internet_access.py @@ -0,0 +1,42 @@ +import pytest + +from e2b.sandbox.commands.command_handle import CommandExitException + + +@pytest.mark.skip_debug() +async def test_internet_access_enabled(async_sandbox_factory): + """Test that sandbox with internet access enabled can reach external websites.""" + sbx = await async_sandbox_factory(allow_internet_access=True) + + # Test internet connectivity by making a curl request to a reliable external site + result = await sbx.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://e2b.dev" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "200" + + +@pytest.mark.skip_debug() +async def test_internet_access_disabled(async_sandbox_factory): + """Test that sandbox with internet access disabled cannot reach external websites.""" + sbx = await async_sandbox_factory(allow_internet_access=False) + + # Test that internet connectivity is blocked by making a curl request + with pytest.raises(CommandExitException) as exc_info: + await sbx.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://e2b.dev" + ) + # The command should fail or timeout when internet access is disabled + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +async def test_internet_access_default(async_sandbox): + """Test that sandbox with default settings (no explicit allow_internet_access) has internet access.""" + # Test internet connectivity by making a curl request to a reliable external site + + result = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://e2b.dev" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "200" diff --git a/tests/async/sandbox_async/test_kill.py b/tests/async/sandbox_async/test_kill.py new file mode 100644 index 0000000..f57502b --- /dev/null +++ b/tests/async/sandbox_async/test_kill.py @@ -0,0 +1,16 @@ +import pytest + +from e2b import AsyncSandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +async def test_kill(async_sandbox: AsyncSandbox, sandbox_test_id: str): + await async_sandbox.kill() + + paginator = AsyncSandbox.list( + query=SandboxQuery( + state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id} + ) + ) + sandboxes = await paginator.next_items() + assert async_sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] diff --git a/tests/async/sandbox_async/test_metrics.py b/tests/async/sandbox_async/test_metrics.py new file mode 100644 index 0000000..0982a5a --- /dev/null +++ b/tests/async/sandbox_async/test_metrics.py @@ -0,0 +1,26 @@ +import asyncio + +import pytest + + +@pytest.mark.skip_debug() +async def test_sbx_metrics(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=20) + + # Wait for the sandbox to have some metrics + metrics = [] + for _ in range(15): + metrics = await sbx.get_metrics() + if len(metrics) > 0: + break + await asyncio.sleep(1) + + assert len(metrics) > 0 + + metric = metrics[0] + assert metric.cpu_count is not None + assert metric.cpu_used_pct is not None + assert metric.mem_used is not None + assert metric.mem_total is not None + assert metric.disk_used is not None + assert metric.disk_total is not None diff --git a/tests/async/sandbox_async/test_network.py b/tests/async/sandbox_async/test_network.py new file mode 100644 index 0000000..1a7dd79 --- /dev/null +++ b/tests/async/sandbox_async/test_network.py @@ -0,0 +1,212 @@ +import pytest + +from e2b import ALL_TRAFFIC, SandboxNetworkOpts +from e2b.sandbox.commands.command_handle import CommandExitException + + +@pytest.mark.skip_debug() +async def test_allow_specific_ip_with_deny_all(async_sandbox_factory): + """Test that sandbox with denyOut all and allowOut creates a whitelist.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC], allow_out=["1.1.1.1"]) + ) + + # Test that allowed IP works + result = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "301" + + # Test that other IPs are denied + with pytest.raises(CommandExitException) as exc_info: + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +async def test_deny_specific_ip(async_sandbox_factory): + """Test that sandbox with denyOut denies specified IP addresses.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(deny_out=["8.8.8.8"]) + ) + + # Test that denied IP fails + with pytest.raises(CommandExitException) as exc_info: + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + # Test that other IPs work + result = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "301" + + +@pytest.mark.skip_debug() +async def test_deny_all_traffic(async_sandbox_factory): + """Test that sandbox can deny all traffic using all_traffic helper.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC]), timeout=30 + ) + + # Test that all traffic is denied + with pytest.raises(CommandExitException) as exc_info: + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://1.1.1.1" + ) + assert exc_info.value.exit_code != 0 + + with pytest.raises(CommandExitException) as exc_info: + await async_sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +async def test_allow_takes_precedence_over_deny(async_sandbox_factory): + """Test that allowOut takes precedence over denyOut.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts( + deny_out=[ALL_TRAFFIC], allow_out=["1.1.1.1", "8.8.8.8"] + ) + ) + + # Test that 1.1.1.1 works (explicitly allowed) + result1 = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result1.exit_code == 0 + assert result1.stdout.strip() == "301" + + # Test that 8.8.8.8 also works (explicitly allowed, takes precedence over deny_out) + result2 = await async_sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert result2.exit_code == 0 + assert result2.stdout.strip() == "302" + + +@pytest.mark.skip_debug() +async def test_allow_public_traffic_false(async_sandbox_factory): + """Test that sandbox with allow_public_traffic=False requires traffic access token.""" + async_sandbox = await async_sandbox_factory( + secure=True, network=SandboxNetworkOpts(allow_public_traffic=False) + ) + + import asyncio + + import httpx + + # Verify the sandbox was created successfully and has a traffic access token + assert async_sandbox.traffic_access_token is not None + + # Start a simple HTTP server in the sandbox + port = 8080 + await async_sandbox.commands.run( + f"python3 -m http.server {port}", background=True, timeout=0 + ) + + # Wait for server to start + await asyncio.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{async_sandbox.get_host(port)}" + + async with httpx.AsyncClient() as client: + # Test 1: Request without traffic access token should fail with 403 + response = await client.get(sandbox_url, follow_redirects=True) + assert response.status_code == 403 + + # Test 2: Request with valid traffic access token should succeed + headers = {"e2b-traffic-access-token": async_sandbox.traffic_access_token} + response = await client.get(sandbox_url, headers=headers, follow_redirects=True) + assert response.status_code == 200 + + +@pytest.mark.skip_debug() +async def test_allow_public_traffic_true(async_sandbox_factory): + """Test that sandbox with allow_public_traffic=True works without token.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(allow_public_traffic=True) + ) + + import asyncio + + import httpx + + # Start a simple HTTP server in the sandbox + port = 8080 + await async_sandbox.commands.run( + f"python3 -m http.server {port}", background=True, timeout=0 + ) + + # Wait for server to start + await asyncio.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{async_sandbox.get_host(port)}" + + async with httpx.AsyncClient() as client: + # Request without traffic access token should succeed (public access enabled) + response = await client.get(sandbox_url, follow_redirects=True) + assert response.status_code == 200 + + +@pytest.mark.skip_debug() +async def test_mask_request_host(async_sandbox_factory): + """Test that mask_request_host modifies the Host header correctly.""" + async_sandbox = await async_sandbox_factory( + network=SandboxNetworkOpts(mask_request_host="custom-host.example.com:${PORT}"), + timeout=60, + ) + + import asyncio + + import httpx + + # Install netcat for testing + await async_sandbox.commands.run("apt-get update", user="root") + await async_sandbox.commands.run( + "apt-get install -y netcat-traditional", user="root" + ) + + port = 8080 + output_file = "/tmp/nc_output.txt" + + # Start netcat listener in background to capture request headers + await async_sandbox.commands.run( + f"nc -l -p {port} > {output_file}", + background=True, + timeout=0, + user="root", + ) + + # Wait for netcat to start + await asyncio.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{async_sandbox.get_host(port)}" + + # Make a request from OUTSIDE the sandbox through the proxy + # The Host header should be modified according to mask_request_host + async with httpx.AsyncClient() as client: + try: + await client.get(sandbox_url, timeout=5.0) + except Exception: + # Request may fail since netcat doesn't respond properly, but headers are captured + pass + + # Read the captured output from inside the sandbox + result = await async_sandbox.commands.run(f"cat {output_file}", user="root") + + # Verify the Host header was modified according to mask_request_host + assert "Host:" in result.stdout + assert "custom-host.example.com" in result.stdout + assert str(port) in result.stdout diff --git a/tests/async/sandbox_async/test_secure.py b/tests/async/sandbox_async/test_secure.py new file mode 100644 index 0000000..3ca68da --- /dev/null +++ b/tests/async/sandbox_async/test_secure.py @@ -0,0 +1,26 @@ +import pytest + +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_start_secured(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=5, secure=True) + + assert await sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + +@pytest.mark.skip_debug() +async def test_connect_to_secured(async_sandbox_factory): + sbx = await async_sandbox_factory(timeout=100, secure=True) + + assert await sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + sbx_connection = await AsyncSandbox.connect(sbx.sandbox_id) + assert await sbx_connection.is_running() + assert sbx_connection._envd_version is not None + assert sbx_connection._envd_access_token is not None diff --git a/tests/async/sandbox_async/test_snapshot.py b/tests/async/sandbox_async/test_snapshot.py new file mode 100644 index 0000000..d5b7bce --- /dev/null +++ b/tests/async/sandbox_async/test_snapshot.py @@ -0,0 +1,15 @@ +import pytest +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_snapshot(async_sandbox: AsyncSandbox): + assert await async_sandbox.is_running() + + await async_sandbox.beta_pause() + assert not await async_sandbox.is_running() + + resumed_sandbox = await async_sandbox.connect() + assert await async_sandbox.is_running() + assert await resumed_sandbox.is_running() + assert resumed_sandbox.sandbox_id == async_sandbox.sandbox_id diff --git a/tests/async/sandbox_async/test_timeout.py b/tests/async/sandbox_async/test_timeout.py new file mode 100644 index 0000000..7474b08 --- /dev/null +++ b/tests/async/sandbox_async/test_timeout.py @@ -0,0 +1,30 @@ +import pytest +from datetime import datetime + +from time import sleep + +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_shorten_timeout(async_sandbox: AsyncSandbox): + await async_sandbox.set_timeout(5) + sleep(6) + + is_running = await async_sandbox.is_running() + assert is_running is False + + +@pytest.mark.skip_debug() +async def test_shorten_then_lengthen_timeout(async_sandbox: AsyncSandbox): + await async_sandbox.set_timeout(5) + sleep(1) + await async_sandbox.set_timeout(10) + sleep(6) + await async_sandbox.is_running() + + +@pytest.mark.skip_debug() +async def test_get_timeout(async_sandbox: AsyncSandbox): + info = await async_sandbox.get_info() + assert isinstance(info.end_at, datetime) diff --git a/tests/async/template_async/methods/test_apt_install.py b/tests/async/template_async/methods/test_apt_install.py new file mode 100644 index 0000000..5ed6392 --- /dev/null +++ b/tests/async/template_async/methods/test_apt_install.py @@ -0,0 +1,23 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_apt_install(async_build): + template = ( + AsyncTemplate().from_image("ubuntu:24.04").skip_cache().apt_install("rolldice") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_apt_install_no_install_recommends(async_build): + template = ( + AsyncTemplate() + .from_image("ubuntu:24.04") + .skip_cache() + .apt_install("rolldice", no_install_recommends=True) + ) + await async_build(template) diff --git a/tests/async/template_async/methods/test_bun_install.py b/tests/async/template_async/methods/test_bun_install.py new file mode 100644 index 0000000..7dafa79 --- /dev/null +++ b/tests/async/template_async/methods/test_bun_install.py @@ -0,0 +1,36 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_bun_install(async_build): + template = ( + AsyncTemplate().from_bun_image("1.3").skip_cache().bun_install("left-pad") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_bun_install_global(async_build): + template = ( + AsyncTemplate() + .from_bun_image("1.3") + .skip_cache() + .bun_install("left-pad", g=True) + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_bun_install_dev(async_build): + template = ( + AsyncTemplate() + .from_bun_image("1.3") + .skip_cache() + .bun_install("left-pad", dev=True) + ) + + await async_build(template) diff --git a/tests/async/template_async/methods/test_from_dockerfile.py b/tests/async/template_async/methods/test_from_dockerfile.py new file mode 100644 index 0000000..597704e --- /dev/null +++ b/tests/async/template_async/methods/test_from_dockerfile.py @@ -0,0 +1,66 @@ +import pytest + +from e2b import AsyncTemplate +from e2b.template.types import InstructionType + + +@pytest.mark.skip_debug() +async def test_from_dockerfile(): + dockerfile = """FROM node:24 +WORKDIR /app +COPY package.json . +RUN npm install +ENTRYPOINT ["sleep", "20"]""" + + template = AsyncTemplate().from_dockerfile(dockerfile) + + # base image + assert template._template._base_image == "node:24" + + instructions = template._template._instructions + + # Docker defaults + assert instructions[1]["type"] == InstructionType.WORKDIR + assert instructions[1]["args"][0] == "/" + + # Instructions from Dockerfile + assert instructions[2]["type"] == InstructionType.WORKDIR + assert instructions[2]["args"][0] == "/app" + + assert instructions[3]["type"] == InstructionType.COPY + assert instructions[3]["args"][0] == "package.json" + assert instructions[3]["args"][1] == "." + + assert instructions[4]["type"] == InstructionType.RUN + assert instructions[4]["args"][0] == "npm install" + + # E2B defaults appended + assert instructions[5]["type"] == InstructionType.USER + assert instructions[5]["args"][0] == "user" + + # Start command + assert template._template._start_cmd == "sleep 20" + + +@pytest.mark.skip_debug() +async def test_from_dockerfile_with_default_user_and_workdir(): + dockerfile = "FROM node:24" + + template = AsyncTemplate().from_dockerfile(dockerfile) + + assert template._template._instructions[-2]["type"] == InstructionType.USER + assert template._template._instructions[-2]["args"][0] == "user" + assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR + assert template._template._instructions[-1]["args"][0] == "/home/user" + + +@pytest.mark.skip_debug() +async def test_from_dockerfile_with_custom_user_and_workdir(): + dockerfile = "FROM node:24\nUSER mish\nWORKDIR /home/mish" + + template = AsyncTemplate().from_dockerfile(dockerfile) + + assert template._template._instructions[-2]["type"] == InstructionType.USER + assert template._template._instructions[-2]["args"][0] == "mish" + assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR + assert template._template._instructions[-1]["args"][0] == "/home/mish" diff --git a/tests/async/template_async/methods/test_make_symlink.py b/tests/async/template_async/methods/test_make_symlink.py new file mode 100644 index 0000000..f6c1c61 --- /dev/null +++ b/tests/async/template_async/methods/test_make_symlink.py @@ -0,0 +1,32 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_make_symlink(async_build): + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .skip_cache() + .make_symlink(".bashrc", ".bashrc.local") + .run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"') + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_make_symlink_force(async_build): + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .make_symlink(".bashrc", ".bashrc.local") + .skip_cache() + .make_symlink( + ".bashrc", ".bashrc.local", force=True + ) # Overwrite existing symlink + .run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"') + ) + + await async_build(template) diff --git a/tests/async/template_async/methods/test_npm_install.py b/tests/async/template_async/methods/test_npm_install.py new file mode 100644 index 0000000..c61cc7f --- /dev/null +++ b/tests/async/template_async/methods/test_npm_install.py @@ -0,0 +1,36 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_npm_install(async_build): + template = ( + AsyncTemplate().from_node_image("24").skip_cache().npm_install("left-pad") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_npm_install_global(async_build): + template = ( + AsyncTemplate() + .from_node_image("24") + .skip_cache() + .npm_install("left-pad", g=True) + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_npm_install_dev(async_build): + template = ( + AsyncTemplate() + .from_node_image("24") + .skip_cache() + .npm_install("left-pad", dev=True) + ) + + await async_build(template) diff --git a/tests/async/template_async/methods/test_pip_install.py b/tests/async/template_async/methods/test_pip_install.py new file mode 100644 index 0000000..d53acf6 --- /dev/null +++ b/tests/async/template_async/methods/test_pip_install.py @@ -0,0 +1,27 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_pip_install(async_build): + template = ( + AsyncTemplate() + .from_python_image("3.13.7-trixie") + .skip_cache() + .pip_install("pip-install-test") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_pip_install_user(async_build): + template = ( + AsyncTemplate() + .from_python_image("3.13.7-trixie") + .skip_cache() + .pip_install("pip-install-test", g=False) + ) + + await async_build(template) diff --git a/tests/async/template_async/methods/test_run_cmd.py b/tests/async/template_async/methods/test_run_cmd.py new file mode 100644 index 0000000..6f29096 --- /dev/null +++ b/tests/async/template_async/methods/test_run_cmd.py @@ -0,0 +1,40 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_run_command(async_build): + template = AsyncTemplate().from_image("ubuntu:22.04").skip_cache().run_cmd("ls -l") + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_run_command_as_different_user(async_build): + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd('test "$(whoami)" = "root"', user="root") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_run_command_as_user_that_does_not_exist(async_build): + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd("whoami", user="root123") + ) + + with pytest.raises(Exception) as exc_info: + await async_build(template) + + assert ( + "failed to run command 'whoami': command failed: unauthenticated: invalid username: 'root123'" + in str(exc_info.value) + ) diff --git a/tests/async/template_async/methods/test_to_dockerfile.py b/tests/async/template_async/methods/test_to_dockerfile.py new file mode 100644 index 0000000..f955d1f --- /dev/null +++ b/tests/async/template_async/methods/test_to_dockerfile.py @@ -0,0 +1,57 @@ +import pytest + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_to_dockerfile(): + template = ( + AsyncTemplate() + .from_ubuntu_image("24.04") + .copy("README.md", "/app/README.md") + .run_cmd('echo "Hello, World!"') + ) + + dockerfile = AsyncTemplate.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +""" + assert dockerfile == expected_dockerfile + + +@pytest.mark.skip_debug() +async def test_to_dockerfile_with_options(): + template = ( + AsyncTemplate() + .from_ubuntu_image("24.04") + .copy("README.md", "/app/README.md", user="root") + .run_cmd('echo "Hello, World!"', user="root") + ) + + dockerfile = AsyncTemplate.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +""" + assert dockerfile == expected_dockerfile + + +@pytest.mark.skip_debug() +async def test_to_dockerfile_with_env_instructions(): + template = ( + AsyncTemplate() + .from_ubuntu_image("24.04") + .set_envs({"NODE_ENV": "production", "PORT": "8080"}) + .set_envs({"DEBUG": "false"}) + ) + + dockerfile = AsyncTemplate.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +ENV NODE_ENV=production PORT=8080 +ENV DEBUG=false +""" + assert dockerfile == expected_dockerfile diff --git a/tests/async/template_async/test_background_build.py b/tests/async/template_async/test_background_build.py new file mode 100644 index 0000000..783a766 --- /dev/null +++ b/tests/async/template_async/test_background_build.py @@ -0,0 +1,34 @@ +import uuid + +import pytest + +from e2b import AsyncTemplate, wait_for_timeout + + +@pytest.mark.skip_debug() +@pytest.mark.timeout(10) +async def test_build_in_background_should_start_build_and_return_info(): + """Test that build_in_background returns immediately without waiting for build to complete.""" + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd("sleep 5") # Add a delay to ensure build takes time + .set_start_cmd('echo "Hello"', wait_for_timeout(10_000)) + ) + + alias = f"e2b-test-{uuid.uuid4()}" + + build_info = await AsyncTemplate.build_in_background( + template, + alias=alias, + cpu_count=1, + memory_mb=1024, + ) + + # Should return quickly (within a few seconds), not wait for the full build + assert build_info is not None + + # Verify the build is actually running + status = await AsyncTemplate.get_build_status(build_info) + assert status.status.value == "building" diff --git a/tests/async/template_async/test_build.py b/tests/async/template_async/test_build.py new file mode 100644 index 0000000..f16da0b --- /dev/null +++ b/tests/async/template_async/test_build.py @@ -0,0 +1,102 @@ +import os +import shutil +import tempfile + +import pytest + +from e2b import AsyncTemplate, default_build_logger, wait_for_timeout + + +@pytest.fixture(scope="module") +def setup_test_folder(): + test_dir = tempfile.mkdtemp(prefix="python_async_test_") + folder_path = os.path.join(test_dir, "folder") + + os.makedirs(folder_path, exist_ok=True) + with open(os.path.join(folder_path, "test.txt"), "w") as f: + f.write("This is a test file.") + + # Create relative symlink + symlink_path = os.path.join(folder_path, "symlink.txt") + if os.path.exists(symlink_path): + os.remove(symlink_path) + os.symlink("test.txt", symlink_path) + + # Create absolute symlink + symlink2_path = os.path.join(folder_path, "symlink2.txt") + if os.path.exists(symlink2_path): + os.remove(symlink2_path) + os.symlink(os.path.join(folder_path, "test.txt"), symlink2_path) + + # Create a symlink to a file that does not exist + symlink3_path = os.path.join(folder_path, "symlink3.txt") + if os.path.exists(symlink3_path): + os.remove(symlink3_path) + os.symlink("12345test.txt", symlink3_path) + + yield test_dir + + # Cleanup + shutil.rmtree(test_dir, ignore_errors=True) + + +@pytest.mark.skip_debug() +async def test_build_template(async_build, setup_test_folder): + template = ( + AsyncTemplate(file_context_path=setup_test_folder) + .from_base_image() + .copy("folder/*", "folder", force_upload=True) + .run_cmd("cat folder/test.txt") + .set_workdir("/app") + .set_start_cmd("echo 'Hello, world!'", wait_for_timeout(10_000)) + ) + + await async_build(template, skip_cache=True, on_build_logs=default_build_logger()) + + +@pytest.mark.skip_debug() +async def test_build_template_from_base_template(async_build): + template = AsyncTemplate().from_template("base") + await async_build(template, skip_cache=True, on_build_logs=default_build_logger()) + + +@pytest.mark.skip_debug() +async def test_build_template_with_symlinks(async_build, setup_test_folder): + template = ( + AsyncTemplate(file_context_path=setup_test_folder) + .from_image("ubuntu:22.04") + .skip_cache() + .copy("folder/*", "folder", force_upload=True) + .run_cmd("cat folder/symlink.txt") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_build_template_with_resolve_symlinks(async_build, setup_test_folder): + template = ( + AsyncTemplate(file_context_path=setup_test_folder) + .from_image("ubuntu:22.04") + .skip_cache() + .copy( + "folder/symlink.txt", + "folder/symlink.txt", + force_upload=True, + resolve_symlinks=True, + ) + .run_cmd("cat folder/symlink.txt") + ) + + await async_build(template) + + +@pytest.mark.skip_debug() +async def test_build_template_with_skip_cache(async_build, setup_test_folder): + template = ( + AsyncTemplate(file_context_path=setup_test_folder) + .skip_cache() + .from_image("ubuntu:22.04") + ) + + await async_build(template) diff --git a/tests/async/template_async/test_stacktrace.py b/tests/async/template_async/test_stacktrace.py new file mode 100644 index 0000000..7bff6ee --- /dev/null +++ b/tests/async/template_async/test_stacktrace.py @@ -0,0 +1,330 @@ +import traceback +from types import SimpleNamespace +from typing import Optional +from uuid import uuid4 + +import pytest +import linecache + +from e2b import AsyncTemplate, CopyItem, wait_for_timeout +from e2b.api.client.models import TemplateBuildStatus +import e2b.template_async.main as template_async_main +import e2b.template_async.build_api as build_api_mod + +non_existent_path = "/nonexistent/path" + +# map template alias -> failed step index +failure_map: dict[str, Optional[int]] = { + "from_image": 0, + "from_template": 0, + "from_dockerfile": 0, + "from_image_registry": 0, + "from_aws_registry": 0, + "from_gcp_registry": 0, + "copy": None, + "copy_items": None, + "remove": 1, + "rename": 1, + "make_dir": 1, + "make_symlink": 1, + "run_cmd": 1, + "set_workdir": 1, + "set_user": 1, + "pip_install": 1, + "npm_install": 1, + "apt_install": 1, + "git_clone": 1, + "set_start_cmd": 1, + "add_mcp_server": None, + "beta_dev_container_prebuild": 1, + "beta_set_dev_container_start": 1, +} + + +@pytest.fixture(autouse=True) +def mock_template_build(monkeypatch): + async def mock_request_build(client, name: str, cpu_count: int, memory_mb: int): + return SimpleNamespace(template_id=name, build_id=str(uuid4())) + + async def mock_trigger_build(client, template_id: str, build_id: str, template): + return None + + async def mock_get_build_status( + client, template_id: str, build_id: str, logs_offset: int + ): + step = failure_map[template_id] + reason = SimpleNamespace( + message="Mocked API build error", + log_entries=[], + step=str(step) if step is not None else None, + ) + return SimpleNamespace( + status=TemplateBuildStatus.ERROR, + log_entries=[], + reason=reason, + ) + + monkeypatch.setattr(template_async_main, "request_build", mock_request_build) + monkeypatch.setattr(template_async_main, "trigger_build", mock_trigger_build) + monkeypatch.setattr(build_api_mod, "get_build_status", mock_get_build_status) + + +async def _expect_to_throw_and_check_trace(func, expected_method: str): + try: + await func() + assert False, "Expected AsyncTemplate.build to raise an exception" + except Exception as e: # noqa: BLE001 - we want to assert on the traceback regardless of type + tb = e.__traceback__ + saw_this_file = False + saw_expected_method = False + while tb is not None: + traceback_file = tb.tb_frame.f_code.co_filename + if traceback_file == __file__: + saw_this_file = True + caller_line = linecache.getline(traceback_file, tb.tb_lineno) + if caller_line and f".{expected_method}(" in caller_line: + saw_expected_method = True + break + tb = tb.tb_next + assert saw_this_file, traceback.format_exc() + assert saw_expected_method, traceback.format_exc() + + +@pytest.mark.skip_debug() +async def test_traces_on_from_image(async_build): + template = AsyncTemplate().from_image("e2b.dev/this-image-does-not-exist") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="from_image", skip_cache=True), "from_image" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_template(async_build): + template = AsyncTemplate().from_template("this-template-does-not-exist") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="from_template", skip_cache=True), + "from_template", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_dockerfile(async_build): + template = AsyncTemplate().from_dockerfile("FROM ubuntu:22.04\nRUN nonexistent") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="from_dockerfile", skip_cache=True), + "from_dockerfile", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_image_registry(async_build): + template = AsyncTemplate().from_image( + "registry.example.com/nonexistent:latest", + username="test", + password="test", + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="from_image_registry", skip_cache=True), + "from_image", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_aws_registry(async_build): + template = AsyncTemplate().from_aws_registry( + "123456789.dkr.ecr.us-east-1.amazonaws.com/nonexistent:latest", + access_key_id="test", + secret_access_key="test", + region="us-east-1", + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="from_aws_registry"), "from_aws_registry" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_from_gcp_registry(async_build): + template = AsyncTemplate().from_gcp_registry( + "gcr.io/nonexistent-project/nonexistent:latest", + service_account_json={ + "type": "service_account", + }, + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="from_gcp_registry"), "from_gcp_registry" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_copy(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy(non_existent_path, non_existent_path) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="copy"), "copy" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_copyItems(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy_items( + [CopyItem(src=non_existent_path, dest=non_existent_path)] + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="copy_items"), "copy_items" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_remove(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().remove(non_existent_path) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="remove"), "remove" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_rename(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().rename(non_existent_path, "/tmp/dest.txt") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="rename"), "rename" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_make_dir(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.set_user("root").skip_cache().make_dir("/root/.bashrc") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="make_dir"), "make_dir" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_make_symlink(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().make_symlink(".bashrc", ".bashrc") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="make_symlink"), "make_symlink" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_run_cmd(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().run_cmd(f"cat {non_existent_path}") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="run_cmd"), "run_cmd" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_set_workdir(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.set_user("root").skip_cache().set_workdir("/root/.bashrc") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="set_workdir"), "set_workdir" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_set_user(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().set_user("; exit 1") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="set_user"), "set_user" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_pip_install(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().pip_install("nonexistent-package") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="pip_install"), "pip_install" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_npm_install(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().npm_install("nonexistent-package") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="npm_install"), "npm_install" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_apt_install(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().apt_install("nonexistent-package") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="apt_install"), "apt_install" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_git_clone(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().git_clone("https://github.com/repo.git") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="git_clone"), "git_clone" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_start_cmd(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.set_start_cmd( + f"./{non_existent_path}", wait_for_timeout(10_000) + ) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="set_start_cmd"), "set_start_cmd" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_add_mcp_server(): + # needs mcp-gateway as base template, without it no mcp servers can be added + await _expect_to_throw_and_check_trace( + lambda: AsyncTemplate().from_base_image().skip_cache().add_mcp_server("exa"), + "add_mcp_server", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_dev_container_prebuild(async_build): + template = AsyncTemplate() + template = template.from_template("devcontainer") + template = template.skip_cache().beta_dev_container_prebuild(non_existent_path) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="beta_dev_container_prebuild"), + "beta_dev_container_prebuild", + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_set_dev_container_start(async_build): + template = AsyncTemplate() + template = template.from_template("devcontainer") + template = template.beta_set_dev_container_start(non_existent_path) + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="beta_set_dev_container_start"), + "beta_set_dev_container_start", + ) diff --git a/tests/bugs/test_envelope_decode.py b/tests/bugs/test_envelope_decode.py new file mode 100644 index 0000000..6bc3595 --- /dev/null +++ b/tests/bugs/test_envelope_decode.py @@ -0,0 +1,45 @@ +from e2b.sandbox.commands.command_handle import CommandExitException + +import pytest + +from e2b import Sandbox + + +class Desktop(Sandbox): + default_template = "desktop" + + @staticmethod + def _wrap_pyautogui_code(code: str): + return f""" +import pyautogui +import os +import Xlib.display + +display = Xlib.display.Display(os.environ["DISPLAY"]) +pyautogui._pyautogui_x11._display = display + +{code} +exit(0) +""" + + def pyautogui(self, pyautogui_code: str): + code_path = "/home/user/code-4f3a0850-1a83-47b2-8402-67b039a084ae.py" + print(code_path) + + code = self._wrap_pyautogui_code(pyautogui_code) + + self.files.write(code_path, code) + + self.commands.run(f"python {code_path}") + + +@pytest.mark.skip +def test_envelope_decode(): + with Desktop(timeout=30) as desktop: + for _ in range(10): + with pytest.raises(CommandExitException): + desktop.pyautogui( + """ +pyautogui.write("Hello, ") +""" + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8ce6311 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,182 @@ +import asyncio +import os +import uuid +from typing import Callable, Optional +from uuid import uuid4 + +import pytest +import pytest_asyncio + +from e2b import ( + AsyncCommandHandle, + AsyncSandbox, + AsyncTemplate, + CommandExitException, + CommandHandle, + LogEntry, + Sandbox, + Template, + TemplateClass, +) + + +@pytest.fixture(scope="session") +def sandbox_test_id(): + return f"test_{uuid.uuid4()}" + + +@pytest.fixture() +def template(): + return "base" + + +@pytest.fixture() +def sandbox_factory(request, template, sandbox_test_id): + def factory(*, template_name: str = template, **kwargs): + kwargs.setdefault("secure", False) + kwargs.setdefault("timeout", 5) + + metadata = kwargs.setdefault("metadata", dict()) + metadata.setdefault("sandbox_test_id", sandbox_test_id) + + sandbox = Sandbox.create(template_name, **kwargs) + + request.addfinalizer(lambda: sandbox.kill()) + + return sandbox + + return factory + + +@pytest.fixture() +def sandbox(sandbox_factory): + return sandbox_factory() + + +# override the event loop so it never closes +# this helps us with the global-scoped async http transport +@pytest.fixture(scope="session") +def event_loop(): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +def async_sandbox_factory(request, template, sandbox_test_id, event_loop): + async def factory(*, template_name: str = template, **kwargs): + kwargs.setdefault("timeout", 5) + + metadata = kwargs.setdefault("metadata", dict()) + metadata.setdefault("sandbox_test_id", sandbox_test_id) + + sandbox = await AsyncSandbox.create(template_name, **kwargs) + + def kill(): + async def _kill(): + await sandbox.kill() + + event_loop.run_until_complete(_kill()) + + request.addfinalizer(kill) + + return sandbox + + return factory + + +@pytest.fixture +async def async_sandbox(async_sandbox_factory): + return await async_sandbox_factory() + + +@pytest.fixture +def build(): + def _build( + template: TemplateClass, + alias: Optional[str] = None, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + ): + return Template.build( + template, + alias=alias or f"e2b-test-{uuid4()}", + cpu_count=1, + memory_mb=1024, + skip_cache=skip_cache, + on_build_logs=on_build_logs, + ) + + return _build + + +@pytest_asyncio.fixture +def async_build(): + async def _async_build( + template: TemplateClass, + alias: Optional[str] = None, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + ): + return await AsyncTemplate.build( + template, + alias=alias or f"e2b-test-{uuid4()}", + cpu_count=1, + memory_mb=1024, + skip_cache=skip_cache, + on_build_logs=on_build_logs, + ) + + return _async_build + + +@pytest.fixture +def debug(): + return os.getenv("E2B_DEBUG") is not None + + +@pytest.fixture(autouse=True) +def skip_by_debug(request, debug): + if request.node.get_closest_marker("skip_debug"): + if debug: + pytest.skip("skipped because E2B_DEBUG is set") + + +class Helpers: + @staticmethod + def catch_cmd_exit_error_in_background(cmd: AsyncCommandHandle): + disabled = False + + async def wait_for_exit(): + try: + await cmd.wait() + except CommandExitException as e: + if not disabled: + assert False, ( + f"command failed with exit code {e.exit_code}: {e.stderr}" + ) + + asyncio.create_task(wait_for_exit()) + + def disable(): + nonlocal disabled + disabled = True + + return disable + + @staticmethod + def check_cmd_exit_error(cmd: CommandHandle): + try: + cmd.wait() + except CommandExitException as e: + assert False, f"command failed with exit code {e.exit_code}: {e.stderr}" + except Exception as e: + raise e + + +@pytest.fixture +def helpers(): + return Helpers diff --git a/tests/e2b_connect/__init__.py b/tests/e2b_connect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2b_connect/test_client.py b/tests/e2b_connect/test_client.py new file mode 100644 index 0000000..ea1f272 --- /dev/null +++ b/tests/e2b_connect/test_client.py @@ -0,0 +1,134 @@ +import asyncio + +import pytest + +from e2b_connect.client import _retry + + +class GoodError(Exception): + pass + + +class BadError(Exception): + pass + + +def test_sync_retry_after_expected_exception(): + total = 0 + + @_retry(GoodError, 1) + def f(): + nonlocal total + total += 1 + raise GoodError() + + with pytest.raises(GoodError): + f() + + assert total == 2 + + +def test_sync_do_not_retry_on_unexpected_exception(): + total = 0 + + @_retry(GoodError, 1) + def f(): + nonlocal total + total += 1 + raise BadError() + + with pytest.raises(BadError): + f() + + assert total == 1 + + +def test_sync_do_not_throw_when_retry_works(): + total = 0 + + @_retry(GoodError, 1) + def f(): + nonlocal total + total += 1 + + if total < 2: + raise GoodError() + + return True + + result = f() + assert result is True + assert total == 2 + + +async def test_async_retry_after_expected_exception(): + total = 0 + + @_retry(GoodError, 1) + async def f(): + nonlocal total + total += 1 + raise GoodError() + + with pytest.raises(GoodError): + await f() + + assert total == 2 + + +async def test_async_do_not_retry_on_unexpected_exception(): + total = 0 + + @_retry(GoodError, 1) + async def f(): + nonlocal total + total += 1 + raise BadError() + + with pytest.raises(BadError): + await f() + + assert total == 1 + + +async def test_async_do_not_throw_when_retry_works(): + total = 0 + + @_retry(GoodError, 1) + async def f(): + nonlocal total + total += 1 + + if total < 2: + raise GoodError() + + return True + + result = await f() + assert result is True + assert total == 2 + + +async def test_async_with_multiple_await_calls(): + total = 0 + + async def a(): + await asyncio.sleep(0.001) + + @_retry(GoodError, 1) + async def f(): + nonlocal total + total += 1 + + await a() + + if total < 2: + raise GoodError() + + await a() + + return True + + result = await f() + assert result is True + assert total == 2 diff --git a/tests/shared/template/utils/get_all_files_in_path.py b/tests/shared/template/utils/get_all_files_in_path.py new file mode 100644 index 0000000..a55815a --- /dev/null +++ b/tests/shared/template/utils/get_all_files_in_path.py @@ -0,0 +1,275 @@ +import os +import tempfile +import pytest +from e2b.template.utils import get_all_files_in_path + + +class TestGetAllFilesInPath: + @pytest.fixture + def test_dir(self): + """Create a temporary directory for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield tmpdir + + def test_should_return_files_matching_simple_pattern(self, test_dir): + """Test that function returns files matching a simple pattern.""" + # Create test files + with open(os.path.join(test_dir, "file1.txt"), "w") as f: + f.write("content1") + with open(os.path.join(test_dir, "file2.txt"), "w") as f: + f.write("content2") + with open(os.path.join(test_dir, "file3.js"), "w") as f: + f.write("content3") + + files = get_all_files_in_path("*.txt", test_dir, []) + + assert len(files) == 2 + assert any("file1.txt" in f for f in files) + assert any("file2.txt" in f for f in files) + assert not any("file3.js" in f for f in files) + + def test_should_handle_directory_patterns_recursively(self, test_dir): + """Test that function handles directory patterns recursively.""" + # Create nested directory structure + os.makedirs(os.path.join(test_dir, "src", "components"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(test_dir, "src", "components", "Button.tsx"), "w") as f: + f.write("button content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open(os.path.join(test_dir, "README.md"), "w") as f: + f.write("readme content") + + files = get_all_files_in_path("src", test_dir, []) + + assert len(files) == 6 # 3 files + 3 directories (src, components, utils) + assert any("index.ts" in f for f in files) + assert any("Button.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert not any("README.md" in f for f in files) + + def test_should_respect_ignore_patterns(self, test_dir): + """Test that function respects ignore patterns.""" + # Create test files + with open(os.path.join(test_dir, "file1.txt"), "w") as f: + f.write("content1") + with open(os.path.join(test_dir, "file2.txt"), "w") as f: + f.write("content2") + with open(os.path.join(test_dir, "temp.txt"), "w") as f: + f.write("temp content") + with open(os.path.join(test_dir, "backup.txt"), "w") as f: + f.write("backup content") + + files = get_all_files_in_path("*.txt", test_dir, ["temp*", "backup*"]) + + assert len(files) == 2 + assert any("file1.txt" in f for f in files) + assert any("file2.txt" in f for f in files) + assert not any("temp.txt" in f for f in files) + assert not any("backup.txt" in f for f in files) + + def test_should_handle_complex_ignore_patterns(self, test_dir): + """Test that function handles complex ignore patterns.""" + # Create nested structure with various file types + os.makedirs(os.path.join(test_dir, "src", "components"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "tests"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(test_dir, "src", "components", "Button.tsx"), "w") as f: + f.write("button content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open(os.path.join(test_dir, "tests", "test.spec.ts"), "w") as f: + f.write("test content") + with open( + os.path.join(test_dir, "src", "components", "Button.test.tsx"), "w" + ) as f: + f.write("test content") + with open(os.path.join(test_dir, "src", "utils", "helper.spec.ts"), "w") as f: + f.write("spec content") + + files = get_all_files_in_path("src", test_dir, ["**/*.test.*", "**/*.spec.*"]) + + assert len(files) == 6 # 3 files + 3 directories (src, components, utils) + assert any("index.ts" in f for f in files) + assert any("Button.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert not any("Button.test.tsx" in f for f in files) + assert not any("helper.spec.ts" in f for f in files) + + def test_should_handle_empty_directories(self, test_dir): + """Test that function handles empty directories.""" + os.makedirs(os.path.join(test_dir, "empty"), exist_ok=True) + with open(os.path.join(test_dir, "file.txt"), "w") as f: + f.write("content") + + files = get_all_files_in_path("empty", test_dir, []) + + assert len(files) == 1 + + def test_should_handle_mixed_files_and_directories(self, test_dir): + """Test that function handles mixed files and directories.""" + # Create a mix of files and directories + with open(os.path.join(test_dir, "file1.txt"), "w") as f: + f.write("content1") + os.makedirs(os.path.join(test_dir, "dir1"), exist_ok=True) + with open(os.path.join(test_dir, "dir1", "file2.txt"), "w") as f: + f.write("content2") + with open(os.path.join(test_dir, "file3.txt"), "w") as f: + f.write("content3") + + files = get_all_files_in_path("*", test_dir, []) + + assert len(files) == 4 + assert any("file1.txt" in f for f in files) + assert any("file2.txt" in f for f in files) + assert any("file3.txt" in f for f in files) + + def test_should_handle_glob_patterns_with_subdirectories(self, test_dir): + """Test that function handles glob patterns with subdirectories.""" + # Create nested structure + os.makedirs(os.path.join(test_dir, "src", "components"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(test_dir, "src", "components", "Button.tsx"), "w") as f: + f.write("button content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open(os.path.join(test_dir, "src", "components", "Button.css"), "w") as f: + f.write("css content") + + files = get_all_files_in_path("src/**/*", test_dir, []) + + assert len(files) == 6 + assert any("index.ts" in f for f in files) + assert any("Button.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert any("Button.css" in f for f in files) + + def test_should_handle_specific_file_extensions(self, test_dir): + """Test that function handles specific file extensions.""" + with open(os.path.join(test_dir, "file1.ts"), "w") as f: + f.write("ts content") + with open(os.path.join(test_dir, "file2.js"), "w") as f: + f.write("js content") + with open(os.path.join(test_dir, "file3.tsx"), "w") as f: + f.write("tsx content") + with open(os.path.join(test_dir, "file4.css"), "w") as f: + f.write("css content") + + files = get_all_files_in_path("*.ts", test_dir, []) + + assert len(files) == 1 + assert any("file1.ts" in f for f in files) + + def test_should_return_sorted_files(self, test_dir): + """Test that function returns sorted files.""" + with open(os.path.join(test_dir, "zebra.txt"), "w") as f: + f.write("z content") + with open(os.path.join(test_dir, "apple.txt"), "w") as f: + f.write("a content") + with open(os.path.join(test_dir, "banana.txt"), "w") as f: + f.write("b content") + + files = get_all_files_in_path("*.txt", test_dir, []) + + assert len(files) == 3 + assert "apple.txt" in files[0] + assert "banana.txt" in files[1] + assert "zebra.txt" in files[2] + + def test_should_handle_no_matching_files(self, test_dir): + """Test that function handles no matching files.""" + with open(os.path.join(test_dir, "file.txt"), "w") as f: + f.write("content") + + files = get_all_files_in_path("*.js", test_dir, []) + + assert len(files) == 0 + + def test_should_handle_complex_ignore_patterns_with_directories(self, test_dir): + """Test that function handles complex ignore patterns with directories.""" + # Create a complex structure + os.makedirs(os.path.join(test_dir, "src", "components"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "tests"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "dist"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(test_dir, "src", "components", "Button.tsx"), "w") as f: + f.write("button content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open(os.path.join(test_dir, "src", "tests", "test.spec.ts"), "w") as f: + f.write("test content") + with open(os.path.join(test_dir, "dist", "bundle.js"), "w") as f: + f.write("bundle content") + with open(os.path.join(test_dir, "README.md"), "w") as f: + f.write("readme content") + + files = get_all_files_in_path("src", test_dir, ["**/tests/**", "**/*.spec.*"]) + + assert len(files) == 6 # 3 files + 3 directories (src, components, utils) + assert any("index.ts" in f for f in files) + assert any("Button.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert not any("test.spec.ts" in f for f in files) + + def test_should_handle_symlinks(self, test_dir): + """Test that function handles symbolic links.""" + # Create a file and a symlink to it + with open(os.path.join(test_dir, "original.txt"), "w") as f: + f.write("original content") + + # Create symlink (only on Unix-like systems) + if hasattr(os, "symlink"): + os.symlink("original.txt", os.path.join(test_dir, "link.txt")) + + files = get_all_files_in_path("*.txt", test_dir, []) + + assert len(files) == 2 + assert any("original.txt" in f for f in files) + assert any("link.txt" in f for f in files) + + def test_should_handle_nested_ignore_patterns(self, test_dir): + """Test that function handles nested ignore patterns.""" + # Create nested structure + os.makedirs(os.path.join(test_dir, "src", "components", "ui"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "components", "forms"), exist_ok=True) + os.makedirs(os.path.join(test_dir, "src", "utils"), exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open( + os.path.join(test_dir, "src", "components", "ui", "Button.tsx"), "w" + ) as f: + f.write("button content") + with open( + os.path.join(test_dir, "src", "components", "forms", "Input.tsx"), "w" + ) as f: + f.write("input content") + with open(os.path.join(test_dir, "src", "utils", "helper.ts"), "w") as f: + f.write("helper content") + with open( + os.path.join(test_dir, "src", "components", "ui", "Button.test.tsx"), "w" + ) as f: + f.write("test content") + + files = get_all_files_in_path("src", test_dir, ["**/ui/**"]) + + assert ( + len(files) == 7 + ) # 3 files + 4 directories (src, components, forms, utils) + assert any("index.ts" in f for f in files) + assert any("Input.tsx" in f for f in files) + assert any("helper.ts" in f for f in files) + assert not any("Button.tsx" in f for f in files) + assert not any("Button.test.tsx" in f for f in files) diff --git a/tests/shared/template/utils/test_get_caller_directory.py b/tests/shared/template/utils/test_get_caller_directory.py new file mode 100644 index 0000000..ba31413 --- /dev/null +++ b/tests/shared/template/utils/test_get_caller_directory.py @@ -0,0 +1,6 @@ +import os +from e2b.template.utils import get_caller_directory + + +def test_get_caller_directory(): + assert get_caller_directory(1) == os.path.dirname(__file__) diff --git a/tests/shared/template/utils/test_tar_file_stream.py b/tests/shared/template/utils/test_tar_file_stream.py new file mode 100644 index 0000000..672b937 --- /dev/null +++ b/tests/shared/template/utils/test_tar_file_stream.py @@ -0,0 +1,148 @@ +import os +import tempfile +import tarfile +import io +import pytest +from e2b.template.utils import tar_file_stream + + +class TestTarFileStream: + @pytest.fixture + def test_dir(self): + """Create a temporary directory for testing.""" + tmpdir = tempfile.TemporaryDirectory() + yield tmpdir.name + tmpdir.cleanup() + + def _extract_tar_contents(self, tar_buffer: io.BytesIO) -> dict: + """Extract tar contents into a dictionary mapping paths to file contents.""" + tar_buffer.seek(0) + contents = {} + with tarfile.open(fileobj=tar_buffer, mode="r:gz") as tar: + for member in tar.getmembers(): + if member.isfile(): + file_obj = tar.extractfile(member) + if file_obj: + contents[member.name] = file_obj.read() + elif member.isdir(): + contents[member.name] = None # Mark as directory + return contents + + def test_should_create_tar_with_simple_files(self, test_dir): + """Test that function creates tar with simple files.""" + # Create test files + file1_path = os.path.join(test_dir, "file1.txt") + file2_path = os.path.join(test_dir, "file2.txt") + + with open(file1_path, "w") as f: + f.write("content1") + with open(file2_path, "w") as f: + f.write("content2") + + tar_buffer = tar_file_stream("*.txt", test_dir, [], False) + contents = self._extract_tar_contents(tar_buffer) + + assert len(contents) == 2 + assert "file1.txt" in contents + assert "file2.txt" in contents + assert contents["file1.txt"] == b"content1" + assert contents["file2.txt"] == b"content2" + + def test_should_respect_ignore_patterns(self, test_dir): + """Test that function respects ignore patterns.""" + # Create test files + with open(os.path.join(test_dir, "file1.txt"), "w") as f: + f.write("content1") + with open(os.path.join(test_dir, "file2.txt"), "w") as f: + f.write("content2") + with open(os.path.join(test_dir, "temp.txt"), "w") as f: + f.write("temp content") + with open(os.path.join(test_dir, "backup.txt"), "w") as f: + f.write("backup content") + + tar_buffer = tar_file_stream("*.txt", test_dir, ["temp*", "backup*"], False) + contents = self._extract_tar_contents(tar_buffer) + + assert len(contents) == 2 + assert "file1.txt" in contents + assert "file2.txt" in contents + assert contents["file1.txt"] == b"content1" + assert contents["file2.txt"] == b"content2" + assert "temp.txt" not in contents + assert "backup.txt" not in contents + + def test_should_handle_nested_files(self, test_dir): + """Test that function handles nested directory structures.""" + # Create nested directory structure + nested_dir = os.path.join(test_dir, "src", "components") + os.makedirs(nested_dir, exist_ok=True) + + with open(os.path.join(test_dir, "src", "index.ts"), "w") as f: + f.write("index content") + with open(os.path.join(nested_dir, "Button.tsx"), "w") as f: + f.write("button content") + + tar_buffer = tar_file_stream("src", test_dir, [], False) + contents = self._extract_tar_contents(tar_buffer) + + # Should include the directory and files + assert "src/index.ts" in contents + assert "src/components/Button.tsx" in contents + + def test_should_resolve_symlinks_when_enabled(self, test_dir): + """Test that function resolves symlinks when resolve_symlinks=True.""" + if not hasattr(os, "symlink"): + pytest.skip("Symlinks not supported on this platform") + + # Create original file + original_path = os.path.join(test_dir, "original.txt") + with open(original_path, "w") as f: + f.write("original content") + + # Create symlink + symlink_path = os.path.join(test_dir, "link.txt") + os.symlink("original.txt", symlink_path) + + # Test with resolve_symlinks=True + tar_buffer = tar_file_stream("*.txt", test_dir, [], True) + contents = self._extract_tar_contents(tar_buffer) + + # Both files should be in tar + assert "original.txt" in contents + assert "link.txt" in contents + # Symlink should be resolved (contain actual content, not link) + assert contents["original.txt"] == b"original content" + assert contents["link.txt"] == b"original content" + + def test_should_preserve_symlinks_when_disabled(self, test_dir): + """Test that function preserves symlinks when resolve_symlinks=False.""" + if not hasattr(os, "symlink"): + pytest.skip("Symlinks not supported on this platform") + + # Create original file + original_path = os.path.join(test_dir, "original.txt") + with open(original_path, "w") as f: + f.write("original content") + + # Create symlink + symlink_path = os.path.join(test_dir, "link.txt") + os.symlink("original.txt", symlink_path) + + # Test with resolve_symlinks=False + tar_buffer = tar_file_stream("*.txt", test_dir, [], False) + tar_buffer.seek(0) + + with tarfile.open(fileobj=tar_buffer, mode="r:gz") as tar: + members = {m.name: m for m in tar.getmembers()} + + # Both files should be in tar + assert "original.txt" in members + assert "link.txt" in members + + # Original should be a regular file + assert members["original.txt"].isfile() + assert not members["original.txt"].issym() + + # Link should be a symlink + assert members["link.txt"].issym() + assert members["link.txt"].linkname == "original.txt" diff --git a/tests/sync/api_sync/test_sbx_info.py b/tests/sync/api_sync/test_sbx_info.py new file mode 100644 index 0000000..e9b362e --- /dev/null +++ b/tests/sync/api_sync/test_sbx_info.py @@ -0,0 +1,9 @@ +import pytest + +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_get_info(sandbox: Sandbox): + info = Sandbox.get_info(sandbox.sandbox_id) + assert info.sandbox_id == sandbox.sandbox_id diff --git a/tests/sync/api_sync/test_sbx_kill.py b/tests/sync/api_sync/test_sbx_kill.py new file mode 100644 index 0000000..dfc5876 --- /dev/null +++ b/tests/sync/api_sync/test_sbx_kill.py @@ -0,0 +1,21 @@ +import pytest + +from e2b import Sandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +def test_kill_existing_sandbox(sandbox: Sandbox, sandbox_test_id: str): + assert Sandbox.kill(sandbox.sandbox_id) + + paginator = Sandbox.list( + query=SandboxQuery( + state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id} + ) + ) + sandboxes = paginator.next_items() + assert sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] + + +@pytest.mark.skip_debug() +def test_kill_non_existing_sandbox(): + assert not Sandbox.kill("non-existing-sandbox") diff --git a/tests/sync/api_sync/test_sbx_list.py b/tests/sync/api_sync/test_sbx_list.py new file mode 100644 index 0000000..35e4d5a --- /dev/null +++ b/tests/sync/api_sync/test_sbx_list.py @@ -0,0 +1,199 @@ +import time + +import pytest + +from e2b import Sandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +def test_list_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + paginator = Sandbox.list( + query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id}) + ) + sandboxes = paginator.next_items() + assert len(sandboxes) >= 1 + assert sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes] + + +@pytest.mark.skip_debug() +def test_list_sandboxes_with_filter(sandbox_test_id: str): + unique_id = str(int(time.time())) + extra_sbx = Sandbox.create(metadata={"unique_id": unique_id}) + + try: + paginator = Sandbox.list(query=SandboxQuery(metadata={"unique_id": unique_id})) + sandboxes = paginator.next_items() + assert len(sandboxes) == 1 + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + finally: + extra_sbx.kill() + + +@pytest.mark.skip_debug() +def test_list_running_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + paginator = Sandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.RUNNING] + ) + ) + sandboxes = paginator.next_items() + assert len(sandboxes) >= 1 + + # Verify our running sandbox is in the list + assert any( + s.sandbox_id == sandbox.sandbox_id and s.state == SandboxState.RUNNING + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +def test_list_paused_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + sandbox.beta_pause() + + paginator = Sandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.PAUSED] + ) + ) + sandboxes = paginator.next_items() + assert len(sandboxes) >= 1 + + # Verify our paused sandbox is in the list + paused_sandbox_id = sandbox.sandbox_id.split("-")[0] + assert any( + s.sandbox_id.startswith(paused_sandbox_id) and s.state == SandboxState.PAUSED + for s in sandboxes + ) + + +@pytest.mark.skip_debug() +def test_paginate_running_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + # Create two sandboxes + extra_sbx = Sandbox.create(metadata={"sandbox_test_id": sandbox_test_id}) + + try: + # Test pagination with limit + paginator = Sandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, + state=[SandboxState.RUNNING], + ), + limit=1, + ) + + sandboxes = paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.RUNNING + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id + + # Get second page + sandboxes = paginator.next_items() + + # Check second page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.RUNNING + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes[0].sandbox_id == sandbox.sandbox_id + finally: + extra_sbx.kill() + + +@pytest.mark.skip_debug() +def test_paginate_paused_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + sandbox_id = sandbox.sandbox_id.split("-")[0] + sandbox.beta_pause() + + # create another paused sandbox + extra_sbx = Sandbox.create(metadata={"sandbox_test_id": sandbox_test_id}) + extra_sbx_id = extra_sbx.sandbox_id.split("-")[0] + extra_sbx.beta_pause() + + try: + # Test pagination with limit + paginator = Sandbox.list( + query=SandboxQuery( + state=[SandboxState.PAUSED], + metadata={"sandbox_test_id": sandbox_test_id}, + ), + limit=1, + ) + + sandboxes = paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id.startswith(extra_sbx_id) is True + + # Get second page + sandboxes = paginator.next_items() + + # Check second page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes[0].sandbox_id.startswith(sandbox_id) is True + finally: + extra_sbx.kill() + + +@pytest.mark.skip_debug() +def test_paginate_running_and_paused_sandboxes(sandbox: Sandbox, sandbox_test_id: str): + # Create extra paused sandbox + extra_sbx = Sandbox.create(metadata={"sandbox_test_id": sandbox_test_id}) + extra_sbx_id = extra_sbx.sandbox_id.split("-")[0] + extra_sbx.beta_pause() + + try: + # Test pagination with limit + paginator = Sandbox.list( + query=SandboxQuery( + metadata={"sandbox_test_id": sandbox_test_id}, + state=[SandboxState.RUNNING, SandboxState.PAUSED], + ), + limit=1, + ) + + sandboxes = paginator.next_items() + + # Check first page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.PAUSED + assert paginator.has_next is True + assert paginator.next_token is not None + assert sandboxes[0].sandbox_id.startswith(extra_sbx_id) is True + + # Get second page + sandboxes = paginator.next_items() + + # Check second page + assert len(sandboxes) == 1 + assert sandboxes[0].state == SandboxState.RUNNING + assert paginator.has_next is False + assert paginator.next_token is None + assert sandboxes[0].sandbox_id == sandbox.sandbox_id + finally: + extra_sbx.kill() + + +@pytest.mark.skip_debug() +def test_paginate_iterator(sandbox: Sandbox, sandbox_test_id: str): + paginator = Sandbox.list( + query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id}) + ) + sandboxes_list = [] + + while paginator.has_next: + sandboxes = paginator.next_items() + sandboxes_list.extend(sandboxes) + + assert len(sandboxes_list) > 0 + assert sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes_list] diff --git a/tests/sync/api_sync/test_sbx_snapshot.py b/tests/sync/api_sync/test_sbx_snapshot.py new file mode 100644 index 0000000..b87b8d4 --- /dev/null +++ b/tests/sync/api_sync/test_sbx_snapshot.py @@ -0,0 +1,19 @@ +import pytest +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_pause_sandbox(sandbox: Sandbox): + Sandbox.beta_pause(sandbox.sandbox_id) + assert not sandbox.is_running() + + +@pytest.mark.skip_debug() +def test_resume_sandbox(sandbox: Sandbox): + # pause + Sandbox.beta_pause(sandbox.sandbox_id) + assert not sandbox.is_running() + + # resume + Sandbox.connect(sandbox.sandbox_id) + assert sandbox.is_running() diff --git a/tests/sync/sandbox_sync/commands/test_cmd_connect.py b/tests/sync/sandbox_sync/commands/test_cmd_connect.py new file mode 100644 index 0000000..eb70569 --- /dev/null +++ b/tests/sync/sandbox_sync/commands/test_cmd_connect.py @@ -0,0 +1,18 @@ +import pytest + +from e2b import NotFoundException + + +def test_connect_to_process(sandbox): + cmd = sandbox.commands.run("sleep 10", background=True) + pid = cmd.pid + + process_info = sandbox.commands.connect(pid) + assert process_info.pid == pid + + +def test_connect_to_non_existing_process(sandbox): + non_existing_pid = 999999 + + with pytest.raises(NotFoundException): + sandbox.commands.connect(non_existing_pid) diff --git a/tests/sync/sandbox_sync/commands/test_cmd_kill.py b/tests/sync/sandbox_sync/commands/test_cmd_kill.py new file mode 100644 index 0000000..45e7ca2 --- /dev/null +++ b/tests/sync/sandbox_sync/commands/test_cmd_kill.py @@ -0,0 +1,19 @@ +import pytest + +from e2b import Sandbox, CommandExitException + + +def test_kill_process(sandbox: Sandbox): + cmd = sandbox.commands.run("sleep 10", background=True) + pid = cmd.pid + + sandbox.commands.kill(pid) + + with pytest.raises(CommandExitException): + sandbox.commands.run(f"kill -0 {pid}") + + +def test_kill_non_existing_process(sandbox): + non_existing_pid = 999999 + + assert not sandbox.commands.kill(non_existing_pid) diff --git a/tests/sync/sandbox_sync/commands/test_cmd_list.py b/tests/sync/sandbox_sync/commands/test_cmd_list.py new file mode 100644 index 0000000..30bedb5 --- /dev/null +++ b/tests/sync/sandbox_sync/commands/test_cmd_list.py @@ -0,0 +1,13 @@ +from e2b import Sandbox + + +def test_kill_process(sandbox: Sandbox): + c1 = sandbox.commands.run("sleep 10", background=True) + c2 = sandbox.commands.run("sleep 10", background=True) + + processes = sandbox.commands.list() + + assert len(processes) >= 2 + pids = [p.pid for p in processes] + assert c1.pid in pids + assert c2.pid in pids diff --git a/tests/sync/sandbox_sync/commands/test_env_vars.py b/tests/sync/sandbox_sync/commands/test_env_vars.py new file mode 100644 index 0000000..a9752de --- /dev/null +++ b/tests/sync/sandbox_sync/commands/test_env_vars.py @@ -0,0 +1,35 @@ +import pytest + +from e2b import Sandbox + + +def test_command_envs(sandbox: Sandbox): + cmd = sandbox.commands.run("echo $FOO", envs={"FOO": "bar"}) + assert cmd.stdout.strip() == "bar" + + +@pytest.mark.skip_debug() +def test_sandbox_envs(sandbox_factory): + sbx = sandbox_factory(envs={"FOO": "bar"}) + + cmd = sbx.commands.run("echo $FOO") + assert cmd.stdout.strip() == "bar" + + +def test_bash_command_scoped_env_vars(sandbox: Sandbox): + cmd = sandbox.commands.run("echo $FOO", envs={"FOO": "bar"}) + assert cmd.exit_code == 0 + assert cmd.stdout.strip() == "bar" + + # test that it is secure and not accessible to subsequent commands + cmd2 = sandbox.commands.run('sudo echo "$FOO"') + assert cmd2.exit_code == 0 + assert cmd2.stdout.strip() == "" + + +def test_python_command_scoped_env_vars(sandbox: Sandbox): + cmd = sandbox.commands.run( + "python3 -c \"import os; print(os.environ['FOO'])\"", envs={"FOO": "bar"} + ) + assert cmd.exit_code == 0 + assert cmd.stdout.strip() == "bar" diff --git a/tests/sync/sandbox_sync/commands/test_run.py b/tests/sync/sandbox_sync/commands/test_run.py new file mode 100644 index 0000000..e75e5ba --- /dev/null +++ b/tests/sync/sandbox_sync/commands/test_run.py @@ -0,0 +1,58 @@ +import pytest + +from e2b import Sandbox, TimeoutException + + +def test_run(sandbox: Sandbox): + text = "Hello, World!" + + cmd = sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +def test_run_with_special_characters(sandbox: Sandbox): + text = "!@#$%^&*()_+" + + cmd = sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +def test_run_with_broken_utf8(sandbox: Sandbox): + # Create a string with 8191 'a' characters followed by the problematic byte 0xe2 + long_str = "a" * 8191 + "\\xe2" + result = sandbox.commands.run(f'printf "{long_str}"') + assert result.exit_code == 0 + + # The broken UTF-8 bytes should be replaced with the Unicode replacement character + assert result.stdout == ("a" * 8191 + "\ufffd") + + +def test_run_with_multiline_string(sandbox): + text = "Hello,\nWorld!" + + cmd = sandbox.commands.run(f'echo "{text}"') + + assert cmd.exit_code == 0 + assert cmd.stdout == f"{text}\n" + + +def test_run_with_timeout(sandbox): + cmd = sandbox.commands.run('echo "Hello, World!"', timeout=10) + + assert cmd.exit_code == 0 + + +def test_run_with_too_short_timeout(sandbox): + with pytest.raises(TimeoutException): + sandbox.commands.run("sleep 10", timeout=2) + + +def test_run_with_too_short_timeout_iterating(sandbox): + cmd = sandbox.commands.run("sleep 10", timeout=2, background=True) + with pytest.raises(TimeoutException): + for _ in cmd: + pass diff --git a/tests/sync/sandbox_sync/commands/test_send_stdin.py b/tests/sync/sandbox_sync/commands/test_send_stdin.py new file mode 100644 index 0000000..bb6eb93 --- /dev/null +++ b/tests/sync/sandbox_sync/commands/test_send_stdin.py @@ -0,0 +1,28 @@ +from e2b import Sandbox + + +def test_send_stdin_to_process(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + sandbox.commands.send_stdin(cmd.pid, "Hello, World!") + + for stdout, _, _ in cmd: + assert stdout == "Hello, World!" + break + + +def test_send_special_characters_to_process(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + sandbox.commands.send_stdin(cmd.pid, "!@#$%^&*()_+") + + for stdout, _, _ in cmd: + assert stdout == "!@#$%^&*()_+" + break + + +def test_send_multiline_string_to_process(sandbox: Sandbox): + cmd = sandbox.commands.run("cat", background=True, stdin=True) + sandbox.commands.send_stdin(cmd.pid, "Hello,\nWorld!") + + for stdout, _, _ in cmd: + assert stdout == "Hello,\nWorld!" + break diff --git a/tests/sync/sandbox_sync/files/test_exists.py b/tests/sync/sandbox_sync/files/test_exists.py new file mode 100644 index 0000000..2a04aec --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_exists.py @@ -0,0 +1,8 @@ +from e2b import Sandbox + + +def test_exists(sandbox: Sandbox): + filename = "test_exists.txt" + + sandbox.files.write(filename, "test") + assert sandbox.files.exists(filename) diff --git a/tests/sync/sandbox_sync/files/test_files_list.py b/tests/sync/sandbox_sync/files/test_files_list.py new file mode 100644 index 0000000..f4fbfeb --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_files_list.py @@ -0,0 +1,246 @@ +import uuid + +from e2b import Sandbox, FileType + + +def test_list_directory(sandbox: Sandbox): + home_dir_name = "/home/user" + parent_dir_name = f"test_directory_{uuid.uuid4()}" + + sandbox.files.make_dir(parent_dir_name) + sandbox.files.make_dir(f"{parent_dir_name}/subdir1") + sandbox.files.make_dir(f"{parent_dir_name}/subdir2") + sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_1") + sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_2") + sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_1") + sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_2") + sandbox.files.write(f"{parent_dir_name}/file1.txt", "Hello, world!") + + test_cases = [ + { + "name": "default depth (1)", + "depth": None, + "expected_len": 3, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir2", + ], + }, + { + "name": "explicit depth 1", + "depth": 1, + "expected_len": 3, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir2", + ], + }, + { + "name": "explicit depth 2", + "depth": 2, + "expected_len": 7, + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + ], + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir1_1", + "subdir1_2", + "subdir2", + "subdir2_1", + "subdir2_2", + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2", + f"{home_dir_name}/{parent_dir_name}/subdir2", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2", + ], + }, + { + "name": "explicit depth 3 (should be the same as depth 2)", + "depth": 3, + "expected_len": 7, + "expected_file_names": [ + "file1.txt", + "subdir1", + "subdir1_1", + "subdir1_2", + "subdir2", + "subdir2_1", + "subdir2_2", + ], + "expected_file_types": [ + FileType.FILE, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + FileType.DIR, + ], + "expected_file_paths": [ + f"{home_dir_name}/{parent_dir_name}/file1.txt", + f"{home_dir_name}/{parent_dir_name}/subdir1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1", + f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2", + f"{home_dir_name}/{parent_dir_name}/subdir2", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1", + f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2", + ], + }, + ] + + for test_case in test_cases: + files = sandbox.files.list( + parent_dir_name, + depth=test_case["depth"] if test_case["depth"] is not None else None, + ) + + assert len(files) == test_case["expected_len"] + + for i in range(len(test_case["expected_file_names"])): + assert files[i].name == test_case["expected_file_names"][i] + assert files[i].path == test_case["expected_file_paths"][i] + assert files[i].type == test_case["expected_file_types"][i] + + sandbox.files.remove(parent_dir_name) + + +def test_list_directory_error_cases(sandbox: Sandbox): + parent_dir_name = f"test_directory_{uuid.uuid4()}" + sandbox.files.make_dir(parent_dir_name) + + expected_error_message = "depth should be at least 1" + try: + sandbox.files.list(parent_dir_name, depth=-1) + assert False, "Expected error but none was thrown" + except Exception as err: + assert expected_error_message in str(err), ( + f'expected error message to include "{expected_error_message}"' + ) + + sandbox.files.remove(parent_dir_name) + + +def test_file_entry_details(sandbox: Sandbox): + test_dir = "test-file-entry" + file_path = f"{test_dir}/test.txt" + content = "Hello, World!" + + sandbox.files.make_dir(test_dir) + sandbox.files.write(file_path, content) + + files = sandbox.files.list(test_dir, depth=1) + assert len(files) == 1 + + file_entry = files[0] + assert file_entry.name == "test.txt" + assert file_entry.path == f"/home/user/{file_path}" + assert file_entry.type == FileType.FILE + assert file_entry.mode == 0o644 + assert file_entry.permissions == "-rw-r--r--" + assert file_entry.owner == "user" + assert file_entry.group == "user" + assert file_entry.size == len(content) + assert file_entry.modified_time is not None + assert file_entry.symlink_target is None + + sandbox.files.remove(test_dir) + + +def test_directory_entry_details(sandbox: Sandbox): + test_dir = "test-entry-info" + sub_dir = f"{test_dir}/subdir" + + sandbox.files.make_dir(test_dir) + sandbox.files.make_dir(sub_dir) + + files = sandbox.files.list(test_dir, depth=1) + assert len(files) == 1 + + dir_entry = files[0] + assert dir_entry.name == "subdir" + assert dir_entry.path == f"/home/user/{sub_dir}" + assert dir_entry.type == FileType.DIR + assert dir_entry.mode == 0o755 + assert dir_entry.permissions == "drwxr-xr-x" + assert dir_entry.owner == "user" + assert dir_entry.group == "user" + assert dir_entry.modified_time is not None + + sandbox.files.remove(test_dir) + + +def test_mixed_entries(sandbox: Sandbox): + test_dir = "test-mixed-entries" + sub_dir = f"{test_dir}/subdir" + file_path = f"{test_dir}/test.txt" + content = "Hello, World!" + + sandbox.files.make_dir(test_dir) + sandbox.files.make_dir(sub_dir) + sandbox.files.write(file_path, content) + + files = sandbox.files.list(test_dir, depth=1) + assert len(files) == 2 + + # Create a dictionary of entries by name for easier verification + entries = {entry.name: entry for entry in files} + + # Verify directory entry + dir_entry = entries.get("subdir") + assert dir_entry is not None + assert dir_entry.path == f"/home/user/{sub_dir}" + assert dir_entry.type == FileType.DIR + assert dir_entry.mode == 0o755 + assert dir_entry.permissions == "drwxr-xr-x" + assert dir_entry.owner == "user" + assert dir_entry.group == "user" + assert dir_entry.modified_time is not None + + # Verify file entry + file_entry = entries.get("test.txt") + assert file_entry is not None + assert file_entry.path == f"/home/user/{file_path}" + assert file_entry.type == FileType.FILE + assert file_entry.mode == 0o644 + assert file_entry.permissions == "-rw-r--r--" + assert file_entry.owner == "user" + assert file_entry.group == "user" + assert file_entry.size == len(content) + assert file_entry.modified_time is not None + + sandbox.files.remove(test_dir) diff --git a/tests/sync/sandbox_sync/files/test_info.py b/tests/sync/sandbox_sync/files/test_info.py new file mode 100644 index 0000000..35e18ac --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_info.py @@ -0,0 +1,73 @@ +import pytest +from e2b.exceptions import NotFoundException +from e2b import Sandbox, FileType + + +def test_get_info_of_file(sandbox: Sandbox): + filename = "test_file.txt" + + sandbox.files.write(filename, "test") + info = sandbox.files.get_info(filename) + current_path = sandbox.commands.run("pwd") + + assert info.name == filename + assert info.type == FileType.FILE + assert info.path == f"{current_path.stdout.strip()}/{filename}" + assert info.size == 4 + assert info.mode == 0o644 + assert info.permissions == "-rw-r--r--" + assert info.owner == "user" + assert info.group == "user" + assert info.modified_time is not None + + +def test_get_info_of_nonexistent_file(sandbox: Sandbox): + filename = "test_does_not_exist.txt" + + with pytest.raises(NotFoundException): + sandbox.files.get_info(filename) + + +def test_get_info_of_directory(sandbox: Sandbox): + dirname = "test_dir" + + sandbox.files.make_dir(dirname) + info = sandbox.files.get_info(dirname) + current_path = sandbox.commands.run("pwd") + + assert info.name == dirname + assert info.type == FileType.DIR + assert info.path == f"{current_path.stdout.strip()}/{dirname}" + assert info.size > 0 + assert info.mode == 0o755 + assert info.permissions == "drwxr-xr-x" + assert info.owner == "user" + assert info.group == "user" + assert info.modified_time is not None + + +def test_get_info_of_nonexistent_directory(sandbox: Sandbox): + dirname = "test_does_not_exist_dir" + + with pytest.raises(NotFoundException): + sandbox.files.get_info(dirname) + + +def test_file_symlink(sandbox: Sandbox): + test_dir = "test-simlink-entry" + file_name = "test.txt" + content = "Hello, World!" + + sandbox.files.make_dir(test_dir) + sandbox.files.write(f"{test_dir}/{file_name}", content) + + symlink_name = "symlink_to_test.txt" + sandbox.commands.run(f"ln -s {file_name} {symlink_name}", cwd=test_dir) + + file = sandbox.files.get_info(f"{test_dir}/{symlink_name}") + + pwd = sandbox.commands.run("pwd") + assert file.type == FileType.FILE + assert file.symlink_target == f"{pwd.stdout.strip()}/{test_dir}/{file_name}" + + sandbox.files.remove(test_dir) diff --git a/tests/sync/sandbox_sync/files/test_make_dir.py b/tests/sync/sandbox_sync/files/test_make_dir.py new file mode 100644 index 0000000..9251e3d --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_make_dir.py @@ -0,0 +1,29 @@ +import uuid + +from e2b import Sandbox + + +def test_make_directory(sandbox: Sandbox): + dir_name = f"test_directory_{uuid.uuid4()}" + + sandbox.files.make_dir(dir_name) + exists = sandbox.files.exists(dir_name) + assert exists + + +async def test_make_directory_already_exists(sandbox: Sandbox): + dir_name = f"test_directory_{uuid.uuid4()}" + + created = sandbox.files.make_dir(dir_name) + assert created + + created = sandbox.files.make_dir(dir_name) + assert not created + + +def test_make_nested_directory(sandbox: Sandbox): + nested_dir_name = f"test_directory_{uuid.uuid4()}/nested_directory" + + sandbox.files.make_dir(nested_dir_name) + exists = sandbox.files.exists(nested_dir_name) + assert exists diff --git a/tests/sync/sandbox_sync/files/test_read.py b/tests/sync/sandbox_sync/files/test_read.py new file mode 100644 index 0000000..d889e80 --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_read.py @@ -0,0 +1,27 @@ +import pytest +from e2b import NotFoundException + + +def test_read_file(sandbox): + filename = "test_read.txt" + content = "Hello, world!" + + sandbox.files.write(filename, content) + read_content = sandbox.files.read(filename) + assert read_content == content + + +def test_read_non_existing_file(sandbox): + filename = "non_existing_file.txt" + + with pytest.raises(NotFoundException): + sandbox.files.read(filename) + + +def test_read_empty_file(sandbox): + filename = "empty_file.txt" + content = "" + + sandbox.commands.run(f"touch {filename}") + read_content = sandbox.files.read(filename) + assert read_content == content diff --git a/tests/sync/sandbox_sync/files/test_remove.py b/tests/sync/sandbox_sync/files/test_remove.py new file mode 100644 index 0000000..17e07cf --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_remove.py @@ -0,0 +1,18 @@ +from e2b import Sandbox + + +def test_remove_file(sandbox: Sandbox): + filename = "test_remove.txt" + content = "This file will be removed." + + sandbox.files.write(filename, content) + + sandbox.files.remove(filename) + + exists = sandbox.files.exists(filename) + assert not exists + + +def test_remove_non_existing_file(sandbox): + filename = "non_existing_file.txt" + sandbox.files.remove(filename) diff --git a/tests/sync/sandbox_sync/files/test_rename.py b/tests/sync/sandbox_sync/files/test_rename.py new file mode 100644 index 0000000..1c763fb --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_rename.py @@ -0,0 +1,28 @@ +import pytest +from e2b import NotFoundException, Sandbox + + +def test_rename_file(sandbox: Sandbox): + old_filename = "test_rename_old.txt" + new_filename = "test_rename_new.txt" + content = "This file will be renamed." + + sandbox.files.write(old_filename, content) + + info = sandbox.files.rename(old_filename, new_filename) + assert info.path == f"/home/user/{new_filename}" + + exists_old = sandbox.files.exists(old_filename) + exists_new = sandbox.files.exists(new_filename) + assert not exists_old + assert exists_new + read_content = sandbox.files.read(new_filename) + assert read_content == content + + +def test_rename_non_existing_file(sandbox): + old_filename = "non_existing_file.txt" + new_filename = "new_non_existing_file.txt" + + with pytest.raises(NotFoundException): + sandbox.files.rename(old_filename, new_filename) diff --git a/tests/sync/sandbox_sync/files/test_secured.py b/tests/sync/sandbox_sync/files/test_secured.py new file mode 100644 index 0000000..4fa5969 --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_secured.py @@ -0,0 +1,59 @@ +import urllib.request +import urllib.error +import json +import pytest + + +@pytest.mark.skip_debug() +def test_download_url_with_signing(sandbox_factory): + sbx = sandbox_factory(timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + sbx.files.write(file_path, file_content) + signed_url = sbx.download_url(file_path, "user") + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + + +@pytest.mark.skip_debug() +def test_download_url_with_signing_and_expiration(sandbox_factory): + sbx = sandbox_factory(timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + sbx.files.write(file_path, file_content) + signed_url = sbx.download_url(file_path, "user", 120) + + with urllib.request.urlopen(signed_url) as resp: + assert resp.status == 200 + body_bytes = resp.read() + body_text = body_bytes.decode() + assert body_text == file_content + + +@pytest.mark.skip_debug() +def test_download_url_with_expired_signing(sandbox_factory): + sbx = sandbox_factory(timeout=100, secure=True) + file_path = "test_download_url_with_signing.txt" + file_content = "This file will be watched." + + sbx.files.write(file_path, file_content) + + signed_url = sbx.download_url(file_path, "user", use_signature_expiration=-120) + + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(signed_url) + + err = exc_info.value + assert err.code == 401, f"Unexpected status {err.code}" + + error_json_str = err.read().decode() # bytes ➜ str + error_payload = json.loads(error_json_str) # str ➜ dict + + expected_payload = {"code": 401, "message": "signature is already expired"} + assert error_payload == expected_payload diff --git a/tests/sync/sandbox_sync/files/test_watch.py b/tests/sync/sandbox_sync/files/test_watch.py new file mode 100644 index 0000000..a6c433d --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_watch.py @@ -0,0 +1,118 @@ +import pytest + +from e2b import NotFoundException, FilesystemEventType, Sandbox, SandboxException + + +def test_watch_directory_changes(sandbox: Sandbox): + dirname = "test_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + + sandbox.files.make_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", content) + + handle = sandbox.files.watch_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", content) + + events = handle.get_new_events() + assert events[0].type == FilesystemEventType.WRITE + assert events[0].name == filename + + handle.stop() + + +def test_watch_iterated(sandbox: Sandbox): + dirname = "test_watch_dir_iterated" + filename = "test_watch_iterated.txt" + content = "This file will be watched." + new_content = "This file has been modified." + + sandbox.files.make_dir(dirname) + handle = sandbox.files.watch_dir(dirname) + sandbox.files.write(f"{dirname}/{filename}", content) + + events = handle.get_new_events() + assert len(events) == 3 + + sandbox.files.write(f"{dirname}/{filename}", new_content) + events = handle.get_new_events() + for event in events: + if event.type == FilesystemEventType.WRITE and event.name == filename: + break + + handle.stop() + + +def test_watch_recursive_directory_changes(sandbox: Sandbox): + dirname = "test_recursive_watch_dir" + nested_dirname = "test_nested_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + + sandbox.files.remove(dirname) + sandbox.files.make_dir(f"{dirname}/{nested_dirname}") + + handle = sandbox.files.watch_dir(dirname, recursive=True) + sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content) + + events = handle.get_new_events() + assert len(events) == 3 + expected_filename = f"{nested_dirname}/{filename}" + assert events[0].type == FilesystemEventType.CREATE + assert events[0].name == expected_filename + + handle.stop() + + +def test_watch_recursive_directory_after_nested_folder_addition(sandbox: Sandbox): + dirname = "test_recursive_watch_dir_add" + nested_dirname = "test_nested_watch_dir" + filename = "test_watch.txt" + content = "This file will be watched." + + sandbox.files.remove(dirname) + sandbox.files.make_dir(dirname) + + handle = sandbox.files.watch_dir(dirname, recursive=True) + + sandbox.files.make_dir(f"{dirname}/{nested_dirname}") + sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content) + + expected_filename = f"{nested_dirname}/{filename}" + + events = handle.get_new_events() + file_changed = False + folder_created = False + for event in events: + if event.type == FilesystemEventType.WRITE and event.name == expected_filename: + file_changed = True + continue + if event.type == FilesystemEventType.CREATE and event.name == nested_dirname: + folder_created = True + + assert folder_created + assert file_changed + + handle.stop() + + +def test_watch_non_existing_directory(sandbox: Sandbox): + dirname = "non_existing_watch_dir" + + with pytest.raises(NotFoundException): + sandbox.files.watch_dir(dirname) + + +def test_watch_file(sandbox: Sandbox): + filename = "test_watch.txt" + sandbox.files.write(filename, "This file will be watched.") + + with pytest.raises(SandboxException): + sandbox.files.watch_dir(filename) + + +def test_watch_file_with_secured_envd(sandbox_factory): + sbx = sandbox_factory(timeout=30, secure=True) + + sbx.files.watch_dir("/home/user/") + sbx.files.write("test_watch.txt", "This file will be watched.") diff --git a/tests/sync/sandbox_sync/files/test_write.py b/tests/sync/sandbox_sync/files/test_write.py new file mode 100644 index 0000000..97e42e2 --- /dev/null +++ b/tests/sync/sandbox_sync/files/test_write.py @@ -0,0 +1,120 @@ +import io +import uuid + +from e2b.sandbox.filesystem.filesystem import WriteInfo, WriteEntry + + +def test_write_text_file(sandbox): + filename = "test_write.txt" + content = "This is a test file." + + info = sandbox.files.write(filename, content) + assert info.path == f"/home/user/{filename}" + + exists = sandbox.files.exists(filename) + assert exists + + read_content = sandbox.files.read(filename) + assert read_content == content + + +def test_write_binary_file(sandbox): + filename = "test_write.bin" + text = "This is a test binary file." + # equivalent to `open("path/to/local/file", "rb")` + content = io.BytesIO(text.encode("utf-8")) + + info = sandbox.files.write(filename, content) + assert info.path == f"/home/user/{filename}" + + exists = sandbox.files.exists(filename) + assert exists + + read_content = sandbox.files.read(filename) + assert read_content == text + + +def test_write_multiple_files(sandbox): + # Attempt to write with empty files array + empty_info = sandbox.files.write_files([]) + assert isinstance(empty_info, list) + assert len(empty_info) == 0 + + # Attempt to write with no files + assert sandbox.files.write_files([]) == [] + + # Attempt to write with one file in array + info = sandbox.files.write_files( + [WriteEntry(path="one_test_file.txt", data="This is a test file.")] + ) + assert isinstance(info, list) + assert len(info) == 1 + info = info[0] + assert isinstance(info, WriteInfo) + assert info.path == "/home/user/one_test_file.txt" + exists = sandbox.files.exists(info.path) + assert exists + + read_content = sandbox.files.read(info.path) + assert read_content == "This is a test file." + + # Attempt to write with multiple files in array + files = [] + for i in range(10): + path = f"test_write_{i}.txt" + content = f"This is a test file {i}." + files.append(WriteEntry(path=path, data=content)) + + infos = sandbox.files.write_files(files) + assert isinstance(infos, list) + assert len(infos) == len(files) + for i, info in enumerate(infos): + assert isinstance(info, WriteInfo) + assert info.path == f"/home/user/test_write_{i}.txt" + exists = sandbox.files.exists(path) + assert exists + + read_content = sandbox.files.read(info.path) + assert read_content == files[i]["data"] + + +def test_overwrite_file(sandbox): + filename = "test_overwrite.txt" + initial_content = "Initial content." + new_content = "New content." + + sandbox.files.write(filename, initial_content) + sandbox.files.write(filename, new_content) + read_content = sandbox.files.read(filename) + assert read_content == new_content + + +def test_write_to_non_existing_directory(sandbox): + filename = "non_existing_dir/test_write.txt" + content = "This should succeed too." + + sandbox.files.write(filename, content) + exists = sandbox.files.exists(filename) + assert exists + + read_content = sandbox.files.read(filename) + assert read_content == content + + +def test_write_with_secured_envd(sandbox_factory): + filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt" + content = "This should succeed too." + + sbx = sandbox_factory(timeout=30, secure=True) + + assert sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + sbx.files.write(filename, content) + + exists = sbx.files.exists(filename) + assert exists + + read_content = sbx.files.read(filename) + assert read_content == content diff --git a/tests/sync/sandbox_sync/pty/test_pty.py b/tests/sync/sandbox_sync/pty/test_pty.py new file mode 100644 index 0000000..1dd6255 --- /dev/null +++ b/tests/sync/sandbox_sync/pty/test_pty.py @@ -0,0 +1,17 @@ +from e2b import Sandbox +from e2b.sandbox.commands.command_handle import PtySize + + +def test_pty(sandbox: Sandbox): + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + terminal = sandbox.pty.create(PtySize(80, 24), envs={"ABC": "123"}, cwd="/") + + sandbox.pty.send_stdin(terminal.pid, b"echo $ABC\nexit\n") + + output = [] + result = terminal.wait(on_pty=lambda x: append_data(output, x)) + assert result.exit_code == 0 + + assert "123" in "\n".join(output) diff --git a/tests/sync/sandbox_sync/pty/test_resize.py b/tests/sync/sandbox_sync/pty/test_resize.py new file mode 100644 index 0000000..726b34e --- /dev/null +++ b/tests/sync/sandbox_sync/pty/test_resize.py @@ -0,0 +1,28 @@ +from e2b import Sandbox +from e2b.sandbox.commands.command_handle import PtySize + + +def test_resize(sandbox: Sandbox): + def append_data(data: list, x: bytes): + data.append(x.decode("utf-8")) + + terminal = sandbox.pty.create(PtySize(cols=80, rows=24)) + + sandbox.pty.send_stdin(terminal.pid, b"tput cols\nexit\n") + + output = [] + result = terminal.wait(on_pty=lambda x: append_data(output, x)) + assert result.exit_code == 0 + + assert "80" in "".join(output) + + terminal = sandbox.pty.create(PtySize(cols=80, rows=24)) + + sandbox.pty.resize(terminal.pid, PtySize(cols=100, rows=24)) + sandbox.pty.send_stdin(terminal.pid, b"tput cols\nexit\n") + + output = [] + result = terminal.wait(on_pty=lambda x: append_data(output, x)) + assert result.exit_code == 0 + + assert "100" in "".join(output) diff --git a/tests/sync/sandbox_sync/pty/test_send_input.py b/tests/sync/sandbox_sync/pty/test_send_input.py new file mode 100644 index 0000000..15c5df0 --- /dev/null +++ b/tests/sync/sandbox_sync/pty/test_send_input.py @@ -0,0 +1,9 @@ +from e2b import Sandbox +from e2b.sandbox.commands.command_handle import PtySize + + +def test_send_input(sandbox: Sandbox): + terminal = sandbox.pty.create(PtySize(cols=80, rows=24)) + sandbox.pty.send_stdin(terminal.pid, b"exit\n") + result = terminal.wait() + assert result.exit_code == 0 diff --git a/tests/sync/sandbox_sync/test_connect.py b/tests/sync/sandbox_sync/test_connect.py new file mode 100644 index 0000000..cce497d --- /dev/null +++ b/tests/sync/sandbox_sync/test_connect.py @@ -0,0 +1,70 @@ +import uuid +import pytest + +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_connect(sandbox_factory): + sbx = sandbox_factory(timeout=10) + + assert sbx.is_running() + + sbx_connection = Sandbox.connect(sbx.sandbox_id) + assert sbx_connection.is_running() + + +@pytest.mark.skip_debug() +def test_connect_with_secure(sandbox_factory): + dir_name = f"test_directory_{uuid.uuid4()}" + + sbx = sandbox_factory(timeout=10, secure=True) + + assert sbx.is_running() + + sbx_connection = Sandbox.connect(sbx.sandbox_id) + + sbx_connection.files.make_dir(dir_name) + files = sbx_connection.files.list(dir_name) + assert len(files) == 0 + + +@pytest.mark.skip_debug() +def test_connect_does_not_shorten_timeout_on_running_sandbox(template): + # Create sandbox with a 300 second timeout + sbx = Sandbox.create(template, timeout=300) + try: + assert sbx.is_running() + + # Get initial info to check end_at + info_before = Sandbox.get_info(sbx.sandbox_id) + + # Connect with a shorter timeout (10 seconds) + Sandbox.connect(sbx.sandbox_id, timeout=10) + + # Get info after connection + info_after = Sandbox.get_info(sbx.sandbox_id) + + # The end_at time should not have been shortened. It should be the same + assert info_after.end_at == info_before.end_at, ( + f"Timeout was shortened: before={info_before.end_at}, after={info_after.end_at}" + ) + finally: + sbx.kill() + + +@pytest.mark.skip_debug() +def test_connect_extends_timeout_on_running_sandbox(sandbox): + # Get initial info to check end_at + info_before = sandbox.get_info() + + # Connect with a longer timeout + Sandbox.connect(sandbox.sandbox_id, timeout=600) + + # Get info after connection + info_after = sandbox.get_info() + + # The end_at time should have been extended + assert info_after.end_at > info_before.end_at, ( + f"Timeout was not extended: before={info_before.end_at}, after={info_after.end_at}" + ) diff --git a/tests/sync/sandbox_sync/test_create.py b/tests/sync/sandbox_sync/test_create.py new file mode 100644 index 0000000..4edcdf6 --- /dev/null +++ b/tests/sync/sandbox_sync/test_create.py @@ -0,0 +1,28 @@ +import pytest + +from e2b import Sandbox +from e2b.sandbox.sandbox_api import SandboxQuery + + +@pytest.mark.skip_debug() +def test_start(sandbox_factory): + sbx = sandbox_factory(timeout=5) + + assert sbx.is_running() + assert sbx._envd_version is not None + + +@pytest.mark.skip_debug() +def test_metadata(sandbox_factory): + sbx = sandbox_factory(timeout=5, metadata={"test-key": "test-value"}) + + paginator = Sandbox.list(query=SandboxQuery(metadata={"test-key": "test-value"})) + sandboxes = paginator.next_items() + + for sbx_info in sandboxes: + if sbx.sandbox_id == sbx_info.sandbox_id: + assert sbx_info.metadata is not None + assert sbx_info.metadata["test-key"] == "test-value" + break + else: + assert False, "Sandbox not found" diff --git a/tests/sync/sandbox_sync/test_host.py b/tests/sync/sandbox_sync/test_host.py new file mode 100644 index 0000000..00edae4 --- /dev/null +++ b/tests/sync/sandbox_sync/test_host.py @@ -0,0 +1,24 @@ +import httpx + +from time import sleep + + +def test_ping_server(sandbox, debug, helpers): + cmd = sandbox.commands.run("python -m http.server 8001", background=True) + + try: + host = sandbox.get_host(8001) + status_code = None + for _ in range(20): + res = httpx.get(f"{'http' if debug else 'https'}://{host}") + status_code = res.status_code + if res.status_code == 200: + break + sleep(0.5) + + assert status_code == 200 + except Exception as e: + helpers.check_cmd_exit_error(cmd) + raise e + finally: + cmd.kill() diff --git a/tests/sync/sandbox_sync/test_internet_access.py b/tests/sync/sandbox_sync/test_internet_access.py new file mode 100644 index 0000000..fecb2f9 --- /dev/null +++ b/tests/sync/sandbox_sync/test_internet_access.py @@ -0,0 +1,38 @@ +import pytest + +from e2b.sandbox.commands.command_handle import CommandExitException + + +@pytest.mark.skip_debug() +def test_internet_access_enabled(sandbox_factory): + """Test that sandbox with internet access enabled can reach external websites.""" + sbx = sandbox_factory(allow_internet_access=True) + + # Test internet connectivity by making a curl request to a reliable external site + result = sbx.commands.run("curl -s -o /dev/null -w '%{http_code}' https://e2b.dev") + assert result.exit_code == 0 + assert result.stdout.strip() == "200" + + +@pytest.mark.skip_debug() +def test_internet_access_disabled(sandbox_factory): + """Test that sandbox with internet access disabled cannot reach external websites.""" + sbx = sandbox_factory(allow_internet_access=False) + + # Test that internet connectivity is blocked by making a curl request + with pytest.raises(CommandExitException) as exc_info: + sbx.commands.run("curl --connect-timeout 3 --max-time 5 -Is https://e2b.dev") + # The command should fail or timeout when internet access is disabled + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +def test_internet_access_default(sandbox): + """Test that sandbox with default settings (no explicit allow_internet_access) has internet access.""" + + # Test internet connectivity by making a curl request to a reliable external site + result = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://e2b.dev" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "200" diff --git a/tests/sync/sandbox_sync/test_kill.py b/tests/sync/sandbox_sync/test_kill.py new file mode 100644 index 0000000..45628e9 --- /dev/null +++ b/tests/sync/sandbox_sync/test_kill.py @@ -0,0 +1,16 @@ +import pytest + +from e2b import Sandbox, SandboxQuery, SandboxState + + +@pytest.mark.skip_debug() +def test_kill(sandbox: Sandbox, sandbox_test_id: str): + sandbox.kill() + + paginator = Sandbox.list( + query=SandboxQuery( + state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id} + ) + ) + sandboxes = paginator.next_items() + assert sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] diff --git a/tests/sync/sandbox_sync/test_metrics.py b/tests/sync/sandbox_sync/test_metrics.py new file mode 100644 index 0000000..6759201 --- /dev/null +++ b/tests/sync/sandbox_sync/test_metrics.py @@ -0,0 +1,25 @@ +import time + +import pytest + + +@pytest.mark.skip_debug() +def test_sbx_metrics(sandbox_factory) -> None: + sbx = sandbox_factory(timeout=60) + # Wait for the sandbox to have some metrics + metrics = [] + for _ in range(15): + metrics = sbx.get_metrics() + if len(metrics) > 0: + break + time.sleep(1) + + assert len(metrics) > 0 + + metric = metrics[0] + assert metric.cpu_count is not None + assert metric.cpu_used_pct is not None + assert metric.mem_used is not None + assert metric.mem_total is not None + assert metric.disk_used is not None + assert metric.disk_total is not None diff --git a/tests/sync/sandbox_sync/test_network.py b/tests/sync/sandbox_sync/test_network.py new file mode 100644 index 0000000..6e93f13 --- /dev/null +++ b/tests/sync/sandbox_sync/test_network.py @@ -0,0 +1,207 @@ +import pytest + +from e2b import ALL_TRAFFIC, SandboxNetworkOpts +from e2b.sandbox.commands.command_handle import CommandExitException + + +@pytest.mark.skip_debug() +def test_allow_specific_ip_with_deny_all(sandbox_factory): + """Test that sandbox with denyOut all and allowOut creates a whitelist.""" + sandbox = sandbox_factory( + network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC], allow_out=["1.1.1.1"]) + ) + + # Test that allowed IP works + result = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "301" + + # Test that other IPs are denied + with pytest.raises(CommandExitException) as exc_info: + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +def test_deny_specific_ip(sandbox_factory): + """Test that sandbox with denyOut denies specified IP addresses.""" + sandbox = sandbox_factory(network=SandboxNetworkOpts(deny_out=["8.8.8.8"])) + + # Test that denied IP fails + with pytest.raises(CommandExitException) as exc_info: + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + # Test that other IPs work + result = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "301" + + +@pytest.mark.skip_debug() +def test_deny_all_traffic(sandbox_factory): + """Test that sandbox can deny all traffic using all_traffic helper.""" + sandbox = sandbox_factory( + network=SandboxNetworkOpts(deny_out=[ALL_TRAFFIC]), timeout=30 + ) + + # Test that all traffic is denied + with pytest.raises(CommandExitException) as exc_info: + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://1.1.1.1" + ) + assert exc_info.value.exit_code != 0 + + with pytest.raises(CommandExitException) as exc_info: + sandbox.commands.run( + "curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8" + ) + assert exc_info.value.exit_code != 0 + + +@pytest.mark.skip_debug() +def test_allow_takes_precedence_over_deny(sandbox_factory): + """Test that allowOut takes precedence over denyOut.""" + sandbox = sandbox_factory( + network=SandboxNetworkOpts( + deny_out=[ALL_TRAFFIC], allow_out=["1.1.1.1", "8.8.8.8"] + ) + ) + + # Test that 1.1.1.1 works (explicitly allowed) + result1 = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert result1.exit_code == 0 + assert result1.stdout.strip() == "301" + + # Test that 8.8.8.8 also works (explicitly allowed, takes precedence over deny_out) + result2 = sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert result2.exit_code == 0 + assert result2.stdout.strip() == "302" + + +@pytest.mark.skip_debug() +def test_allow_public_traffic_false(sandbox_factory): + """Test that sandbox with allow_public_traffic=False requires traffic access token.""" + sandbox = sandbox_factory( + secure=True, network=SandboxNetworkOpts(allow_public_traffic=False) + ) + + import time + + import httpx + + # Verify the sandbox was created successfully and has a traffic access token + assert sandbox.traffic_access_token is not None + + # Start a simple HTTP server in the sandbox + port = 8080 + sandbox.commands.run( + f"python3 -m http.server {port}", + background=True, + ) + + # Wait for server to start + time.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{sandbox.get_host(port)}" + + with httpx.Client() as client: + # Test 1: Request without traffic access token should fail with 403 + response = client.get(sandbox_url, follow_redirects=True) + assert response.status_code == 403 + + # Test 2: Request with valid traffic access token should succeed + headers = {"e2b-traffic-access-token": sandbox.traffic_access_token} + response = client.get(sandbox_url, headers=headers, follow_redirects=True) + assert response.status_code == 200 + + +@pytest.mark.skip_debug() +def test_allow_public_traffic_true(sandbox_factory): + """Test that sandbox with allow_public_traffic=True works without token.""" + sandbox = sandbox_factory(network=SandboxNetworkOpts(allow_public_traffic=True)) + + import time + + import httpx + + # Start a simple HTTP server in the sandbox + port = 8080 + sandbox.commands.run( + f"python3 -m http.server {port}", + background=True, + ) + + # Wait for server to start + time.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{sandbox.get_host(port)}" + + with httpx.Client() as client: + # Request without traffic access token should succeed (public access enabled) + response = client.get(sandbox_url, follow_redirects=True) + assert response.status_code == 200 + + +@pytest.mark.skip_debug() +def test_mask_request_host(sandbox_factory): + """Test that mask_request_host modifies the Host header correctly.""" + sandbox = sandbox_factory( + network=SandboxNetworkOpts(mask_request_host="custom-host.example.com:${PORT}"), + timeout=60, + ) + + import time + + import httpx + + # Install netcat for testing + sandbox.commands.run("apt-get update", user="root") + sandbox.commands.run("apt-get install -y netcat-traditional", user="root") + + port = 8080 + output_file = "/tmp/nc_output.txt" + + # Start netcat listener in background to capture request headers + sandbox.commands.run( + f"nc -l -p {port} > {output_file}", + background=True, + user="root", + ) + + # Wait for netcat to start + time.sleep(3) + + # Get the public URL for the sandbox + sandbox_url = f"https://{sandbox.get_host(port)}" + + # Make a request from OUTSIDE the sandbox through the proxy + # The Host header should be modified according to mask_request_host + with httpx.Client() as client: + try: + client.get(sandbox_url, timeout=5.0) + except Exception: + # Request may fail since netcat doesn't respond properly, but headers are captured + pass + + # Read the captured output from inside the sandbox + result = sandbox.commands.run(f"cat {output_file}", user="root") + + # Verify the Host header was modified according to mask_request_host + assert "Host:" in result.stdout + assert "custom-host.example.com" in result.stdout + assert str(port) in result.stdout diff --git a/tests/sync/sandbox_sync/test_secure.py b/tests/sync/sandbox_sync/test_secure.py new file mode 100644 index 0000000..12bb426 --- /dev/null +++ b/tests/sync/sandbox_sync/test_secure.py @@ -0,0 +1,26 @@ +import pytest + +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_start_secured(sandbox_factory): + sbx = sandbox_factory(timeout=5, secure=True) + + assert sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + +@pytest.mark.skip_debug() +def test_connect_to_secured(sandbox_factory): + sbx = sandbox_factory(timeout=5, secure=True) + + assert sbx.is_running() + assert sbx._envd_version is not None + assert sbx._envd_access_token is not None + + sbx_connection = Sandbox.connect(sbx.sandbox_id) + assert sbx_connection.is_running() + assert sbx_connection._envd_version is not None + assert sbx_connection._envd_access_token is not None diff --git a/tests/sync/sandbox_sync/test_snapshot.py b/tests/sync/sandbox_sync/test_snapshot.py new file mode 100644 index 0000000..491f6ff --- /dev/null +++ b/tests/sync/sandbox_sync/test_snapshot.py @@ -0,0 +1,15 @@ +import pytest +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_snapshot(sandbox: Sandbox): + assert sandbox.is_running() + + sandbox.beta_pause() + assert not sandbox.is_running() + + resumed_sandbox = sandbox.connect() + assert sandbox.is_running() + assert resumed_sandbox.is_running() + assert resumed_sandbox.sandbox_id == sandbox.sandbox_id diff --git a/tests/sync/sandbox_sync/test_timeout.py b/tests/sync/sandbox_sync/test_timeout.py new file mode 100644 index 0000000..f175e7b --- /dev/null +++ b/tests/sync/sandbox_sync/test_timeout.py @@ -0,0 +1,28 @@ +from time import sleep +from datetime import datetime + +import pytest + + +@pytest.mark.skip_debug() +def test_shorten_timeout(sandbox): + sandbox.set_timeout(5) + sleep(6) + + is_running = sandbox.is_running(request_timeout=5) + assert is_running is False + + +@pytest.mark.skip_debug() +def test_shorten_then_lengthen_timeout(sandbox): + sandbox.set_timeout(5) + sleep(1) + sandbox.set_timeout(10) + sleep(6) + sandbox.is_running() + + +@pytest.mark.skip_debug() +def test_get_timeout(sandbox): + info = sandbox.get_info() + assert isinstance(info.end_at, datetime) diff --git a/tests/sync/template_sync/methods/test_apt_install.py b/tests/sync/template_sync/methods/test_apt_install.py new file mode 100644 index 0000000..a82b93c --- /dev/null +++ b/tests/sync/template_sync/methods/test_apt_install.py @@ -0,0 +1,24 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_apt_install(build): + template = ( + Template().from_image("ubuntu:24.04").skip_cache().apt_install("rolldice") + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_apt_install_no_install_recommends(build): + template = ( + Template() + .from_image("ubuntu:24.04") + .skip_cache() + .apt_install("rolldice", no_install_recommends=True) + ) + + build(template) diff --git a/tests/sync/template_sync/methods/test_bun_install.py b/tests/sync/template_sync/methods/test_bun_install.py new file mode 100644 index 0000000..398477a --- /dev/null +++ b/tests/sync/template_sync/methods/test_bun_install.py @@ -0,0 +1,28 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_bun_install(build): + template = Template().from_bun_image("1.3").skip_cache().bun_install("left-pad") + + build(template) + + +@pytest.mark.skip_debug() +def test_bun_install_global(build): + template = ( + Template().from_bun_image("1.3").skip_cache().bun_install("left-pad", g=True) + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_bun_install_dev(build): + template = ( + Template().from_bun_image("1.3").skip_cache().bun_install("left-pad", dev=True) + ) + + build(template) diff --git a/tests/sync/template_sync/methods/test_from_dockerfile.py b/tests/sync/template_sync/methods/test_from_dockerfile.py new file mode 100644 index 0000000..5cb68d3 --- /dev/null +++ b/tests/sync/template_sync/methods/test_from_dockerfile.py @@ -0,0 +1,66 @@ +import pytest + +from e2b import Template +from e2b.template.types import InstructionType + + +@pytest.mark.skip_debug() +def test_from_dockerfile(): + dockerfile = """FROM node:24 +WORKDIR /app +COPY package.json . +RUN npm install +ENTRYPOINT ["sleep", "20"]""" + + template = Template().from_dockerfile(dockerfile) + + # base image + assert template._template._base_image == "node:24" + + instructions = template._template._instructions + + # Docker defaults + assert instructions[1]["type"] == InstructionType.WORKDIR + assert instructions[1]["args"][0] == "/" + + # Instructions from Dockerfile + assert instructions[2]["type"] == InstructionType.WORKDIR + assert instructions[2]["args"][0] == "/app" + + assert instructions[3]["type"] == InstructionType.COPY + assert instructions[3]["args"][0] == "package.json" + assert instructions[3]["args"][1] == "." + + assert instructions[4]["type"] == InstructionType.RUN + assert instructions[4]["args"][0] == "npm install" + + # E2B defaults appended + assert instructions[5]["type"] == InstructionType.USER + assert instructions[5]["args"][0] == "user" + + # Start command + assert template._template._start_cmd == "sleep 20" + + +@pytest.mark.skip_debug() +def test_from_dockerfile_with_default_user_and_workdir(): + dockerfile = "FROM node:24" + + template = Template().from_dockerfile(dockerfile) + + assert template._template._instructions[-2]["type"] == InstructionType.USER + assert template._template._instructions[-2]["args"][0] == "user" + assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR + assert template._template._instructions[-1]["args"][0] == "/home/user" + + +@pytest.mark.skip_debug() +def test_from_dockerfile_with_custom_user_and_workdir(): + dockerfile = "FROM node:24\nUSER mish\nWORKDIR /home/mish" + + template = Template().from_dockerfile(dockerfile) + + assert template._template._instructions[-2]["type"] == InstructionType.USER + assert template._template._instructions[-2]["args"][0] == "mish" + assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR + assert template._template._instructions[-1]["args"][0] == "/home/mish" diff --git a/tests/sync/template_sync/methods/test_make_symlink.py b/tests/sync/template_sync/methods/test_make_symlink.py new file mode 100644 index 0000000..0371099 --- /dev/null +++ b/tests/sync/template_sync/methods/test_make_symlink.py @@ -0,0 +1,32 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_make_symlink(build): + template = ( + Template() + .from_image("ubuntu:22.04") + .skip_cache() + .make_symlink(".bashrc", ".bashrc.local") + .run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"') + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_make_symlink_force(build): + template = ( + Template() + .from_image("ubuntu:22.04") + .make_symlink(".bashrc", ".bashrc.local") + .skip_cache() + .make_symlink( + ".bashrc", ".bashrc.local", force=True + ) # Overwrite existing symlink + .run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"') + ) + + build(template) diff --git a/tests/sync/template_sync/methods/test_npm_install.py b/tests/sync/template_sync/methods/test_npm_install.py new file mode 100644 index 0000000..ab6c7d0 --- /dev/null +++ b/tests/sync/template_sync/methods/test_npm_install.py @@ -0,0 +1,28 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_npm_install(build): + template = Template().from_node_image("24").skip_cache().npm_install("left-pad") + + build(template) + + +@pytest.mark.skip_debug() +def test_npm_install_global(build): + template = ( + Template().from_node_image("24").skip_cache().npm_install("left-pad", g=True) + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_npm_install_dev(build): + template = ( + Template().from_node_image("24").skip_cache().npm_install("left-pad", dev=True) + ) + + build(template) diff --git a/tests/sync/template_sync/methods/test_pip_install.py b/tests/sync/template_sync/methods/test_pip_install.py new file mode 100644 index 0000000..5d7719d --- /dev/null +++ b/tests/sync/template_sync/methods/test_pip_install.py @@ -0,0 +1,27 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_pip_install(build): + template = ( + Template() + .from_python_image("3.13.7-trixie") + .skip_cache() + .pip_install("pip-install-test") + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_pip_install_user(build): + template = ( + Template() + .from_python_image("3.13.7-trixie") + .skip_cache() + .pip_install("pip-install-test", g=False) + ) + + build(template) diff --git a/tests/sync/template_sync/methods/test_run_cmd.py b/tests/sync/template_sync/methods/test_run_cmd.py new file mode 100644 index 0000000..2114026 --- /dev/null +++ b/tests/sync/template_sync/methods/test_run_cmd.py @@ -0,0 +1,40 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_run_command(build): + template = Template().from_image("ubuntu:22.04").skip_cache().run_cmd("ls -l") + + build(template) + + +@pytest.mark.skip_debug() +def test_run_command_as_different_user(build): + template = ( + Template() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd('test "$(whoami)" = "root"', user="root") + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_run_command_as_user_that_does_not_exist(build): + template = ( + Template() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd("whoami", user="root123") + ) + + with pytest.raises(Exception) as exc_info: + build(template) + + assert ( + "failed to run command 'whoami': command failed: unauthenticated: invalid username: 'root123'" + in str(exc_info.value) + ) diff --git a/tests/sync/template_sync/methods/test_to_dockerfile.py b/tests/sync/template_sync/methods/test_to_dockerfile.py new file mode 100644 index 0000000..ed1b149 --- /dev/null +++ b/tests/sync/template_sync/methods/test_to_dockerfile.py @@ -0,0 +1,57 @@ +import pytest + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_to_dockerfile(): + template = ( + Template() + .from_ubuntu_image("24.04") + .copy("README.md", "/app/README.md") + .run_cmd('echo "Hello, World!"') + ) + + dockerfile = Template.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +""" + assert dockerfile == expected_dockerfile + + +@pytest.mark.skip_debug() +def test_to_dockerfile_with_options(): + template = ( + Template() + .from_ubuntu_image("24.04") + .copy("README.md", "/app/README.md", user="root") + .run_cmd('echo "Hello, World!"', user="root") + ) + + dockerfile = Template.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +COPY README.md /app/README.md +RUN echo "Hello, World!" +""" + assert dockerfile == expected_dockerfile + + +@pytest.mark.skip_debug() +def test_to_dockerfile_with_env_instructions(): + template = ( + Template() + .from_ubuntu_image("24.04") + .set_envs({"NODE_ENV": "production", "PORT": "8080"}) + .set_envs({"DEBUG": "false"}) + ) + + dockerfile = Template.to_dockerfile(template) + + expected_dockerfile = """FROM ubuntu:24.04 +ENV NODE_ENV=production PORT=8080 +ENV DEBUG=false +""" + assert dockerfile == expected_dockerfile diff --git a/tests/sync/template_sync/test_background_build.py b/tests/sync/template_sync/test_background_build.py new file mode 100644 index 0000000..56fd157 --- /dev/null +++ b/tests/sync/template_sync/test_background_build.py @@ -0,0 +1,34 @@ +import uuid + +import pytest + +from e2b import Template, wait_for_timeout + + +@pytest.mark.skip_debug() +@pytest.mark.timeout(10) +def test_build_in_background_should_start_build_and_return_info(): + """Test that build_in_background returns immediately without waiting for build to complete.""" + template = ( + Template() + .from_image("ubuntu:22.04") + .skip_cache() + .run_cmd("sleep 5") # Add a delay to ensure build takes time + .set_start_cmd('echo "Hello"', wait_for_timeout(10_000)) + ) + + alias = f"e2b-test-{uuid.uuid4()}" + + build_info = Template.build_in_background( + template, + alias=alias, + cpu_count=1, + memory_mb=1024, + ) + + # Should return quickly (within a few seconds), not wait for the full build + assert build_info is not None + + # Verify the build is actually running + status = Template.get_build_status(build_info) + assert status.status.value == "building" diff --git a/tests/sync/template_sync/test_build.py b/tests/sync/template_sync/test_build.py new file mode 100644 index 0000000..423e809 --- /dev/null +++ b/tests/sync/template_sync/test_build.py @@ -0,0 +1,92 @@ +import tempfile + +import pytest +import os +import shutil + +from e2b import Template, wait_for_timeout, default_build_logger + + +@pytest.fixture(scope="module") +def setup_test_folder(): + test_dir = tempfile.mkdtemp(prefix="python_sync_test_") + folder_path = os.path.join(test_dir, "folder") + + os.makedirs(folder_path, exist_ok=True) + with open(os.path.join(folder_path, "test.txt"), "w") as f: + f.write("This is a test file.") + + # Create relative symlink + symlink_path = os.path.join(folder_path, "symlink.txt") + if os.path.exists(symlink_path): + os.remove(symlink_path) + os.symlink("test.txt", symlink_path) + + # Create absolute symlink + symlink2_path = os.path.join(folder_path, "symlink2.txt") + if os.path.exists(symlink2_path): + os.remove(symlink2_path) + os.symlink(os.path.join(folder_path, "test.txt"), symlink2_path) + + # Create a symlink to a file that does not exist + symlink3_path = os.path.join(folder_path, "symlink3.txt") + if os.path.exists(symlink3_path): + os.remove(symlink3_path) + os.symlink("12345test.txt", symlink3_path) + + yield test_dir + + # Cleanup + shutil.rmtree(test_dir, ignore_errors=True) + + +@pytest.mark.skip_debug() +def test_build_template(build, setup_test_folder): + template = ( + Template(file_context_path=setup_test_folder) + # using base image to avoid re-building ubuntu:22.04 image + .from_base_image() + .copy("folder/*", "folder", force_upload=True) + .run_cmd("cat folder/test.txt") + .set_workdir("/app") + .set_start_cmd("echo 'Hello, world!'", wait_for_timeout(10_000)) + ) + + build(template, skip_cache=True, on_build_logs=default_build_logger()) + + +@pytest.mark.skip_debug() +def test_build_template_from_base_template(build): + template = Template().from_template("base") + build(template, skip_cache=True, on_build_logs=default_build_logger()) + + +@pytest.mark.skip_debug() +def test_build_template_with_symlinks(build, setup_test_folder): + template = ( + Template(file_context_path=setup_test_folder) + .from_image("ubuntu:22.04") + .skip_cache() + .copy("folder/*", "folder", force_upload=True) + .run_cmd("cat folder/symlink.txt") + ) + + build(template) + + +@pytest.mark.skip_debug() +def test_build_template_with_resolve_symlinks(build, setup_test_folder): + template = ( + Template(file_context_path=setup_test_folder) + .from_image("ubuntu:22.04") + .skip_cache() + .copy( + "folder/symlink.txt", + "folder/symlink.txt", + force_upload=True, + resolve_symlinks=True, + ) + .run_cmd("cat folder/symlink.txt") + ) + + build(template) diff --git a/tests/sync/template_sync/test_stacktrace.py b/tests/sync/template_sync/test_stacktrace.py new file mode 100644 index 0000000..02e0a97 --- /dev/null +++ b/tests/sync/template_sync/test_stacktrace.py @@ -0,0 +1,328 @@ +import traceback +from types import SimpleNamespace +from typing import Optional +from uuid import uuid4 + +import pytest +import linecache + +from e2b import Template, CopyItem, wait_for_timeout +from e2b.api.client.models import TemplateBuildStatus +import e2b.template_sync.main as template_sync_main +import e2b.template_sync.build_api as build_api_mod + +non_existent_path = "/nonexistent/path" + +# map template alias -> failed step index +failure_map: dict[str, Optional[int]] = { + "from_image": 0, + "from_template": 0, + "from_dockerfile": 0, + "from_image_registry": 0, + "from_aws_registry": 0, + "from_gcp_registry": 0, + "copy": None, + "copy_items": None, + "remove": 1, + "rename": 1, + "make_dir": 1, + "make_symlink": 1, + "run_cmd": 1, + "set_workdir": 1, + "set_user": 1, + "pip_install": 1, + "npm_install": 1, + "apt_install": 1, + "git_clone": 1, + "set_start_cmd": 1, + "add_mcp_server": None, + "beta_dev_container_prebuild": 1, + "beta_set_dev_container_start": 1, +} + + +@pytest.fixture(autouse=True) +def mock_template_build(monkeypatch): + def mock_request_build(client, name: str, cpu_count: int, memory_mb: int): + return SimpleNamespace(template_id=name, build_id=str(uuid4())) + + def mock_trigger_build(client, template_id: str, build_id: str, template): + return None + + def mock_get_build_status( + client, template_id: str, build_id: str, logs_offset: int + ): + step = failure_map[template_id] + reason = SimpleNamespace( + message="Mocked API build error", + log_entries=[], + step=str(step) if step is not None else None, + ) + return SimpleNamespace( + status=TemplateBuildStatus.ERROR, + log_entries=[], + reason=reason, + ) + + monkeypatch.setattr(template_sync_main, "request_build", mock_request_build) + monkeypatch.setattr(template_sync_main, "trigger_build", mock_trigger_build) + monkeypatch.setattr(build_api_mod, "get_build_status", mock_get_build_status) + + +def _expect_to_throw_and_check_trace(func, expected_method: str): + try: + func() + assert False, "Expected Template.build to raise an exception" + except Exception as e: # noqa: BLE001 - we want to assert on the traceback regardless of type + tb = e.__traceback__ + saw_this_file = False + saw_expected_method = False + while tb is not None: + traceback_file = tb.tb_frame.f_code.co_filename + if traceback_file == __file__: + saw_this_file = True + caller_line = linecache.getline(traceback_file, tb.tb_lineno) + if caller_line and f".{expected_method}(" in caller_line: + saw_expected_method = True + break + tb = tb.tb_next + assert saw_this_file, traceback.format_exc() + assert saw_expected_method, traceback.format_exc() + + +@pytest.mark.skip_debug() +def test_traces_on_from_image(build): + template = Template() + template = template.from_image("e2b.dev/this-image-does-not-exist") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="from_image", skip_cache=True), "from_image" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_template(build): + template = Template().from_template("this-template-does-not-exist") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="from_template", skip_cache=True), "from_template" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_dockerfile(build): + template = Template() + template = template.from_dockerfile("FROM ubuntu:22.04\nRUN nonexistent") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="from_dockerfile", skip_cache=True), + "from_dockerfile", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_image_registry(build): + template = Template() + template = template.from_image( + "registry.example.com/nonexistent:latest", + username="test", + password="test", + ) + _expect_to_throw_and_check_trace( + lambda: build(template, alias="from_image_registry", skip_cache=True), + "from_image", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_aws_registry(build): + template = Template() + template = template.from_aws_registry( + "123456789.dkr.ecr.us-east-1.amazonaws.com/nonexistent:latest", + access_key_id="test", + secret_access_key="test", + region="us-east-1", + ) + _expect_to_throw_and_check_trace( + lambda: build(template, alias="from_aws_registry"), "from_aws_registry" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_from_gcp_registry(build): + template = Template() + template = template.from_gcp_registry( + "gcr.io/nonexistent-project/nonexistent:latest", + service_account_json={ + "type": "service_account", + }, + ) + _expect_to_throw_and_check_trace( + lambda: build(template, alias="from_gcp_registry"), "from_gcp_registry" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_copy(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy(non_existent_path, non_existent_path) + _expect_to_throw_and_check_trace(lambda: build(template, alias="copy"), "copy") + + +@pytest.mark.skip_debug() +def test_traces_on_copyItems(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy_items( + [CopyItem(src=non_existent_path, dest=non_existent_path)] + ) + _expect_to_throw_and_check_trace( + lambda: build(template, alias="copy_items"), "copy_items" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_remove(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().remove(non_existent_path) + _expect_to_throw_and_check_trace(lambda: build(template, alias="remove"), "remove") + + +@pytest.mark.skip_debug() +def test_traces_on_rename(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().rename(non_existent_path, "/tmp/dest.txt") + _expect_to_throw_and_check_trace(lambda: build(template, alias="rename"), "rename") + + +@pytest.mark.skip_debug() +def test_traces_on_make_dir(build): + template = Template() + template = template.from_base_image() + template = template.set_user("root").skip_cache().make_dir("/root/.bashrc") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="make_dir"), "make_dir" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_make_symlink(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().make_symlink(".bashrc", ".bashrc") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="make_symlink"), "make_symlink" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_run_cmd(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().run_cmd(f"cat {non_existent_path}") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="run_cmd"), "run_cmd" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_set_workdir(build): + template = Template() + template = template.from_base_image() + template = template.set_user("root").skip_cache().set_workdir("/root/.bashrc") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="set_workdir"), "set_workdir" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_set_user(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().set_user("; exit 1") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="set_user"), "set_user" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_pip_install(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().pip_install("nonexistent-package") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="pip_install"), "pip_install" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_npm_install(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().npm_install("nonexistent-package") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="npm_install"), "npm_install" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_apt_install(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().apt_install("nonexistent-package") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="apt_install"), "apt_install" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_git_clone(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().git_clone("https://github.com/repo.git") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="git_clone"), "git_clone" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_set_start_cmd(build): + template = Template() + template = template.from_base_image() + template = template.set_start_cmd( + f"./{non_existent_path}", wait_for_timeout(10_000) + ) + _expect_to_throw_and_check_trace( + lambda: build(template, alias="set_start_cmd"), "set_start_cmd" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_add_mcp_server(): + # needs mcp-gateway as base template, without it no mcp servers can be added + _expect_to_throw_and_check_trace( + lambda: Template().from_base_image().skip_cache().add_mcp_server("exa"), + "add_mcp_server", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_dev_container_prebuild(build): + template = Template() + template = template.from_template("devcontainer") + template = template.skip_cache().beta_dev_container_prebuild(non_existent_path) + _expect_to_throw_and_check_trace( + lambda: build(template, alias="beta_dev_container_prebuild"), + "beta_dev_container_prebuild", + ) + + +@pytest.mark.skip_debug() +def test_traces_on_set_dev_container_start(build): + template = Template() + template = template.from_template("devcontainer") + template = template.beta_set_dev_container_start(non_existent_path) + _expect_to_throw_and_check_trace( + lambda: build(template, alias="beta_set_dev_container_start"), + "beta_set_dev_container_start", + ) diff --git a/tests/test_connection_config.py b/tests/test_connection_config.py new file mode 100644 index 0000000..1411dae --- /dev/null +++ b/tests/test_connection_config.py @@ -0,0 +1,27 @@ +from e2b import ConnectionConfig + + +def test_api_url_defaults_correctly(monkeypatch): + monkeypatch.setenv("E2B_DOMAIN", "") + + config = ConnectionConfig() + assert config.api_url == "https://api.e2b.app" + + +def test_api_url_in_args(): + config = ConnectionConfig(api_url="http://localhost:8080") + assert config.api_url == "http://localhost:8080" + + +def test_api_url_in_env_var(monkeypatch): + monkeypatch.setenv("E2B_API_URL", "http://localhost:8080") + + config = ConnectionConfig() + assert config.api_url == "http://localhost:8080" + + +def test_api_url_has_correct_priority(monkeypatch): + monkeypatch.setenv("E2B_API_URL", "http://localhost:1111") + + config = ConnectionConfig(api_url="http://localhost:8080") + assert config.api_url == "http://localhost:8080" diff --git a/ucloud_sandbox/__init__.py b/ucloud_sandbox/__init__.py new file mode 100644 index 0000000..6713bc6 --- /dev/null +++ b/ucloud_sandbox/__init__.py @@ -0,0 +1,164 @@ +""" +UCloud AgentBox SDK - Secure sandboxed cloud environments for AI agents. + +AgentBox is a secure cloud sandbox environment made for AI agents and AI apps. +Sandboxes allow AI agents and apps to have long running cloud secure environments. +In these environments, large language models can use the same tools as humans do. + +This SDK supports both sync and async API: + +```py +from ucloud_agentbox import Sandbox + +# Create sandbox +sandbox = Sandbox.create() +``` + +```py +from ucloud_agentbox import AsyncSandbox + +# Create sandbox +sandbox = await AsyncSandbox.create() +``` +""" + +from .api import ( + ApiClient, + client, +) +from .connection_config import ( + ConnectionConfig, + ProxyTypes, +) +from .exceptions import ( + AuthenticationException, + BuildException, + FileUploadException, + InvalidArgumentException, + NotEnoughSpaceException, + NotFoundException, + SandboxException, + TemplateException, + TimeoutException, +) +from .sandbox.commands.command_handle import ( + CommandExitException, + CommandResult, + PtyOutput, + PtySize, + Stderr, + Stdout, +) +from .sandbox.commands.main import ProcessInfo +from .sandbox.filesystem.filesystem import EntryInfo, FileType, WriteInfo +from .sandbox.filesystem.watch_handle import ( + FilesystemEvent, + FilesystemEventType, +) +from .sandbox.network import ALL_TRAFFIC +from .sandbox.sandbox_api import ( + SandboxInfo, + SandboxMetrics, + SandboxNetworkOpts, + SandboxQuery, + SandboxState, +) +from .sandbox_async.commands.command_handle import AsyncCommandHandle +from .sandbox_async.filesystem.watch_handle import AsyncWatchHandle +from .sandbox_async.main import AsyncSandbox +from .sandbox_async.paginator import AsyncSandboxPaginator +from .sandbox_async.utils import OutputHandler +from .sandbox_sync.commands.command_handle import CommandHandle +from .sandbox_sync.filesystem.watch_handle import WatchHandle +from .sandbox_sync.main import Sandbox +from .sandbox_sync.paginator import SandboxPaginator +from .template.logger import ( + LogEntry, + LogEntryEnd, + LogEntryLevel, + LogEntryStart, + default_build_logger, +) +from .template.main import TemplateBase, TemplateClass +from .template.readycmd import ( + ReadyCmd, + wait_for_file, + wait_for_port, + wait_for_process, + wait_for_timeout, + wait_for_url, +) +from .template.types import BuildInfo, CopyItem +from .template_async.main import AsyncTemplate +from .template_sync.main import Template + +__all__ = [ + # API + "ApiClient", + "client", + # Connection config + "ConnectionConfig", + "ProxyTypes", + # Exceptions + "SandboxException", + "TimeoutException", + "NotFoundException", + "AuthenticationException", + "InvalidArgumentException", + "NotEnoughSpaceException", + "TemplateException", + "BuildException", + "FileUploadException", + # Sandbox API + "SandboxInfo", + "SandboxMetrics", + "ProcessInfo", + "SandboxQuery", + "SandboxState", + "SandboxMetrics", + # Command handle + "CommandResult", + "Stderr", + "Stdout", + "CommandExitException", + "PtyOutput", + "PtySize", + # Filesystem + "FilesystemEvent", + "FilesystemEventType", + "EntryInfo", + "WriteInfo", + "FileType", + # Network + "SandboxNetworkOpts", + "ALL_TRAFFIC", + # Sync sandbox + "Sandbox", + "SandboxPaginator", + "WatchHandle", + "CommandHandle", + # Async sandbox + "OutputHandler", + "AsyncSandboxPaginator", + "AsyncSandbox", + "AsyncWatchHandle", + "AsyncCommandHandle", + # Template + "Template", + "AsyncTemplate", + "TemplateBase", + "TemplateClass", + "CopyItem", + "BuildInfo", + "ReadyCmd", + "wait_for_file", + "wait_for_url", + "wait_for_port", + "wait_for_process", + "wait_for_timeout", + "LogEntry", + "LogEntryStart", + "LogEntryEnd", + "LogEntryLevel", + "default_build_logger", +] diff --git a/ucloud_sandbox/api/__init__.py b/ucloud_sandbox/api/__init__.py new file mode 100644 index 0000000..89e2295 --- /dev/null +++ b/ucloud_sandbox/api/__init__.py @@ -0,0 +1,164 @@ +import json +import logging +import os +from dataclasses import dataclass +from types import TracebackType +from typing import Optional, Union + +from httpx import AsyncBaseTransport, BaseTransport, Limits + +from ucloud_agentbox.api.client.client import AuthenticatedClient +from ucloud_agentbox.api.client.types import Response +from ucloud_agentbox.api.metadata import default_headers +from ucloud_agentbox.connection_config import ConnectionConfig +from ucloud_agentbox.exceptions import ( + AuthenticationException, + RateLimitException, + SandboxException, +) + +logger = logging.getLogger(__name__) + +limits = Limits( + max_keepalive_connections=int(os.getenv("AGENTBOX_MAX_KEEPALIVE_CONNECTIONS", "20")), + max_connections=int(os.getenv("AGENTBOX_MAX_CONNECTIONS", "2000")), + keepalive_expiry=int(os.getenv("AGENTBOX_KEEPALIVE_EXPIRY", "300")), +) + + +@dataclass +class SandboxCreateResponse: + sandbox_id: str + sandbox_domain: Optional[str] + envd_version: str + envd_access_token: str + traffic_access_token: Optional[str] + + +def handle_api_exception( + e: Response, + default_exception_class: type[Exception] = SandboxException, + stack_trace: Optional[TracebackType] = None, +): + try: + body = json.loads(e.content) if e.content else {} + except json.JSONDecodeError: + body = {} + + if e.status_code == 401: + message = f"{e.status_code}: Unauthorized, please check your credentials." + if body.get("message"): + message += f" - {body['message']}" + return AuthenticationException(message) + + if e.status_code == 429: + message = f"{e.status_code}: Rate limit exceeded, please try again later." + if body.get("message"): + message += f" - {body['message']}" + return RateLimitException(message) + + if "message" in body: + return default_exception_class( + f"{e.status_code}: {body['message']}" + ).with_traceback(stack_trace) + return default_exception_class(f"{e.status_code}: {e.content}").with_traceback( + stack_trace + ) + + +class ApiClient(AuthenticatedClient): + """ + The client for interacting with the AgentBox API. + """ + + def __init__( + self, + config: ConnectionConfig, + require_api_key: bool = True, + require_access_token: bool = False, + transport: Optional[Union[BaseTransport, AsyncBaseTransport]] = None, + *args, + **kwargs, + ): + if require_api_key and require_access_token: + raise AuthenticationException( + "Only one of api_key or access_token can be required, not both", + ) + + if not require_api_key and not require_access_token: + raise AuthenticationException( + "Either api_key or access_token is required", + ) + + token = None + if require_api_key: + if config.api_key is None: + raise AuthenticationException( + "API key is required. " + "You can either set the environment variable `AGENTBOX_API_KEY` " + 'or you can pass it directly to the method like api_key="..."', + ) + token = config.api_key + + if require_access_token: + if config.access_token is None: + raise AuthenticationException( + "Access token is required. " + "You can set the environment variable `AGENTBOX_ACCESS_TOKEN` or pass the `access_token` in options.", + ) + token = config.access_token + + auth_header_name = "X-API-KEY" if require_api_key else "Authorization" + prefix = "" if require_api_key else "Bearer" + + headers = { + **default_headers, + **(config.headers or {}), + } + + # Prevent passing these parameters twice + more_headers: Optional[dict] = kwargs.pop("headers", None) + if more_headers: + headers.update(more_headers) + kwargs.pop("token", None) + kwargs.pop("auth_header_name", None) + kwargs.pop("prefix", None) + + super().__init__( + base_url=config.api_url, + httpx_args={ + "event_hooks": { + "request": [self._log_request], + "response": [self._log_response], + }, + "proxy": config.proxy, + "transport": transport, + }, + headers=headers, + token=token, + auth_header_name=auth_header_name, + prefix=prefix, + *args, + **kwargs, + ) + + def _log_request(self, request): + logger.info(f"Request {request.method} {request.url}") + + def _log_response(self, response: Response): + if response.status_code >= 400: + logger.error(f"Response {response.status_code}") + else: + logger.info(f"Response {response.status_code}") + + +# We need to override the logging hooks for the async usage +class AsyncApiClient(ApiClient): + async def _log_request(self, request): + logger.info(f"Request {request.method} {request.url}") + + async def _log_response(self, response: Response): + if response.status_code >= 400: + logger.error(f"Response {response.status_code}") + else: + logger.info(f"Response {response.status_code}") diff --git a/ucloud_sandbox/api/client/__init__.py b/ucloud_sandbox/api/client/__init__.py new file mode 100644 index 0000000..90b24dc --- /dev/null +++ b/ucloud_sandbox/api/client/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing UCloud AgentBox API""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/ucloud_sandbox/api/client/api/__init__.py b/ucloud_sandbox/api/client/api/__init__.py new file mode 100644 index 0000000..81f9fa2 --- /dev/null +++ b/ucloud_sandbox/api/client/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/ucloud_sandbox/api/client/api/sandboxes/__init__.py b/ucloud_sandbox/api/client/api/sandboxes/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/ucloud_sandbox/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py b/ucloud_sandbox/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py new file mode 100644 index 0000000..77288c4 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py @@ -0,0 +1,161 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/sandboxes/{sandbox_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + 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, Error]]: + """Kill a sandbox + + 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, Error]] + """ + + 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, Error]]: + """Kill a sandbox + + 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, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Kill a sandbox + + 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, Error]] + """ + + 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, Error]]: + """Kill a sandbox + + 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, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes.py b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes.py new file mode 100644 index 0000000..19829e5 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes.py @@ -0,0 +1,176 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.listed_sandbox import ListedSandbox +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + metadata: Union[Unset, str] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["metadata"] = metadata + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/sandboxes", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["ListedSandbox"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = ListedSandbox.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["ListedSandbox"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all running sandboxes + + Args: + metadata (Union[Unset, 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[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all running sandboxes + + Args: + metadata (Union[Unset, 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[Error, list['ListedSandbox']] + """ + + return sync_detailed( + client=client, + metadata=metadata, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all running sandboxes + + Args: + metadata (Union[Unset, 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[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all running sandboxes + + Args: + metadata (Union[Unset, 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[Error, list['ListedSandbox']] + """ + + return ( + await asyncio_detailed( + client=client, + metadata=metadata, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_metrics.py b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_metrics.py new file mode 100644 index 0000000..d05b6f9 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_metrics.py @@ -0,0 +1,173 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandboxes_with_metrics import SandboxesWithMetrics +from ...types import UNSET, Response + + +def _get_kwargs( + *, + sandbox_ids: list[str], +) -> dict[str, Any]: + params: dict[str, Any] = {} + + json_sandbox_ids = sandbox_ids + + params["sandbox_ids"] = ",".join(str(item) for item in json_sandbox_ids) + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/sandboxes/metrics", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SandboxesWithMetrics]]: + if response.status_code == 200: + response_200 = SandboxesWithMetrics.from_dict(response.json()) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SandboxesWithMetrics]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + sandbox_ids: list[str], +) -> Response[Union[Error, SandboxesWithMetrics]]: + """List metrics for given sandboxes + + Args: + sandbox_ids (list[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[Error, SandboxesWithMetrics]] + """ + + kwargs = _get_kwargs( + sandbox_ids=sandbox_ids, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + sandbox_ids: list[str], +) -> Optional[Union[Error, SandboxesWithMetrics]]: + """List metrics for given sandboxes + + Args: + sandbox_ids (list[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[Error, SandboxesWithMetrics] + """ + + return sync_detailed( + client=client, + sandbox_ids=sandbox_ids, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + sandbox_ids: list[str], +) -> Response[Union[Error, SandboxesWithMetrics]]: + """List metrics for given sandboxes + + Args: + sandbox_ids (list[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[Error, SandboxesWithMetrics]] + """ + + kwargs = _get_kwargs( + sandbox_ids=sandbox_ids, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + sandbox_ids: list[str], +) -> Optional[Union[Error, SandboxesWithMetrics]]: + """List metrics for given sandboxes + + Args: + sandbox_ids (list[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[Error, SandboxesWithMetrics] + """ + + return ( + await asyncio_detailed( + client=client, + sandbox_ids=sandbox_ids, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_sandbox_id.py b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_sandbox_id.py new file mode 100644 index 0000000..74f0b27 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_sandbox_id.py @@ -0,0 +1,163 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandbox_detail import SandboxDetail +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/sandboxes/{sandbox_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SandboxDetail]]: + if response.status_code == 200: + response_200 = SandboxDetail.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SandboxDetail]]: + 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[Error, SandboxDetail]]: + """Get a sandbox by id + + 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[Error, SandboxDetail]] + """ + + 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[Error, SandboxDetail]]: + """Get a sandbox by id + + 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[Error, SandboxDetail] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, SandboxDetail]]: + """Get a sandbox by id + + 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[Error, SandboxDetail]] + """ + + 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[Error, SandboxDetail]]: + """Get a sandbox by id + + 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[Error, SandboxDetail] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py new file mode 100644 index 0000000..a72765c --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py @@ -0,0 +1,199 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandbox_logs import SandboxLogs +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + sandbox_id: str, + *, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["start"] = start + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/sandboxes/{sandbox_id}/logs", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SandboxLogs]]: + if response.status_code == 200: + response_200 = SandboxLogs.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SandboxLogs]]: + 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, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Response[Union[Error, SandboxLogs]]: + """Get sandbox logs + + Args: + sandbox_id (str): + start (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + + 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[Error, SandboxLogs]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + start=start, + limit=limit, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Optional[Union[Error, SandboxLogs]]: + """Get sandbox logs + + Args: + sandbox_id (str): + start (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + + 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[Error, SandboxLogs] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + start=start, + limit=limit, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Response[Union[Error, SandboxLogs]]: + """Get sandbox logs + + Args: + sandbox_id (str): + start (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + + 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[Error, SandboxLogs]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + start=start, + limit=limit, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, +) -> Optional[Union[Error, SandboxLogs]]: + """Get sandbox logs + + Args: + sandbox_id (str): + start (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + + 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[Error, SandboxLogs] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + start=start, + limit=limit, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py new file mode 100644 index 0000000..48302c5 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py @@ -0,0 +1,212 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.sandbox_metric import SandboxMetric +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + sandbox_id: str, + *, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["start"] = start + + params["end"] = end + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/sandboxes/{sandbox_id}/metrics", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, 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 == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, 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, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> Response[Union[Error, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + start (Union[Unset, int]): + end (Union[Unset, int]): Unix timestamp for the end of the interval, in seconds, for which + the metrics + + 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[Error, list['SandboxMetric']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + start=start, + end=end, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> Optional[Union[Error, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + start (Union[Unset, int]): + end (Union[Unset, int]): Unix timestamp for the end of the interval, in seconds, for which + the metrics + + 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[Error, list['SandboxMetric']] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + start=start, + end=end, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> Response[Union[Error, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + start (Union[Unset, int]): + end (Union[Unset, int]): Unix timestamp for the end of the interval, in seconds, for which + the metrics + + 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[Error, list['SandboxMetric']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + start=start, + end=end, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + start: Union[Unset, int] = UNSET, + end: Union[Unset, int] = UNSET, +) -> Optional[Union[Error, list["SandboxMetric"]]]: + """Get sandbox metrics + + Args: + sandbox_id (str): + start (Union[Unset, int]): + end (Union[Unset, int]): Unix timestamp for the end of the interval, in seconds, for which + the metrics + + 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[Error, list['SandboxMetric']] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + start=start, + end=end, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/get_v2_sandboxes.py b/ucloud_sandbox/api/client/api/sandboxes/get_v2_sandboxes.py new file mode 100644 index 0000000..4955f81 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/get_v2_sandboxes.py @@ -0,0 +1,230 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.listed_sandbox import ListedSandbox +from ...models.sandbox_state import SandboxState +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["metadata"] = metadata + + json_state: Union[Unset, list[str]] = UNSET + if not isinstance(state, Unset): + json_state = [] + for state_item_data in state: + state_item = state_item_data.value + json_state.append(state_item) + + if not isinstance(json_state, Unset): + params["state"] = ",".join(str(item) for item in json_state) + + params["nextToken"] = next_token + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v2/sandboxes", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["ListedSandbox"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = ListedSandbox.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["ListedSandbox"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + 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[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + 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[Error, list['ListedSandbox']] + """ + + return sync_detailed( + client=client, + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Response[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + 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[Error, list['ListedSandbox']]] + """ + + kwargs = _get_kwargs( + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + metadata: Union[Unset, str] = UNSET, + state: Union[Unset, list[SandboxState]] = UNSET, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Optional[Union[Error, list["ListedSandbox"]]]: + """List all sandboxes + + Args: + metadata (Union[Unset, str]): + state (Union[Unset, list[SandboxState]]): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + 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[Error, list['ListedSandbox']] + """ + + return ( + await asyncio_detailed( + client=client, + metadata=metadata, + state=state, + next_token=next_token, + limit=limit, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes.py b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes.py new file mode 100644 index 0000000..36d79cb --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.new_sandbox import NewSandbox +from ...models.sandbox import Sandbox +from ...types import Response + + +def _get_kwargs( + *, + body: NewSandbox, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/sandboxes", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, Sandbox]]: + if response.status_code == 201: + response_201 = Sandbox.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, Sandbox]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: NewSandbox, +) -> Response[Union[Error, Sandbox]]: + """Create a sandbox from the template + + Args: + body (NewSandbox): + + 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[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: NewSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Create a sandbox from the template + + Args: + body (NewSandbox): + + 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[Error, Sandbox] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: NewSandbox, +) -> Response[Union[Error, Sandbox]]: + """Create a sandbox from the template + + Args: + body (NewSandbox): + + 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[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: NewSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Create a sandbox from the template + + Args: + body (NewSandbox): + + 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[Error, Sandbox] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py new file mode 100644 index 0000000..c3b71fc --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py @@ -0,0 +1,193 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.connect_sandbox import ConnectSandbox +from ...models.error import Error +from ...models.sandbox import Sandbox +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: ConnectSandbox, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/connect", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, Sandbox]]: + if response.status_code == 200: + response_200 = Sandbox.from_dict(response.json()) + + return response_200 + if response.status_code == 201: + response_201 = Sandbox.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, Sandbox]]: + 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, + body: ConnectSandbox, +) -> Response[Union[Error, Sandbox]]: + """Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + + Args: + sandbox_id (str): + body (ConnectSandbox): + + 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[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ConnectSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + + Args: + sandbox_id (str): + body (ConnectSandbox): + + 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[Error, Sandbox] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ConnectSandbox, +) -> Response[Union[Error, Sandbox]]: + """Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + + Args: + sandbox_id (str): + body (ConnectSandbox): + + 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[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ConnectSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + + Args: + sandbox_id (str): + body (ConnectSandbox): + + 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[Error, Sandbox] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py new file mode 100644 index 0000000..f1c55c6 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py @@ -0,0 +1,165 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/pause", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + return response_409 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + 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, Error]]: + """Pause the sandbox + + 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, Error]] + """ + + 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, Error]]: + """Pause the sandbox + + 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, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Pause the sandbox + + 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, Error]] + """ + + 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, Error]]: + """Pause the sandbox + + 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, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py new file mode 100644 index 0000000..5b667ba --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py @@ -0,0 +1,181 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.post_sandboxes_sandbox_id_refreshes_body import ( + PostSandboxesSandboxIDRefreshesBody, +) +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: PostSandboxesSandboxIDRefreshesBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/refreshes", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + 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, Error]]: + 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, + body: PostSandboxesSandboxIDRefreshesBody, +) -> Response[Union[Any, Error]]: + """Refresh the sandbox extending its time to live + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDRefreshesBody): + + 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, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDRefreshesBody, +) -> Optional[Union[Any, Error]]: + """Refresh the sandbox extending its time to live + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDRefreshesBody): + + 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, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDRefreshesBody, +) -> Response[Union[Any, Error]]: + """Refresh the sandbox extending its time to live + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDRefreshesBody): + + 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, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDRefreshesBody, +) -> Optional[Union[Any, Error]]: + """Refresh the sandbox extending its time to live + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDRefreshesBody): + + 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, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py new file mode 100644 index 0000000..caca629 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py @@ -0,0 +1,189 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.resumed_sandbox import ResumedSandbox +from ...models.sandbox import Sandbox +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: ResumedSandbox, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/resume", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, Sandbox]]: + if response.status_code == 201: + response_201 = Sandbox.from_dict(response.json()) + + return response_201 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + return response_409 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, Sandbox]]: + 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, + body: ResumedSandbox, +) -> Response[Union[Error, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + 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[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ResumedSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + 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[Error, Sandbox] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ResumedSandbox, +) -> Response[Union[Error, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + 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[Error, Sandbox]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ResumedSandbox, +) -> Optional[Union[Error, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + 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[Error, Sandbox] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py new file mode 100644 index 0000000..2756f30 --- /dev/null +++ b/ucloud_sandbox/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py @@ -0,0 +1,193 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.post_sandboxes_sandbox_id_timeout_body import ( + PostSandboxesSandboxIDTimeoutBody, +) +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: PostSandboxesSandboxIDTimeoutBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/timeout", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + 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, + body: PostSandboxesSandboxIDTimeoutBody, +) -> Response[Union[Any, Error]]: + """Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. + Calling this method multiple times overwrites the TTL, each time using the current timestamp as the + starting point to measure the timeout duration. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDTimeoutBody): + + 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, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDTimeoutBody, +) -> Optional[Union[Any, Error]]: + """Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. + Calling this method multiple times overwrites the TTL, each time using the current timestamp as the + starting point to measure the timeout duration. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDTimeoutBody): + + 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, Error] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDTimeoutBody, +) -> Response[Union[Any, Error]]: + """Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. + Calling this method multiple times overwrites the TTL, each time using the current timestamp as the + starting point to measure the timeout duration. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDTimeoutBody): + + 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, Error]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDTimeoutBody, +) -> Optional[Union[Any, Error]]: + """Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. + Calling this method multiple times overwrites the TTL, each time using the current timestamp as the + starting point to measure the timeout duration. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDTimeoutBody): + + 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, Error] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/__init__.py b/ucloud_sandbox/api/client/api/templates/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/ucloud_sandbox/api/client/api/templates/delete_templates_template_id.py b/ucloud_sandbox/api/client/api/templates/delete_templates_template_id.py new file mode 100644 index 0000000..d73839c --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/delete_templates_template_id.py @@ -0,0 +1,157 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + template_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/templates/{template_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Delete a template + + Args: + template_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, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Delete a template + + Args: + template_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, Error] + """ + + return sync_detailed( + template_id=template_id, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Delete a template + + Args: + template_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, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Delete a template + + Args: + template_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, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/get_templates.py b/ucloud_sandbox/api/client/api/templates/get_templates.py new file mode 100644 index 0000000..a25db12 --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/get_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template import Template +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + team_id: Union[Unset, str] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["teamID"] = team_id + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/templates", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["Template"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = Template.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["Template"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + 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[Error, list['Template']]] + """ + + kwargs = _get_kwargs( + team_id=team_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + 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[Error, list['Template']] + """ + + return sync_detailed( + client=client, + team_id=team_id, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + 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[Error, list['Template']]] + """ + + kwargs = _get_kwargs( + team_id=team_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + 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[Error, list['Template']] + """ + + return ( + await asyncio_detailed( + client=client, + team_id=team_id, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/get_templates_template_id.py b/ucloud_sandbox/api/client/api/templates/get_templates_template_id.py new file mode 100644 index 0000000..875f9e9 --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/get_templates_template_id.py @@ -0,0 +1,195 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_with_builds import TemplateWithBuilds +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + template_id: str, + *, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["nextToken"] = next_token + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateWithBuilds]]: + if response.status_code == 200: + response_200 = TemplateWithBuilds.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateWithBuilds]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Response[Union[Error, TemplateWithBuilds]]: + """List all builds for a template + + Args: + template_id (str): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + 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[Error, TemplateWithBuilds]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + next_token=next_token, + limit=limit, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Optional[Union[Error, TemplateWithBuilds]]: + """List all builds for a template + + Args: + template_id (str): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + 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[Error, TemplateWithBuilds] + """ + + return sync_detailed( + template_id=template_id, + client=client, + next_token=next_token, + limit=limit, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Response[Union[Error, TemplateWithBuilds]]: + """List all builds for a template + + Args: + template_id (str): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + 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[Error, TemplateWithBuilds]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + next_token=next_token, + limit=limit, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, + next_token: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, +) -> Optional[Union[Error, TemplateWithBuilds]]: + """List all builds for a template + + Args: + template_id (str): + next_token (Union[Unset, str]): + limit (Union[Unset, int]): Default: 100. + + 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[Error, TemplateWithBuilds] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + next_token=next_token, + limit=limit, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/get_templates_template_id_builds_build_id_status.py b/ucloud_sandbox/api/client/api/templates/get_templates_template_id_builds_build_id_status.py new file mode 100644 index 0000000..2fa0fee --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/get_templates_template_id_builds_build_id_status.py @@ -0,0 +1,217 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.log_level import LogLevel +from ...models.template_build_info import TemplateBuildInfo +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + template_id: str, + build_id: str, + *, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["logsOffset"] = logs_offset + + json_level: Union[Unset, str] = UNSET + if not isinstance(level, Unset): + json_level = level.value + + params["level"] = json_level + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/builds/{build_id}/status", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateBuildInfo]]: + if response.status_code == 200: + response_200 = TemplateBuildInfo.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateBuildInfo]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> Response[Union[Error, TemplateBuildInfo]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + level (Union[Unset, LogLevel]): State of the sandbox + + 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[Error, TemplateBuildInfo]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + logs_offset=logs_offset, + level=level, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> Optional[Union[Error, TemplateBuildInfo]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + level (Union[Unset, LogLevel]): State of the sandbox + + 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[Error, TemplateBuildInfo] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + level=level, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> Response[Union[Error, TemplateBuildInfo]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + level (Union[Unset, LogLevel]): State of the sandbox + + 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[Error, TemplateBuildInfo]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + logs_offset=logs_offset, + level=level, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> Optional[Union[Error, TemplateBuildInfo]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + level (Union[Unset, LogLevel]): State of the sandbox + + 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[Error, TemplateBuildInfo] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + level=level, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/get_templates_template_id_files_hash.py b/ucloud_sandbox/api/client/api/templates/get_templates_template_id_files_hash.py new file mode 100644 index 0000000..0f6a1e4 --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/get_templates_template_id_files_hash.py @@ -0,0 +1,180 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_file_upload import TemplateBuildFileUpload +from ...types import Response + + +def _get_kwargs( + template_id: str, + hash_: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/files/{hash_}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateBuildFileUpload]]: + if response.status_code == 201: + response_201 = TemplateBuildFileUpload.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateBuildFileUpload]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + 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[Error, TemplateBuildFileUpload]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + hash_=hash_, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + 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[Error, TemplateBuildFileUpload] + """ + + return sync_detailed( + template_id=template_id, + hash_=hash_, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + 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[Error, TemplateBuildFileUpload]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + hash_=hash_, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + 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[Error, TemplateBuildFileUpload] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + hash_=hash_, + client=client, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/patch_templates_template_id.py b/ucloud_sandbox/api/client/api/templates/patch_templates_template_id.py new file mode 100644 index 0000000..cf19391 --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/patch_templates_template_id.py @@ -0,0 +1,183 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_update_request import TemplateUpdateRequest +from ...types import Response + + +def _get_kwargs( + template_id: str, + *, + body: TemplateUpdateRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": f"/templates/{template_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 200: + response_200 = cast(Any, None) + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Response[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + 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, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Optional[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + 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, Error] + """ + + return sync_detailed( + template_id=template_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Response[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + 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, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Optional[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + 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, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/post_templates.py b/ucloud_sandbox/api/client/api/templates/post_templates.py new file mode 100644 index 0000000..015b0d9 --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/post_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_request import TemplateBuildRequest +from ...models.template_legacy import TemplateLegacy +from ...types import Response + + +def _get_kwargs( + *, + body: TemplateBuildRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/templates", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateLegacy]]: + if response.status_code == 202: + response_202 = TemplateLegacy.from_dict(response.json()) + + return response_202 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateLegacy]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + 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[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + 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[Error, TemplateLegacy] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + 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[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + 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[Error, TemplateLegacy] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/post_templates_template_id.py b/ucloud_sandbox/api/client/api/templates/post_templates_template_id.py new file mode 100644 index 0000000..986a3b5 --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/post_templates_template_id.py @@ -0,0 +1,181 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_request import TemplateBuildRequest +from ...models.template_legacy import TemplateLegacy +from ...types import Response + + +def _get_kwargs( + template_id: str, + *, + body: TemplateBuildRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/templates/{template_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateLegacy]]: + if response.status_code == 202: + response_202 = TemplateLegacy.from_dict(response.json()) + + return response_202 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateLegacy]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, TemplateLegacy]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + 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[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, TemplateLegacy]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + 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[Error, TemplateLegacy] + """ + + return sync_detailed( + template_id=template_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, TemplateLegacy]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + 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[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, TemplateLegacy]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + 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[Error, TemplateLegacy] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/post_templates_template_id_builds_build_id.py b/ucloud_sandbox/api/client/api/templates/post_templates_template_id_builds_build_id.py new file mode 100644 index 0000000..d2709ff --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/post_templates_template_id_builds_build_id.py @@ -0,0 +1,170 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + template_id: str, + build_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/templates/{template_id}/builds/{build_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 202: + response_202 = cast(Any, None) + return response_202 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_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, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_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, Error] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_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, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_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, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/post_v2_templates.py b/ucloud_sandbox/api/client/api/templates/post_v2_templates.py new file mode 100644 index 0000000..86b5ebe --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/post_v2_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_request_v2 import TemplateBuildRequestV2 +from ...models.template_legacy import TemplateLegacy +from ...types import Response + + +def _get_kwargs( + *, + body: TemplateBuildRequestV2, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v2/templates", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateLegacy]]: + if response.status_code == 202: + response_202 = TemplateLegacy.from_dict(response.json()) + + return response_202 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateLegacy]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Response[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + 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[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Optional[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + 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[Error, TemplateLegacy] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Response[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + 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[Error, TemplateLegacy]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Optional[Union[Error, TemplateLegacy]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + 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[Error, TemplateLegacy] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/post_v3_templates.py b/ucloud_sandbox/api/client/api/templates/post_v3_templates.py new file mode 100644 index 0000000..4a39ba3 --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/post_v3_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_request_v3 import TemplateBuildRequestV3 +from ...models.template_request_response_v3 import TemplateRequestResponseV3 +from ...types import Response + + +def _get_kwargs( + *, + body: TemplateBuildRequestV3, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v3/templates", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateRequestResponseV3]]: + if response.status_code == 202: + response_202 = TemplateRequestResponseV3.from_dict(response.json()) + + return response_202 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateRequestResponseV3]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV3, +) -> Response[Union[Error, TemplateRequestResponseV3]]: + """Create a new template + + Args: + body (TemplateBuildRequestV3): + + 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[Error, TemplateRequestResponseV3]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV3, +) -> Optional[Union[Error, TemplateRequestResponseV3]]: + """Create a new template + + Args: + body (TemplateBuildRequestV3): + + 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[Error, TemplateRequestResponseV3] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV3, +) -> Response[Union[Error, TemplateRequestResponseV3]]: + """Create a new template + + Args: + body (TemplateBuildRequestV3): + + 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[Error, TemplateRequestResponseV3]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV3, +) -> Optional[Union[Error, TemplateRequestResponseV3]]: + """Create a new template + + Args: + body (TemplateBuildRequestV3): + + 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[Error, TemplateRequestResponseV3] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py b/ucloud_sandbox/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py new file mode 100644 index 0000000..0d3da1f --- /dev/null +++ b/ucloud_sandbox/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py @@ -0,0 +1,192 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_start_v2 import TemplateBuildStartV2 +from ...types import Response + + +def _get_kwargs( + template_id: str, + build_id: str, + *, + body: TemplateBuildStartV2, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/v2/templates/{template_id}/builds/{build_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 202: + response_202 = cast(Any, None) + return response_202 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.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: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + 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, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + 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, Error] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + 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, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + 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, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=body, + ) + ).parsed diff --git a/ucloud_sandbox/api/client/client.py b/ucloud_sandbox/api/client/client.py new file mode 100644 index 0000000..eeffd00 --- /dev/null +++ b/ucloud_sandbox/api/client/client.py @@ -0,0 +1,286 @@ +import ssl +from typing import Any, Optional, Union + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field( + default=None, kw_only=True, alias="timeout" + ) + _verify_ssl: Union[str, bool, ssl.SSLContext] = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + def with_headers(self, headers: dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + token: The token to use for authentication + prefix: The prefix to use for the Authorization header + auth_header_name: The name of the Authorization header + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field( + default=None, kw_only=True, alias="timeout" + ) + _verify_ssl: Union[str, bool, ssl.SSLContext] = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + token: str + prefix: str = "Bearer" + auth_header_name: str = "Authorization" + + def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client( + self, async_client: httpx.AsyncClient + ) -> "AuthenticatedClient": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/ucloud_sandbox/api/client/errors.py b/ucloud_sandbox/api/client/errors.py new file mode 100644 index 0000000..5f92e76 --- /dev/null +++ b/ucloud_sandbox/api/client/errors.py @@ -0,0 +1,16 @@ +"""Contains shared errors types that can be raised from API functions""" + + +class UnexpectedStatus(Exception): + """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content + + super().__init__( + f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}" + ) + + +__all__ = ["UnexpectedStatus"] diff --git a/ucloud_sandbox/api/client/models/__init__.py b/ucloud_sandbox/api/client/models/__init__.py new file mode 100644 index 0000000..6c81cf2 --- /dev/null +++ b/ucloud_sandbox/api/client/models/__init__.py @@ -0,0 +1,123 @@ +"""Contains all the data models used in inputs/outputs""" + +from .aws_registry import AWSRegistry +from .aws_registry_type import AWSRegistryType +from .build_log_entry import BuildLogEntry +from .build_status_reason import BuildStatusReason +from .connect_sandbox import ConnectSandbox +from .created_access_token import CreatedAccessToken +from .created_team_api_key import CreatedTeamAPIKey +from .disk_metrics import DiskMetrics +from .error import Error +from .gcp_registry import GCPRegistry +from .gcp_registry_type import GCPRegistryType +from .general_registry import GeneralRegistry +from .general_registry_type import GeneralRegistryType +from .identifier_masking_details import IdentifierMaskingDetails +from .listed_sandbox import ListedSandbox +from .log_level import LogLevel +from .max_team_metric import MaxTeamMetric +from .mcp_type_0 import McpType0 +from .new_access_token import NewAccessToken +from .new_sandbox import NewSandbox +from .new_team_api_key import NewTeamAPIKey +from .node import Node +from .node_detail import NodeDetail +from .node_metrics import NodeMetrics +from .node_status import NodeStatus +from .node_status_change import NodeStatusChange +from .post_sandboxes_sandbox_id_refreshes_body import ( + PostSandboxesSandboxIDRefreshesBody, +) +from .post_sandboxes_sandbox_id_timeout_body import PostSandboxesSandboxIDTimeoutBody +from .resumed_sandbox import ResumedSandbox +from .sandbox import Sandbox +from .sandbox_detail import SandboxDetail +from .sandbox_log import SandboxLog +from .sandbox_log_entry import SandboxLogEntry +from .sandbox_log_entry_fields import SandboxLogEntryFields +from .sandbox_logs import SandboxLogs +from .sandbox_metric import SandboxMetric +from .sandbox_network_config import SandboxNetworkConfig +from .sandbox_state import SandboxState +from .sandboxes_with_metrics import SandboxesWithMetrics +from .team import Team +from .team_api_key import TeamAPIKey +from .team_metric import TeamMetric +from .team_user import TeamUser +from .template import Template +from .template_build import TemplateBuild +from .template_build_file_upload import TemplateBuildFileUpload +from .template_build_info import TemplateBuildInfo +from .template_build_request import TemplateBuildRequest +from .template_build_request_v2 import TemplateBuildRequestV2 +from .template_build_request_v3 import TemplateBuildRequestV3 +from .template_build_start_v2 import TemplateBuildStartV2 +from .template_build_status import TemplateBuildStatus +from .template_legacy import TemplateLegacy +from .template_request_response_v3 import TemplateRequestResponseV3 +from .template_step import TemplateStep +from .template_update_request import TemplateUpdateRequest +from .template_with_builds import TemplateWithBuilds +from .update_team_api_key import UpdateTeamAPIKey + +__all__ = ( + "AWSRegistry", + "AWSRegistryType", + "BuildLogEntry", + "BuildStatusReason", + "ConnectSandbox", + "CreatedAccessToken", + "CreatedTeamAPIKey", + "DiskMetrics", + "Error", + "GCPRegistry", + "GCPRegistryType", + "GeneralRegistry", + "GeneralRegistryType", + "IdentifierMaskingDetails", + "ListedSandbox", + "LogLevel", + "MaxTeamMetric", + "McpType0", + "NewAccessToken", + "NewSandbox", + "NewTeamAPIKey", + "Node", + "NodeDetail", + "NodeMetrics", + "NodeStatus", + "NodeStatusChange", + "PostSandboxesSandboxIDRefreshesBody", + "PostSandboxesSandboxIDTimeoutBody", + "ResumedSandbox", + "Sandbox", + "SandboxDetail", + "SandboxesWithMetrics", + "SandboxLog", + "SandboxLogEntry", + "SandboxLogEntryFields", + "SandboxLogs", + "SandboxMetric", + "SandboxNetworkConfig", + "SandboxState", + "Team", + "TeamAPIKey", + "TeamMetric", + "TeamUser", + "Template", + "TemplateBuild", + "TemplateBuildFileUpload", + "TemplateBuildInfo", + "TemplateBuildRequest", + "TemplateBuildRequestV2", + "TemplateBuildRequestV3", + "TemplateBuildStartV2", + "TemplateBuildStatus", + "TemplateLegacy", + "TemplateRequestResponseV3", + "TemplateStep", + "TemplateUpdateRequest", + "TemplateWithBuilds", + "UpdateTeamAPIKey", +) diff --git a/ucloud_sandbox/api/client/models/aws_registry.py b/ucloud_sandbox/api/client/models/aws_registry.py new file mode 100644 index 0000000..ef1ea97 --- /dev/null +++ b/ucloud_sandbox/api/client/models/aws_registry.py @@ -0,0 +1,85 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.aws_registry_type import AWSRegistryType + +T = TypeVar("T", bound="AWSRegistry") + + +@_attrs_define +class AWSRegistry: + """ + Attributes: + aws_access_key_id (str): AWS Access Key ID for ECR authentication + aws_region (str): AWS Region where the ECR registry is located + aws_secret_access_key (str): AWS Secret Access Key for ECR authentication + type_ (AWSRegistryType): Type of registry authentication + """ + + aws_access_key_id: str + aws_region: str + aws_secret_access_key: str + type_: AWSRegistryType + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + aws_access_key_id = self.aws_access_key_id + + aws_region = self.aws_region + + aws_secret_access_key = self.aws_secret_access_key + + type_ = self.type_.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "awsAccessKeyId": aws_access_key_id, + "awsRegion": aws_region, + "awsSecretAccessKey": aws_secret_access_key, + "type": type_, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + aws_access_key_id = d.pop("awsAccessKeyId") + + aws_region = d.pop("awsRegion") + + aws_secret_access_key = d.pop("awsSecretAccessKey") + + type_ = AWSRegistryType(d.pop("type")) + + aws_registry = cls( + aws_access_key_id=aws_access_key_id, + aws_region=aws_region, + aws_secret_access_key=aws_secret_access_key, + type_=type_, + ) + + aws_registry.additional_properties = d + return aws_registry + + @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/ucloud_sandbox/api/client/models/aws_registry_type.py b/ucloud_sandbox/api/client/models/aws_registry_type.py new file mode 100644 index 0000000..6815bbc --- /dev/null +++ b/ucloud_sandbox/api/client/models/aws_registry_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class AWSRegistryType(str, Enum): + AWS = "aws" + + def __str__(self) -> str: + return str(self.value) diff --git a/ucloud_sandbox/api/client/models/build_log_entry.py b/ucloud_sandbox/api/client/models/build_log_entry.py new file mode 100644 index 0000000..35cae95 --- /dev/null +++ b/ucloud_sandbox/api/client/models/build_log_entry.py @@ -0,0 +1,89 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.log_level import LogLevel +from ..types import UNSET, Unset + +T = TypeVar("T", bound="BuildLogEntry") + + +@_attrs_define +class BuildLogEntry: + """ + Attributes: + level (LogLevel): State of the sandbox + message (str): Log message content + timestamp (datetime.datetime): Timestamp of the log entry + step (Union[Unset, str]): Step in the build process related to the log entry + """ + + level: LogLevel + message: str + timestamp: datetime.datetime + step: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + level = self.level.value + + message = self.message + + timestamp = self.timestamp.isoformat() + + step = self.step + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "level": level, + "message": message, + "timestamp": timestamp, + } + ) + if step is not UNSET: + field_dict["step"] = step + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + level = LogLevel(d.pop("level")) + + message = d.pop("message") + + timestamp = isoparse(d.pop("timestamp")) + + step = d.pop("step", UNSET) + + build_log_entry = cls( + level=level, + message=message, + timestamp=timestamp, + step=step, + ) + + build_log_entry.additional_properties = d + return build_log_entry + + @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/ucloud_sandbox/api/client/models/build_status_reason.py b/ucloud_sandbox/api/client/models/build_status_reason.py new file mode 100644 index 0000000..e2af403 --- /dev/null +++ b/ucloud_sandbox/api/client/models/build_status_reason.py @@ -0,0 +1,95 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.build_log_entry import BuildLogEntry + + +T = TypeVar("T", bound="BuildStatusReason") + + +@_attrs_define +class BuildStatusReason: + """ + Attributes: + message (str): Message with the status reason, currently reporting only for error status + log_entries (Union[Unset, list['BuildLogEntry']]): Log entries related to the status reason + step (Union[Unset, str]): Step that failed + """ + + message: str + log_entries: Union[Unset, list["BuildLogEntry"]] = UNSET + step: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + message = self.message + + log_entries: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.log_entries, Unset): + log_entries = [] + for log_entries_item_data in self.log_entries: + log_entries_item = log_entries_item_data.to_dict() + log_entries.append(log_entries_item) + + step = self.step + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "message": message, + } + ) + if log_entries is not UNSET: + field_dict["logEntries"] = log_entries + if step is not UNSET: + field_dict["step"] = step + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.build_log_entry import BuildLogEntry + + d = dict(src_dict) + message = d.pop("message") + + log_entries = [] + _log_entries = d.pop("logEntries", UNSET) + for log_entries_item_data in _log_entries or []: + log_entries_item = BuildLogEntry.from_dict(log_entries_item_data) + + log_entries.append(log_entries_item) + + step = d.pop("step", UNSET) + + build_status_reason = cls( + message=message, + log_entries=log_entries, + step=step, + ) + + build_status_reason.additional_properties = d + return build_status_reason + + @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/ucloud_sandbox/api/client/models/connect_sandbox.py b/ucloud_sandbox/api/client/models/connect_sandbox.py new file mode 100644 index 0000000..de7a8f8 --- /dev/null +++ b/ucloud_sandbox/api/client/models/connect_sandbox.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ConnectSandbox") + + +@_attrs_define +class ConnectSandbox: + """ + Attributes: + timeout (int): Timeout in seconds from the current time after which the sandbox should expire + """ + + timeout: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + timeout = self.timeout + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "timeout": timeout, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + timeout = d.pop("timeout") + + connect_sandbox = cls( + timeout=timeout, + ) + + connect_sandbox.additional_properties = d + return connect_sandbox + + @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/ucloud_sandbox/api/client/models/created_access_token.py b/ucloud_sandbox/api/client/models/created_access_token.py new file mode 100644 index 0000000..04224ad --- /dev/null +++ b/ucloud_sandbox/api/client/models/created_access_token.py @@ -0,0 +1,100 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +if TYPE_CHECKING: + from ..models.identifier_masking_details import IdentifierMaskingDetails + + +T = TypeVar("T", bound="CreatedAccessToken") + + +@_attrs_define +class CreatedAccessToken: + """ + Attributes: + created_at (datetime.datetime): Timestamp of access token creation + id (UUID): Identifier of the access token + mask (IdentifierMaskingDetails): + name (str): Name of the access token + token (str): The fully created access token + """ + + created_at: datetime.datetime + id: UUID + mask: "IdentifierMaskingDetails" + name: str + token: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + created_at = self.created_at.isoformat() + + id = str(self.id) + + mask = self.mask.to_dict() + + name = self.name + + token = self.token + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "createdAt": created_at, + "id": id, + "mask": mask, + "name": name, + "token": token, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.identifier_masking_details import IdentifierMaskingDetails + + d = dict(src_dict) + created_at = isoparse(d.pop("createdAt")) + + id = UUID(d.pop("id")) + + mask = IdentifierMaskingDetails.from_dict(d.pop("mask")) + + name = d.pop("name") + + token = d.pop("token") + + created_access_token = cls( + created_at=created_at, + id=id, + mask=mask, + name=name, + token=token, + ) + + created_access_token.additional_properties = d + return created_access_token + + @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/ucloud_sandbox/api/client/models/created_team_api_key.py b/ucloud_sandbox/api/client/models/created_team_api_key.py new file mode 100644 index 0000000..f1d4150 --- /dev/null +++ b/ucloud_sandbox/api/client/models/created_team_api_key.py @@ -0,0 +1,166 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.identifier_masking_details import IdentifierMaskingDetails + from ..models.team_user import TeamUser + + +T = TypeVar("T", bound="CreatedTeamAPIKey") + + +@_attrs_define +class CreatedTeamAPIKey: + """ + Attributes: + created_at (datetime.datetime): Timestamp of API key creation + id (UUID): Identifier of the API key + key (str): Raw value of the API key + mask (IdentifierMaskingDetails): + name (str): Name of the API key + created_by (Union['TeamUser', None, Unset]): + last_used (Union[None, Unset, datetime.datetime]): Last time this API key was used + """ + + created_at: datetime.datetime + id: UUID + key: str + mask: "IdentifierMaskingDetails" + name: str + created_by: Union["TeamUser", None, Unset] = UNSET + last_used: Union[None, Unset, datetime.datetime] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.team_user import TeamUser + + created_at = self.created_at.isoformat() + + id = str(self.id) + + key = self.key + + mask = self.mask.to_dict() + + name = self.name + + created_by: Union[None, Unset, dict[str, Any]] + if isinstance(self.created_by, Unset): + created_by = UNSET + elif isinstance(self.created_by, TeamUser): + created_by = self.created_by.to_dict() + else: + created_by = self.created_by + + last_used: Union[None, Unset, str] + if isinstance(self.last_used, Unset): + last_used = UNSET + elif isinstance(self.last_used, datetime.datetime): + last_used = self.last_used.isoformat() + else: + last_used = self.last_used + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "createdAt": created_at, + "id": id, + "key": key, + "mask": mask, + "name": name, + } + ) + if created_by is not UNSET: + field_dict["createdBy"] = created_by + if last_used is not UNSET: + field_dict["lastUsed"] = last_used + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.identifier_masking_details import IdentifierMaskingDetails + from ..models.team_user import TeamUser + + d = dict(src_dict) + created_at = isoparse(d.pop("createdAt")) + + id = UUID(d.pop("id")) + + key = d.pop("key") + + mask = IdentifierMaskingDetails.from_dict(d.pop("mask")) + + name = d.pop("name") + + def _parse_created_by(data: object) -> Union["TeamUser", None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + created_by_type_1 = TeamUser.from_dict(data) + + return created_by_type_1 + except: # noqa: E722 + pass + return cast(Union["TeamUser", None, Unset], data) + + created_by = _parse_created_by(d.pop("createdBy", UNSET)) + + def _parse_last_used(data: object) -> Union[None, Unset, datetime.datetime]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + last_used_type_0 = isoparse(data) + + return last_used_type_0 + except: # noqa: E722 + pass + return cast(Union[None, Unset, datetime.datetime], data) + + last_used = _parse_last_used(d.pop("lastUsed", UNSET)) + + created_team_api_key = cls( + created_at=created_at, + id=id, + key=key, + mask=mask, + name=name, + created_by=created_by, + last_used=last_used, + ) + + created_team_api_key.additional_properties = d + return created_team_api_key + + @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/ucloud_sandbox/api/client/models/disk_metrics.py b/ucloud_sandbox/api/client/models/disk_metrics.py new file mode 100644 index 0000000..6d2b75c --- /dev/null +++ b/ucloud_sandbox/api/client/models/disk_metrics.py @@ -0,0 +1,91 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DiskMetrics") + + +@_attrs_define +class DiskMetrics: + """ + Attributes: + device (str): Device name + filesystem_type (str): Filesystem type (e.g., ext4, xfs) + mount_point (str): Mount point of the disk + total_bytes (int): Total space in bytes + used_bytes (int): Used space in bytes + """ + + device: str + filesystem_type: str + mount_point: str + total_bytes: int + used_bytes: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + device = self.device + + filesystem_type = self.filesystem_type + + mount_point = self.mount_point + + total_bytes = self.total_bytes + + used_bytes = self.used_bytes + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "device": device, + "filesystemType": filesystem_type, + "mountPoint": mount_point, + "totalBytes": total_bytes, + "usedBytes": used_bytes, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + device = d.pop("device") + + filesystem_type = d.pop("filesystemType") + + mount_point = d.pop("mountPoint") + + total_bytes = d.pop("totalBytes") + + used_bytes = d.pop("usedBytes") + + disk_metrics = cls( + device=device, + filesystem_type=filesystem_type, + mount_point=mount_point, + total_bytes=total_bytes, + used_bytes=used_bytes, + ) + + disk_metrics.additional_properties = d + return disk_metrics + + @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/ucloud_sandbox/api/client/models/error.py b/ucloud_sandbox/api/client/models/error.py new file mode 100644 index 0000000..362b436 --- /dev/null +++ b/ucloud_sandbox/api/client/models/error.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Error") + + +@_attrs_define +class Error: + """ + Attributes: + code (int): Error code + message (str): Error + """ + + code: int + message: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + code = self.code + + message = self.message + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "code": code, + "message": message, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + code = d.pop("code") + + message = d.pop("message") + + error = cls( + code=code, + message=message, + ) + + error.additional_properties = d + return error + + @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/ucloud_sandbox/api/client/models/gcp_registry.py b/ucloud_sandbox/api/client/models/gcp_registry.py new file mode 100644 index 0000000..01b6ded --- /dev/null +++ b/ucloud_sandbox/api/client/models/gcp_registry.py @@ -0,0 +1,69 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.gcp_registry_type import GCPRegistryType + +T = TypeVar("T", bound="GCPRegistry") + + +@_attrs_define +class GCPRegistry: + """ + Attributes: + service_account_json (str): Service Account JSON for GCP authentication + type_ (GCPRegistryType): Type of registry authentication + """ + + service_account_json: str + type_: GCPRegistryType + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + service_account_json = self.service_account_json + + type_ = self.type_.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "serviceAccountJson": service_account_json, + "type": type_, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + service_account_json = d.pop("serviceAccountJson") + + type_ = GCPRegistryType(d.pop("type")) + + gcp_registry = cls( + service_account_json=service_account_json, + type_=type_, + ) + + gcp_registry.additional_properties = d + return gcp_registry + + @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/ucloud_sandbox/api/client/models/gcp_registry_type.py b/ucloud_sandbox/api/client/models/gcp_registry_type.py new file mode 100644 index 0000000..e9c8aa5 --- /dev/null +++ b/ucloud_sandbox/api/client/models/gcp_registry_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class GCPRegistryType(str, Enum): + GCP = "gcp" + + def __str__(self) -> str: + return str(self.value) diff --git a/ucloud_sandbox/api/client/models/general_registry.py b/ucloud_sandbox/api/client/models/general_registry.py new file mode 100644 index 0000000..38bc179 --- /dev/null +++ b/ucloud_sandbox/api/client/models/general_registry.py @@ -0,0 +1,77 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.general_registry_type import GeneralRegistryType + +T = TypeVar("T", bound="GeneralRegistry") + + +@_attrs_define +class GeneralRegistry: + """ + Attributes: + password (str): Password to use for the registry + type_ (GeneralRegistryType): Type of registry authentication + username (str): Username to use for the registry + """ + + password: str + type_: GeneralRegistryType + username: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + password = self.password + + type_ = self.type_.value + + username = self.username + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "password": password, + "type": type_, + "username": username, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + password = d.pop("password") + + type_ = GeneralRegistryType(d.pop("type")) + + username = d.pop("username") + + general_registry = cls( + password=password, + type_=type_, + username=username, + ) + + general_registry.additional_properties = d + return general_registry + + @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/ucloud_sandbox/api/client/models/general_registry_type.py b/ucloud_sandbox/api/client/models/general_registry_type.py new file mode 100644 index 0000000..d89e784 --- /dev/null +++ b/ucloud_sandbox/api/client/models/general_registry_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class GeneralRegistryType(str, Enum): + REGISTRY = "registry" + + def __str__(self) -> str: + return str(self.value) diff --git a/ucloud_sandbox/api/client/models/identifier_masking_details.py b/ucloud_sandbox/api/client/models/identifier_masking_details.py new file mode 100644 index 0000000..60c0bde --- /dev/null +++ b/ucloud_sandbox/api/client/models/identifier_masking_details.py @@ -0,0 +1,83 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="IdentifierMaskingDetails") + + +@_attrs_define +class IdentifierMaskingDetails: + """ + Attributes: + masked_value_prefix (str): Prefix used in masked version of the token or key + masked_value_suffix (str): Suffix used in masked version of the token or key + prefix (str): Prefix that identifies the token or key type + value_length (int): Length of the token or key + """ + + masked_value_prefix: str + masked_value_suffix: str + prefix: str + value_length: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + masked_value_prefix = self.masked_value_prefix + + masked_value_suffix = self.masked_value_suffix + + prefix = self.prefix + + value_length = self.value_length + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "maskedValuePrefix": masked_value_prefix, + "maskedValueSuffix": masked_value_suffix, + "prefix": prefix, + "valueLength": value_length, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + masked_value_prefix = d.pop("maskedValuePrefix") + + masked_value_suffix = d.pop("maskedValueSuffix") + + prefix = d.pop("prefix") + + value_length = d.pop("valueLength") + + identifier_masking_details = cls( + masked_value_prefix=masked_value_prefix, + masked_value_suffix=masked_value_suffix, + prefix=prefix, + value_length=value_length, + ) + + identifier_masking_details.additional_properties = d + return identifier_masking_details + + @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/ucloud_sandbox/api/client/models/listed_sandbox.py b/ucloud_sandbox/api/client/models/listed_sandbox.py new file mode 100644 index 0000000..2d385a4 --- /dev/null +++ b/ucloud_sandbox/api/client/models/listed_sandbox.py @@ -0,0 +1,154 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.sandbox_state import SandboxState +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ListedSandbox") + + +@_attrs_define +class ListedSandbox: + """ + Attributes: + client_id (str): Identifier of the client + cpu_count (int): CPU cores for the sandbox + disk_size_mb (int): Disk size for the sandbox in MiB + end_at (datetime.datetime): Time when the sandbox will expire + envd_version (str): Version of the envd running in the sandbox + memory_mb (int): Memory for the sandbox in MiB + sandbox_id (str): Identifier of the sandbox + started_at (datetime.datetime): Time when the sandbox was started + state (SandboxState): State of the sandbox + template_id (str): Identifier of the template from which is the sandbox created + alias (Union[Unset, str]): Alias of the template + metadata (Union[Unset, Any]): + """ + + client_id: str + cpu_count: int + disk_size_mb: int + end_at: datetime.datetime + envd_version: str + memory_mb: int + sandbox_id: str + started_at: datetime.datetime + state: SandboxState + template_id: str + alias: Union[Unset, str] = UNSET + metadata: Union[Unset, Any] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + client_id = self.client_id + + cpu_count = self.cpu_count + + disk_size_mb = self.disk_size_mb + + end_at = self.end_at.isoformat() + + envd_version = self.envd_version + + memory_mb = self.memory_mb + + sandbox_id = self.sandbox_id + + started_at = self.started_at.isoformat() + + state = self.state.value + + template_id = self.template_id + + alias = self.alias + + metadata = self.metadata + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "clientID": client_id, + "cpuCount": cpu_count, + "diskSizeMB": disk_size_mb, + "endAt": end_at, + "envdVersion": envd_version, + "memoryMB": memory_mb, + "sandboxID": sandbox_id, + "startedAt": started_at, + "state": state, + "templateID": template_id, + } + ) + if alias is not UNSET: + field_dict["alias"] = alias + if metadata is not UNSET: + field_dict["metadata"] = metadata + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + client_id = d.pop("clientID") + + cpu_count = d.pop("cpuCount") + + disk_size_mb = d.pop("diskSizeMB") + + end_at = isoparse(d.pop("endAt")) + + envd_version = d.pop("envdVersion") + + memory_mb = d.pop("memoryMB") + + sandbox_id = d.pop("sandboxID") + + started_at = isoparse(d.pop("startedAt")) + + state = SandboxState(d.pop("state")) + + template_id = d.pop("templateID") + + alias = d.pop("alias", UNSET) + + metadata = d.pop("metadata", UNSET) + + listed_sandbox = cls( + client_id=client_id, + cpu_count=cpu_count, + disk_size_mb=disk_size_mb, + end_at=end_at, + envd_version=envd_version, + memory_mb=memory_mb, + sandbox_id=sandbox_id, + started_at=started_at, + state=state, + template_id=template_id, + alias=alias, + metadata=metadata, + ) + + listed_sandbox.additional_properties = d + return listed_sandbox + + @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/ucloud_sandbox/api/client/models/log_level.py b/ucloud_sandbox/api/client/models/log_level.py new file mode 100644 index 0000000..bb5bcd9 --- /dev/null +++ b/ucloud_sandbox/api/client/models/log_level.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class LogLevel(str, Enum): + DEBUG = "debug" + ERROR = "error" + INFO = "info" + WARN = "warn" + + def __str__(self) -> str: + return str(self.value) diff --git a/ucloud_sandbox/api/client/models/max_team_metric.py b/ucloud_sandbox/api/client/models/max_team_metric.py new file mode 100644 index 0000000..4a9c3e5 --- /dev/null +++ b/ucloud_sandbox/api/client/models/max_team_metric.py @@ -0,0 +1,78 @@ +import datetime +from collections.abc import Mapping +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="MaxTeamMetric") + + +@_attrs_define +class MaxTeamMetric: + """Team metric with timestamp + + Attributes: + timestamp (datetime.datetime): Timestamp of the metric entry + timestamp_unix (int): Timestamp of the metric entry in Unix time (seconds since epoch) + value (float): The maximum value of the requested metric in the given interval + """ + + timestamp: datetime.datetime + timestamp_unix: int + value: float + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + timestamp = self.timestamp.isoformat() + + timestamp_unix = self.timestamp_unix + + value = self.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "timestamp": timestamp, + "timestampUnix": timestamp_unix, + "value": value, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + timestamp = isoparse(d.pop("timestamp")) + + timestamp_unix = d.pop("timestampUnix") + + value = d.pop("value") + + max_team_metric = cls( + timestamp=timestamp, + timestamp_unix=timestamp_unix, + value=value, + ) + + max_team_metric.additional_properties = d + return max_team_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/ucloud_sandbox/api/client/models/mcp_type_0.py b/ucloud_sandbox/api/client/models/mcp_type_0.py new file mode 100644 index 0000000..a58d6ea --- /dev/null +++ b/ucloud_sandbox/api/client/models/mcp_type_0.py @@ -0,0 +1,44 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="McpType0") + + +@_attrs_define +class McpType0: + """MCP configuration for the sandbox""" + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + mcp_type_0 = cls() + + mcp_type_0.additional_properties = d + return mcp_type_0 + + @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/ucloud_sandbox/api/client/models/new_access_token.py b/ucloud_sandbox/api/client/models/new_access_token.py new file mode 100644 index 0000000..642dac8 --- /dev/null +++ b/ucloud_sandbox/api/client/models/new_access_token.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="NewAccessToken") + + +@_attrs_define +class NewAccessToken: + """ + Attributes: + name (str): Name of the access token + """ + + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + new_access_token = cls( + name=name, + ) + + new_access_token.additional_properties = d + return new_access_token + + @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/ucloud_sandbox/api/client/models/new_sandbox.py b/ucloud_sandbox/api/client/models/new_sandbox.py new file mode 100644 index 0000000..1f80c90 --- /dev/null +++ b/ucloud_sandbox/api/client/models/new_sandbox.py @@ -0,0 +1,172 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.mcp_type_0 import McpType0 + from ..models.sandbox_network_config import SandboxNetworkConfig + + +T = TypeVar("T", bound="NewSandbox") + + +@_attrs_define +class NewSandbox: + """ + Attributes: + template_id (str): Identifier of the required template + allow_internet_access (Union[Unset, bool]): Allow sandbox to access the internet. When set to false, it behaves + the same as specifying denyOut to 0.0.0.0/0 in the network config. + auto_pause (Union[Unset, bool]): Automatically pauses the sandbox after the timeout Default: False. + env_vars (Union[Unset, Any]): + mcp (Union['McpType0', None, Unset]): MCP configuration for the sandbox + metadata (Union[Unset, Any]): + network (Union[Unset, SandboxNetworkConfig]): + secure (Union[Unset, bool]): Secure all system communication with sandbox + timeout (Union[Unset, int]): Time to live for the sandbox in seconds. Default: 15. + """ + + template_id: str + allow_internet_access: Union[Unset, bool] = UNSET + auto_pause: Union[Unset, bool] = False + env_vars: Union[Unset, Any] = UNSET + mcp: Union["McpType0", None, Unset] = UNSET + metadata: Union[Unset, Any] = UNSET + network: Union[Unset, "SandboxNetworkConfig"] = UNSET + secure: Union[Unset, bool] = UNSET + timeout: Union[Unset, int] = 15 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.mcp_type_0 import McpType0 + + template_id = self.template_id + + allow_internet_access = self.allow_internet_access + + auto_pause = self.auto_pause + + env_vars = self.env_vars + + mcp: Union[None, Unset, dict[str, Any]] + if isinstance(self.mcp, Unset): + mcp = UNSET + elif isinstance(self.mcp, McpType0): + mcp = self.mcp.to_dict() + else: + mcp = self.mcp + + metadata = self.metadata + + network: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.network, Unset): + network = self.network.to_dict() + + secure = self.secure + + timeout = self.timeout + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "templateID": template_id, + } + ) + if allow_internet_access is not UNSET: + field_dict["allow_internet_access"] = allow_internet_access + if auto_pause is not UNSET: + field_dict["autoPause"] = auto_pause + if env_vars is not UNSET: + field_dict["envVars"] = env_vars + if mcp is not UNSET: + field_dict["mcp"] = mcp + if metadata is not UNSET: + field_dict["metadata"] = metadata + if network is not UNSET: + field_dict["network"] = network + if secure is not UNSET: + field_dict["secure"] = secure + if timeout is not UNSET: + field_dict["timeout"] = timeout + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.mcp_type_0 import McpType0 + from ..models.sandbox_network_config import SandboxNetworkConfig + + d = dict(src_dict) + template_id = d.pop("templateID") + + allow_internet_access = d.pop("allow_internet_access", UNSET) + + auto_pause = d.pop("autoPause", UNSET) + + env_vars = d.pop("envVars", UNSET) + + def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_mcp_type_0 = McpType0.from_dict(data) + + return componentsschemas_mcp_type_0 + except: # noqa: E722 + pass + return cast(Union["McpType0", None, Unset], data) + + mcp = _parse_mcp(d.pop("mcp", UNSET)) + + metadata = d.pop("metadata", UNSET) + + _network = d.pop("network", UNSET) + network: Union[Unset, SandboxNetworkConfig] + if isinstance(_network, Unset): + network = UNSET + else: + network = SandboxNetworkConfig.from_dict(_network) + + secure = d.pop("secure", UNSET) + + timeout = d.pop("timeout", UNSET) + + new_sandbox = cls( + template_id=template_id, + allow_internet_access=allow_internet_access, + auto_pause=auto_pause, + env_vars=env_vars, + mcp=mcp, + metadata=metadata, + network=network, + secure=secure, + timeout=timeout, + ) + + new_sandbox.additional_properties = d + return new_sandbox + + @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/ucloud_sandbox/api/client/models/new_team_api_key.py b/ucloud_sandbox/api/client/models/new_team_api_key.py new file mode 100644 index 0000000..2fac8a6 --- /dev/null +++ b/ucloud_sandbox/api/client/models/new_team_api_key.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="NewTeamAPIKey") + + +@_attrs_define +class NewTeamAPIKey: + """ + Attributes: + name (str): Name of the API key + """ + + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + new_team_api_key = cls( + name=name, + ) + + new_team_api_key.additional_properties = d + return new_team_api_key + + @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/ucloud_sandbox/api/client/models/node.py b/ucloud_sandbox/api/client/models/node.py new file mode 100644 index 0000000..d509369 --- /dev/null +++ b/ucloud_sandbox/api/client/models/node.py @@ -0,0 +1,155 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.node_status import NodeStatus + +if TYPE_CHECKING: + from ..models.node_metrics import NodeMetrics + + +T = TypeVar("T", bound="Node") + + +@_attrs_define +class Node: + """ + Attributes: + cluster_id (str): Identifier of the cluster + commit (str): Commit of the orchestrator + create_fails (int): Number of sandbox create fails + create_successes (int): Number of sandbox create successes + id (str): Identifier of the node + metrics (NodeMetrics): Node metrics + node_id (str): Identifier of the nomad node + sandbox_count (int): Number of sandboxes running on the node + sandbox_starting_count (int): Number of starting Sandboxes + service_instance_id (str): Service instance identifier of the node + status (NodeStatus): Status of the node + version (str): Version of the orchestrator + """ + + cluster_id: str + commit: str + create_fails: int + create_successes: int + id: str + metrics: "NodeMetrics" + node_id: str + sandbox_count: int + sandbox_starting_count: int + service_instance_id: str + status: NodeStatus + version: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + cluster_id = self.cluster_id + + commit = self.commit + + create_fails = self.create_fails + + create_successes = self.create_successes + + id = self.id + + metrics = self.metrics.to_dict() + + node_id = self.node_id + + sandbox_count = self.sandbox_count + + sandbox_starting_count = self.sandbox_starting_count + + service_instance_id = self.service_instance_id + + status = self.status.value + + version = self.version + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "clusterID": cluster_id, + "commit": commit, + "createFails": create_fails, + "createSuccesses": create_successes, + "id": id, + "metrics": metrics, + "nodeID": node_id, + "sandboxCount": sandbox_count, + "sandboxStartingCount": sandbox_starting_count, + "serviceInstanceID": service_instance_id, + "status": status, + "version": version, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.node_metrics import NodeMetrics + + d = dict(src_dict) + cluster_id = d.pop("clusterID") + + commit = d.pop("commit") + + create_fails = d.pop("createFails") + + create_successes = d.pop("createSuccesses") + + id = d.pop("id") + + metrics = NodeMetrics.from_dict(d.pop("metrics")) + + node_id = d.pop("nodeID") + + sandbox_count = d.pop("sandboxCount") + + sandbox_starting_count = d.pop("sandboxStartingCount") + + service_instance_id = d.pop("serviceInstanceID") + + status = NodeStatus(d.pop("status")) + + version = d.pop("version") + + node = cls( + cluster_id=cluster_id, + commit=commit, + create_fails=create_fails, + create_successes=create_successes, + id=id, + metrics=metrics, + node_id=node_id, + sandbox_count=sandbox_count, + sandbox_starting_count=sandbox_starting_count, + service_instance_id=service_instance_id, + status=status, + version=version, + ) + + node.additional_properties = d + return node + + @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/ucloud_sandbox/api/client/models/node_detail.py b/ucloud_sandbox/api/client/models/node_detail.py new file mode 100644 index 0000000..352ead6 --- /dev/null +++ b/ucloud_sandbox/api/client/models/node_detail.py @@ -0,0 +1,165 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.node_status import NodeStatus + +if TYPE_CHECKING: + from ..models.listed_sandbox import ListedSandbox + from ..models.node_metrics import NodeMetrics + + +T = TypeVar("T", bound="NodeDetail") + + +@_attrs_define +class NodeDetail: + """ + Attributes: + cached_builds (list[str]): List of cached builds id on the node + cluster_id (str): Identifier of the cluster + commit (str): Commit of the orchestrator + create_fails (int): Number of sandbox create fails + create_successes (int): Number of sandbox create successes + id (str): Identifier of the node + metrics (NodeMetrics): Node metrics + node_id (str): Identifier of the nomad node + sandboxes (list['ListedSandbox']): List of sandboxes running on the node + service_instance_id (str): Service instance identifier of the node + status (NodeStatus): Status of the node + version (str): Version of the orchestrator + """ + + cached_builds: list[str] + cluster_id: str + commit: str + create_fails: int + create_successes: int + id: str + metrics: "NodeMetrics" + node_id: str + sandboxes: list["ListedSandbox"] + service_instance_id: str + status: NodeStatus + version: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + cached_builds = self.cached_builds + + cluster_id = self.cluster_id + + commit = self.commit + + create_fails = self.create_fails + + create_successes = self.create_successes + + id = self.id + + metrics = self.metrics.to_dict() + + node_id = self.node_id + + sandboxes = [] + for sandboxes_item_data in self.sandboxes: + sandboxes_item = sandboxes_item_data.to_dict() + sandboxes.append(sandboxes_item) + + service_instance_id = self.service_instance_id + + status = self.status.value + + version = self.version + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "cachedBuilds": cached_builds, + "clusterID": cluster_id, + "commit": commit, + "createFails": create_fails, + "createSuccesses": create_successes, + "id": id, + "metrics": metrics, + "nodeID": node_id, + "sandboxes": sandboxes, + "serviceInstanceID": service_instance_id, + "status": status, + "version": version, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.listed_sandbox import ListedSandbox + from ..models.node_metrics import NodeMetrics + + d = dict(src_dict) + cached_builds = cast(list[str], d.pop("cachedBuilds")) + + cluster_id = d.pop("clusterID") + + commit = d.pop("commit") + + create_fails = d.pop("createFails") + + create_successes = d.pop("createSuccesses") + + id = d.pop("id") + + metrics = NodeMetrics.from_dict(d.pop("metrics")) + + node_id = d.pop("nodeID") + + sandboxes = [] + _sandboxes = d.pop("sandboxes") + for sandboxes_item_data in _sandboxes: + sandboxes_item = ListedSandbox.from_dict(sandboxes_item_data) + + sandboxes.append(sandboxes_item) + + service_instance_id = d.pop("serviceInstanceID") + + status = NodeStatus(d.pop("status")) + + version = d.pop("version") + + node_detail = cls( + cached_builds=cached_builds, + cluster_id=cluster_id, + commit=commit, + create_fails=create_fails, + create_successes=create_successes, + id=id, + metrics=metrics, + node_id=node_id, + sandboxes=sandboxes, + service_instance_id=service_instance_id, + status=status, + version=version, + ) + + node_detail.additional_properties = d + return node_detail + + @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/ucloud_sandbox/api/client/models/node_metrics.py b/ucloud_sandbox/api/client/models/node_metrics.py new file mode 100644 index 0000000..129d74f --- /dev/null +++ b/ucloud_sandbox/api/client/models/node_metrics.py @@ -0,0 +1,122 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.disk_metrics import DiskMetrics + + +T = TypeVar("T", bound="NodeMetrics") + + +@_attrs_define +class NodeMetrics: + """Node metrics + + Attributes: + allocated_cpu (int): Number of allocated CPU cores + allocated_memory_bytes (int): Amount of allocated memory in bytes + cpu_count (int): Total number of CPU cores on the node + cpu_percent (int): Node CPU usage percentage + disks (list['DiskMetrics']): Detailed metrics for each disk/mount point + memory_total_bytes (int): Total node memory in bytes + memory_used_bytes (int): Node memory used in bytes + """ + + allocated_cpu: int + allocated_memory_bytes: int + cpu_count: int + cpu_percent: int + disks: list["DiskMetrics"] + memory_total_bytes: int + memory_used_bytes: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + allocated_cpu = self.allocated_cpu + + allocated_memory_bytes = self.allocated_memory_bytes + + cpu_count = self.cpu_count + + cpu_percent = self.cpu_percent + + disks = [] + for disks_item_data in self.disks: + disks_item = disks_item_data.to_dict() + disks.append(disks_item) + + memory_total_bytes = self.memory_total_bytes + + memory_used_bytes = self.memory_used_bytes + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "allocatedCPU": allocated_cpu, + "allocatedMemoryBytes": allocated_memory_bytes, + "cpuCount": cpu_count, + "cpuPercent": cpu_percent, + "disks": disks, + "memoryTotalBytes": memory_total_bytes, + "memoryUsedBytes": memory_used_bytes, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.disk_metrics import DiskMetrics + + d = dict(src_dict) + allocated_cpu = d.pop("allocatedCPU") + + allocated_memory_bytes = d.pop("allocatedMemoryBytes") + + cpu_count = d.pop("cpuCount") + + cpu_percent = d.pop("cpuPercent") + + disks = [] + _disks = d.pop("disks") + for disks_item_data in _disks: + disks_item = DiskMetrics.from_dict(disks_item_data) + + disks.append(disks_item) + + memory_total_bytes = d.pop("memoryTotalBytes") + + memory_used_bytes = d.pop("memoryUsedBytes") + + node_metrics = cls( + allocated_cpu=allocated_cpu, + allocated_memory_bytes=allocated_memory_bytes, + cpu_count=cpu_count, + cpu_percent=cpu_percent, + disks=disks, + memory_total_bytes=memory_total_bytes, + memory_used_bytes=memory_used_bytes, + ) + + node_metrics.additional_properties = d + return node_metrics + + @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/ucloud_sandbox/api/client/models/node_status.py b/ucloud_sandbox/api/client/models/node_status.py new file mode 100644 index 0000000..4529e3b --- /dev/null +++ b/ucloud_sandbox/api/client/models/node_status.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class NodeStatus(str, Enum): + CONNECTING = "connecting" + DRAINING = "draining" + READY = "ready" + UNHEALTHY = "unhealthy" + + def __str__(self) -> str: + return str(self.value) diff --git a/ucloud_sandbox/api/client/models/node_status_change.py b/ucloud_sandbox/api/client/models/node_status_change.py new file mode 100644 index 0000000..b628e42 --- /dev/null +++ b/ucloud_sandbox/api/client/models/node_status_change.py @@ -0,0 +1,79 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.node_status import NodeStatus +from ..types import UNSET, Unset + +T = TypeVar("T", bound="NodeStatusChange") + + +@_attrs_define +class NodeStatusChange: + """ + Attributes: + status (NodeStatus): Status of the node + cluster_id (Union[Unset, UUID]): Identifier of the cluster + """ + + status: NodeStatus + cluster_id: Union[Unset, UUID] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + status = self.status.value + + cluster_id: Union[Unset, str] = UNSET + if not isinstance(self.cluster_id, Unset): + cluster_id = str(self.cluster_id) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "status": status, + } + ) + if cluster_id is not UNSET: + field_dict["clusterID"] = cluster_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + status = NodeStatus(d.pop("status")) + + _cluster_id = d.pop("clusterID", UNSET) + cluster_id: Union[Unset, UUID] + if isinstance(_cluster_id, Unset): + cluster_id = UNSET + else: + cluster_id = UUID(_cluster_id) + + node_status_change = cls( + status=status, + cluster_id=cluster_id, + ) + + node_status_change.additional_properties = d + return node_status_change + + @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/ucloud_sandbox/api/client/models/post_sandboxes_sandbox_id_refreshes_body.py b/ucloud_sandbox/api/client/models/post_sandboxes_sandbox_id_refreshes_body.py new file mode 100644 index 0000000..5a35d26 --- /dev/null +++ b/ucloud_sandbox/api/client/models/post_sandboxes_sandbox_id_refreshes_body.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PostSandboxesSandboxIDRefreshesBody") + + +@_attrs_define +class PostSandboxesSandboxIDRefreshesBody: + """ + Attributes: + duration (Union[Unset, int]): Duration for which the sandbox should be kept alive in seconds + """ + + duration: Union[Unset, int] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + duration = self.duration + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if duration is not UNSET: + field_dict["duration"] = duration + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + duration = d.pop("duration", UNSET) + + post_sandboxes_sandbox_id_refreshes_body = cls( + duration=duration, + ) + + post_sandboxes_sandbox_id_refreshes_body.additional_properties = d + return post_sandboxes_sandbox_id_refreshes_body + + @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/ucloud_sandbox/api/client/models/post_sandboxes_sandbox_id_timeout_body.py b/ucloud_sandbox/api/client/models/post_sandboxes_sandbox_id_timeout_body.py new file mode 100644 index 0000000..4d06c65 --- /dev/null +++ b/ucloud_sandbox/api/client/models/post_sandboxes_sandbox_id_timeout_body.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="PostSandboxesSandboxIDTimeoutBody") + + +@_attrs_define +class PostSandboxesSandboxIDTimeoutBody: + """ + Attributes: + timeout (int): Timeout in seconds from the current time after which the sandbox should expire + """ + + timeout: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + timeout = self.timeout + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "timeout": timeout, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + timeout = d.pop("timeout") + + post_sandboxes_sandbox_id_timeout_body = cls( + timeout=timeout, + ) + + post_sandboxes_sandbox_id_timeout_body.additional_properties = d + return post_sandboxes_sandbox_id_timeout_body + + @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/ucloud_sandbox/api/client/models/resumed_sandbox.py b/ucloud_sandbox/api/client/models/resumed_sandbox.py new file mode 100644 index 0000000..d990dc8 --- /dev/null +++ b/ucloud_sandbox/api/client/models/resumed_sandbox.py @@ -0,0 +1,68 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ResumedSandbox") + + +@_attrs_define +class ResumedSandbox: + """ + Attributes: + auto_pause (Union[Unset, bool]): Automatically pauses the sandbox after the timeout + timeout (Union[Unset, int]): Time to live for the sandbox in seconds. Default: 15. + """ + + auto_pause: Union[Unset, bool] = UNSET + timeout: Union[Unset, int] = 15 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + auto_pause = self.auto_pause + + timeout = self.timeout + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if auto_pause is not UNSET: + field_dict["autoPause"] = auto_pause + if timeout is not UNSET: + field_dict["timeout"] = timeout + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + auto_pause = d.pop("autoPause", UNSET) + + timeout = d.pop("timeout", UNSET) + + resumed_sandbox = cls( + auto_pause=auto_pause, + timeout=timeout, + ) + + resumed_sandbox.additional_properties = d + return resumed_sandbox + + @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/ucloud_sandbox/api/client/models/sandbox.py b/ucloud_sandbox/api/client/models/sandbox.py new file mode 100644 index 0000000..2eddc5f --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandbox.py @@ -0,0 +1,145 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Sandbox") + + +@_attrs_define +class Sandbox: + """ + Attributes: + client_id (str): Identifier of the client + envd_version (str): Version of the envd running in the sandbox + sandbox_id (str): Identifier of the sandbox + template_id (str): Identifier of the template from which is the sandbox created + alias (Union[Unset, str]): Alias of the template + domain (Union[None, Unset, str]): Base domain where the sandbox traffic is accessible + envd_access_token (Union[Unset, str]): Access token used for envd communication + traffic_access_token (Union[None, Unset, str]): Token required for accessing sandbox via proxy. + """ + + client_id: str + envd_version: str + sandbox_id: str + template_id: str + alias: Union[Unset, str] = UNSET + domain: Union[None, Unset, str] = UNSET + envd_access_token: Union[Unset, str] = UNSET + traffic_access_token: Union[None, Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + client_id = self.client_id + + envd_version = self.envd_version + + sandbox_id = self.sandbox_id + + template_id = self.template_id + + alias = self.alias + + domain: Union[None, Unset, str] + if isinstance(self.domain, Unset): + domain = UNSET + else: + domain = self.domain + + envd_access_token = self.envd_access_token + + traffic_access_token: Union[None, Unset, str] + if isinstance(self.traffic_access_token, Unset): + traffic_access_token = UNSET + else: + traffic_access_token = self.traffic_access_token + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "clientID": client_id, + "envdVersion": envd_version, + "sandboxID": sandbox_id, + "templateID": template_id, + } + ) + if alias is not UNSET: + field_dict["alias"] = alias + if domain is not UNSET: + field_dict["domain"] = domain + if envd_access_token is not UNSET: + field_dict["envdAccessToken"] = envd_access_token + if traffic_access_token is not UNSET: + field_dict["trafficAccessToken"] = traffic_access_token + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + client_id = d.pop("clientID") + + envd_version = d.pop("envdVersion") + + sandbox_id = d.pop("sandboxID") + + template_id = d.pop("templateID") + + alias = d.pop("alias", UNSET) + + def _parse_domain(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + domain = _parse_domain(d.pop("domain", UNSET)) + + envd_access_token = d.pop("envdAccessToken", UNSET) + + def _parse_traffic_access_token(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + traffic_access_token = _parse_traffic_access_token( + d.pop("trafficAccessToken", UNSET) + ) + + sandbox = cls( + client_id=client_id, + envd_version=envd_version, + sandbox_id=sandbox_id, + template_id=template_id, + alias=alias, + domain=domain, + envd_access_token=envd_access_token, + traffic_access_token=traffic_access_token, + ) + + sandbox.additional_properties = d + return sandbox + + @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/ucloud_sandbox/api/client/models/sandbox_detail.py b/ucloud_sandbox/api/client/models/sandbox_detail.py new file mode 100644 index 0000000..da864cc --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandbox_detail.py @@ -0,0 +1,183 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.sandbox_state import SandboxState +from ..types import UNSET, Unset + +T = TypeVar("T", bound="SandboxDetail") + + +@_attrs_define +class SandboxDetail: + """ + Attributes: + client_id (str): Identifier of the client + cpu_count (int): CPU cores for the sandbox + disk_size_mb (int): Disk size for the sandbox in MiB + end_at (datetime.datetime): Time when the sandbox will expire + envd_version (str): Version of the envd running in the sandbox + memory_mb (int): Memory for the sandbox in MiB + sandbox_id (str): Identifier of the sandbox + started_at (datetime.datetime): Time when the sandbox was started + state (SandboxState): State of the sandbox + template_id (str): Identifier of the template from which is the sandbox created + alias (Union[Unset, str]): Alias of the template + domain (Union[None, Unset, str]): Base domain where the sandbox traffic is accessible + envd_access_token (Union[Unset, str]): Access token used for envd communication + metadata (Union[Unset, Any]): + """ + + client_id: str + cpu_count: int + disk_size_mb: int + end_at: datetime.datetime + envd_version: str + memory_mb: int + sandbox_id: str + started_at: datetime.datetime + state: SandboxState + template_id: str + alias: Union[Unset, str] = UNSET + domain: Union[None, Unset, str] = UNSET + envd_access_token: Union[Unset, str] = UNSET + metadata: Union[Unset, Any] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + client_id = self.client_id + + cpu_count = self.cpu_count + + disk_size_mb = self.disk_size_mb + + end_at = self.end_at.isoformat() + + envd_version = self.envd_version + + memory_mb = self.memory_mb + + sandbox_id = self.sandbox_id + + started_at = self.started_at.isoformat() + + state = self.state.value + + template_id = self.template_id + + alias = self.alias + + domain: Union[None, Unset, str] + if isinstance(self.domain, Unset): + domain = UNSET + else: + domain = self.domain + + envd_access_token = self.envd_access_token + + metadata = self.metadata + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "clientID": client_id, + "cpuCount": cpu_count, + "diskSizeMB": disk_size_mb, + "endAt": end_at, + "envdVersion": envd_version, + "memoryMB": memory_mb, + "sandboxID": sandbox_id, + "startedAt": started_at, + "state": state, + "templateID": template_id, + } + ) + if alias is not UNSET: + field_dict["alias"] = alias + if domain is not UNSET: + field_dict["domain"] = domain + if envd_access_token is not UNSET: + field_dict["envdAccessToken"] = envd_access_token + if metadata is not UNSET: + field_dict["metadata"] = metadata + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + client_id = d.pop("clientID") + + cpu_count = d.pop("cpuCount") + + disk_size_mb = d.pop("diskSizeMB") + + end_at = isoparse(d.pop("endAt")) + + envd_version = d.pop("envdVersion") + + memory_mb = d.pop("memoryMB") + + sandbox_id = d.pop("sandboxID") + + started_at = isoparse(d.pop("startedAt")) + + state = SandboxState(d.pop("state")) + + template_id = d.pop("templateID") + + alias = d.pop("alias", UNSET) + + def _parse_domain(data: object) -> Union[None, Unset, str]: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(Union[None, Unset, str], data) + + domain = _parse_domain(d.pop("domain", UNSET)) + + envd_access_token = d.pop("envdAccessToken", UNSET) + + metadata = d.pop("metadata", UNSET) + + sandbox_detail = cls( + client_id=client_id, + cpu_count=cpu_count, + disk_size_mb=disk_size_mb, + end_at=end_at, + envd_version=envd_version, + memory_mb=memory_mb, + sandbox_id=sandbox_id, + started_at=started_at, + state=state, + template_id=template_id, + alias=alias, + domain=domain, + envd_access_token=envd_access_token, + metadata=metadata, + ) + + sandbox_detail.additional_properties = d + return sandbox_detail + + @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/ucloud_sandbox/api/client/models/sandbox_log.py b/ucloud_sandbox/api/client/models/sandbox_log.py new file mode 100644 index 0000000..65b9144 --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandbox_log.py @@ -0,0 +1,70 @@ +import datetime +from collections.abc import Mapping +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="SandboxLog") + + +@_attrs_define +class SandboxLog: + """Log entry with timestamp and line + + Attributes: + line (str): Log line content + timestamp (datetime.datetime): Timestamp of the log entry + """ + + line: str + timestamp: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + line = self.line + + timestamp = self.timestamp.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "line": line, + "timestamp": timestamp, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + line = d.pop("line") + + timestamp = isoparse(d.pop("timestamp")) + + sandbox_log = cls( + line=line, + timestamp=timestamp, + ) + + sandbox_log.additional_properties = d + return sandbox_log + + @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/ucloud_sandbox/api/client/models/sandbox_log_entry.py b/ucloud_sandbox/api/client/models/sandbox_log_entry.py new file mode 100644 index 0000000..bcbbe7d --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandbox_log_entry.py @@ -0,0 +1,93 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.log_level import LogLevel + +if TYPE_CHECKING: + from ..models.sandbox_log_entry_fields import SandboxLogEntryFields + + +T = TypeVar("T", bound="SandboxLogEntry") + + +@_attrs_define +class SandboxLogEntry: + """ + Attributes: + fields (SandboxLogEntryFields): + level (LogLevel): State of the sandbox + message (str): Log message content + timestamp (datetime.datetime): Timestamp of the log entry + """ + + fields: "SandboxLogEntryFields" + level: LogLevel + message: str + timestamp: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + fields = self.fields.to_dict() + + level = self.level.value + + message = self.message + + timestamp = self.timestamp.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "fields": fields, + "level": level, + "message": message, + "timestamp": timestamp, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_log_entry_fields import SandboxLogEntryFields + + d = dict(src_dict) + fields = SandboxLogEntryFields.from_dict(d.pop("fields")) + + level = LogLevel(d.pop("level")) + + message = d.pop("message") + + timestamp = isoparse(d.pop("timestamp")) + + sandbox_log_entry = cls( + fields=fields, + level=level, + message=message, + timestamp=timestamp, + ) + + sandbox_log_entry.additional_properties = d + return sandbox_log_entry + + @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/ucloud_sandbox/api/client/models/sandbox_log_entry_fields.py b/ucloud_sandbox/api/client/models/sandbox_log_entry_fields.py new file mode 100644 index 0000000..6ac463d --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandbox_log_entry_fields.py @@ -0,0 +1,44 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SandboxLogEntryFields") + + +@_attrs_define +class SandboxLogEntryFields: + """ """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sandbox_log_entry_fields = cls() + + sandbox_log_entry_fields.additional_properties = d + return sandbox_log_entry_fields + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> 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/ucloud_sandbox/api/client/models/sandbox_logs.py b/ucloud_sandbox/api/client/models/sandbox_logs.py new file mode 100644 index 0000000..39a1eb5 --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandbox_logs.py @@ -0,0 +1,91 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.sandbox_log import SandboxLog + from ..models.sandbox_log_entry import SandboxLogEntry + + +T = TypeVar("T", bound="SandboxLogs") + + +@_attrs_define +class SandboxLogs: + """ + Attributes: + log_entries (list['SandboxLogEntry']): Structured logs of the sandbox + logs (list['SandboxLog']): Logs of the sandbox + """ + + log_entries: list["SandboxLogEntry"] + logs: list["SandboxLog"] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + log_entries = [] + for log_entries_item_data in self.log_entries: + log_entries_item = log_entries_item_data.to_dict() + log_entries.append(log_entries_item) + + logs = [] + for logs_item_data in self.logs: + logs_item = logs_item_data.to_dict() + logs.append(logs_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "logEntries": log_entries, + "logs": logs, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_log import SandboxLog + from ..models.sandbox_log_entry import SandboxLogEntry + + d = dict(src_dict) + log_entries = [] + _log_entries = d.pop("logEntries") + for log_entries_item_data in _log_entries: + log_entries_item = SandboxLogEntry.from_dict(log_entries_item_data) + + log_entries.append(log_entries_item) + + logs = [] + _logs = d.pop("logs") + for logs_item_data in _logs: + logs_item = SandboxLog.from_dict(logs_item_data) + + logs.append(logs_item) + + sandbox_logs = cls( + log_entries=log_entries, + logs=logs, + ) + + sandbox_logs.additional_properties = d + return sandbox_logs + + @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/ucloud_sandbox/api/client/models/sandbox_metric.py b/ucloud_sandbox/api/client/models/sandbox_metric.py new file mode 100644 index 0000000..eb7442f --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandbox_metric.py @@ -0,0 +1,118 @@ +import datetime +from collections.abc import Mapping +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: + cpu_count (int): Number of CPU cores + cpu_used_pct (float): CPU usage percentage + disk_total (int): Total disk space in bytes + disk_used (int): Disk used in bytes + mem_total (int): Total memory in bytes + mem_used (int): Memory used in bytes + timestamp (datetime.datetime): Timestamp of the metric entry + timestamp_unix (int): Timestamp of the metric entry in Unix time (seconds since epoch) + """ + + cpu_count: int + cpu_used_pct: float + disk_total: int + disk_used: int + mem_total: int + mem_used: int + timestamp: datetime.datetime + timestamp_unix: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + cpu_count = self.cpu_count + + cpu_used_pct = self.cpu_used_pct + + disk_total = self.disk_total + + disk_used = self.disk_used + + mem_total = self.mem_total + + mem_used = self.mem_used + + timestamp = self.timestamp.isoformat() + + timestamp_unix = self.timestamp_unix + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "cpuCount": cpu_count, + "cpuUsedPct": cpu_used_pct, + "diskTotal": disk_total, + "diskUsed": disk_used, + "memTotal": mem_total, + "memUsed": mem_used, + "timestamp": timestamp, + "timestampUnix": timestamp_unix, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + cpu_count = d.pop("cpuCount") + + cpu_used_pct = d.pop("cpuUsedPct") + + disk_total = d.pop("diskTotal") + + disk_used = d.pop("diskUsed") + + mem_total = d.pop("memTotal") + + mem_used = d.pop("memUsed") + + timestamp = isoparse(d.pop("timestamp")) + + timestamp_unix = d.pop("timestampUnix") + + sandbox_metric = cls( + cpu_count=cpu_count, + cpu_used_pct=cpu_used_pct, + disk_total=disk_total, + disk_used=disk_used, + mem_total=mem_total, + mem_used=mem_used, + timestamp=timestamp, + timestamp_unix=timestamp_unix, + ) + + 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/ucloud_sandbox/api/client/models/sandbox_network_config.py b/ucloud_sandbox/api/client/models/sandbox_network_config.py new file mode 100644 index 0000000..08284c7 --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandbox_network_config.py @@ -0,0 +1,92 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="SandboxNetworkConfig") + + +@_attrs_define +class SandboxNetworkConfig: + """ + Attributes: + allow_out (Union[Unset, list[str]]): List of allowed CIDR blocks or IP addresses for egress traffic. Allowed + addresses always take precedence over blocked addresses. + allow_public_traffic (Union[Unset, bool]): Specify if the sandbox URLs should be accessible only with + authentication. Default: True. + deny_out (Union[Unset, list[str]]): List of denied CIDR blocks or IP addresses for egress traffic + mask_request_host (Union[Unset, str]): Specify host mask which will be used for all sandbox requests + """ + + allow_out: Union[Unset, list[str]] = UNSET + allow_public_traffic: Union[Unset, bool] = True + deny_out: Union[Unset, list[str]] = UNSET + mask_request_host: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + allow_out: Union[Unset, list[str]] = UNSET + if not isinstance(self.allow_out, Unset): + allow_out = self.allow_out + + allow_public_traffic = self.allow_public_traffic + + deny_out: Union[Unset, list[str]] = UNSET + if not isinstance(self.deny_out, Unset): + deny_out = self.deny_out + + mask_request_host = self.mask_request_host + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if allow_out is not UNSET: + field_dict["allowOut"] = allow_out + if allow_public_traffic is not UNSET: + field_dict["allowPublicTraffic"] = allow_public_traffic + if deny_out is not UNSET: + field_dict["denyOut"] = deny_out + if mask_request_host is not UNSET: + field_dict["maskRequestHost"] = mask_request_host + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + allow_out = cast(list[str], d.pop("allowOut", UNSET)) + + allow_public_traffic = d.pop("allowPublicTraffic", UNSET) + + deny_out = cast(list[str], d.pop("denyOut", UNSET)) + + mask_request_host = d.pop("maskRequestHost", UNSET) + + sandbox_network_config = cls( + allow_out=allow_out, + allow_public_traffic=allow_public_traffic, + deny_out=deny_out, + mask_request_host=mask_request_host, + ) + + sandbox_network_config.additional_properties = d + return sandbox_network_config + + @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/ucloud_sandbox/api/client/models/sandbox_state.py b/ucloud_sandbox/api/client/models/sandbox_state.py new file mode 100644 index 0000000..2ac2565 --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandbox_state.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SandboxState(str, Enum): + PAUSED = "paused" + RUNNING = "running" + + def __str__(self) -> str: + return str(self.value) diff --git a/ucloud_sandbox/api/client/models/sandboxes_with_metrics.py b/ucloud_sandbox/api/client/models/sandboxes_with_metrics.py new file mode 100644 index 0000000..64981d9 --- /dev/null +++ b/ucloud_sandbox/api/client/models/sandboxes_with_metrics.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SandboxesWithMetrics") + + +@_attrs_define +class SandboxesWithMetrics: + """ + Attributes: + sandboxes (Any): + """ + + sandboxes: Any + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + sandboxes = self.sandboxes + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "sandboxes": sandboxes, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + sandboxes = d.pop("sandboxes") + + sandboxes_with_metrics = cls( + sandboxes=sandboxes, + ) + + sandboxes_with_metrics.additional_properties = d + return sandboxes_with_metrics + + @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/ucloud_sandbox/api/client/models/team.py b/ucloud_sandbox/api/client/models/team.py new file mode 100644 index 0000000..81a434c --- /dev/null +++ b/ucloud_sandbox/api/client/models/team.py @@ -0,0 +1,83 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Team") + + +@_attrs_define +class Team: + """ + Attributes: + api_key (str): API key for the team + is_default (bool): Whether the team is the default team + name (str): Name of the team + team_id (str): Identifier of the team + """ + + api_key: str + is_default: bool + name: str + team_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + api_key = self.api_key + + is_default = self.is_default + + name = self.name + + team_id = self.team_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "apiKey": api_key, + "isDefault": is_default, + "name": name, + "teamID": team_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + api_key = d.pop("apiKey") + + is_default = d.pop("isDefault") + + name = d.pop("name") + + team_id = d.pop("teamID") + + team = cls( + api_key=api_key, + is_default=is_default, + name=name, + team_id=team_id, + ) + + team.additional_properties = d + return team + + @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/ucloud_sandbox/api/client/models/team_api_key.py b/ucloud_sandbox/api/client/models/team_api_key.py new file mode 100644 index 0000000..7edbbc1 --- /dev/null +++ b/ucloud_sandbox/api/client/models/team_api_key.py @@ -0,0 +1,158 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.identifier_masking_details import IdentifierMaskingDetails + from ..models.team_user import TeamUser + + +T = TypeVar("T", bound="TeamAPIKey") + + +@_attrs_define +class TeamAPIKey: + """ + Attributes: + created_at (datetime.datetime): Timestamp of API key creation + id (UUID): Identifier of the API key + mask (IdentifierMaskingDetails): + name (str): Name of the API key + created_by (Union['TeamUser', None, Unset]): + last_used (Union[None, Unset, datetime.datetime]): Last time this API key was used + """ + + created_at: datetime.datetime + id: UUID + mask: "IdentifierMaskingDetails" + name: str + created_by: Union["TeamUser", None, Unset] = UNSET + last_used: Union[None, Unset, datetime.datetime] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.team_user import TeamUser + + created_at = self.created_at.isoformat() + + id = str(self.id) + + mask = self.mask.to_dict() + + name = self.name + + created_by: Union[None, Unset, dict[str, Any]] + if isinstance(self.created_by, Unset): + created_by = UNSET + elif isinstance(self.created_by, TeamUser): + created_by = self.created_by.to_dict() + else: + created_by = self.created_by + + last_used: Union[None, Unset, str] + if isinstance(self.last_used, Unset): + last_used = UNSET + elif isinstance(self.last_used, datetime.datetime): + last_used = self.last_used.isoformat() + else: + last_used = self.last_used + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "createdAt": created_at, + "id": id, + "mask": mask, + "name": name, + } + ) + if created_by is not UNSET: + field_dict["createdBy"] = created_by + if last_used is not UNSET: + field_dict["lastUsed"] = last_used + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.identifier_masking_details import IdentifierMaskingDetails + from ..models.team_user import TeamUser + + d = dict(src_dict) + created_at = isoparse(d.pop("createdAt")) + + id = UUID(d.pop("id")) + + mask = IdentifierMaskingDetails.from_dict(d.pop("mask")) + + name = d.pop("name") + + def _parse_created_by(data: object) -> Union["TeamUser", None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + created_by_type_1 = TeamUser.from_dict(data) + + return created_by_type_1 + except: # noqa: E722 + pass + return cast(Union["TeamUser", None, Unset], data) + + created_by = _parse_created_by(d.pop("createdBy", UNSET)) + + def _parse_last_used(data: object) -> Union[None, Unset, datetime.datetime]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + last_used_type_0 = isoparse(data) + + return last_used_type_0 + except: # noqa: E722 + pass + return cast(Union[None, Unset, datetime.datetime], data) + + last_used = _parse_last_used(d.pop("lastUsed", UNSET)) + + team_api_key = cls( + created_at=created_at, + id=id, + mask=mask, + name=name, + created_by=created_by, + last_used=last_used, + ) + + team_api_key.additional_properties = d + return team_api_key + + @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/ucloud_sandbox/api/client/models/team_metric.py b/ucloud_sandbox/api/client/models/team_metric.py new file mode 100644 index 0000000..97d30d7 --- /dev/null +++ b/ucloud_sandbox/api/client/models/team_metric.py @@ -0,0 +1,86 @@ +import datetime +from collections.abc import Mapping +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="TeamMetric") + + +@_attrs_define +class TeamMetric: + """Team metric with timestamp + + Attributes: + concurrent_sandboxes (int): The number of concurrent sandboxes for the team + sandbox_start_rate (float): Number of sandboxes started per second + timestamp (datetime.datetime): Timestamp of the metric entry + timestamp_unix (int): Timestamp of the metric entry in Unix time (seconds since epoch) + """ + + concurrent_sandboxes: int + sandbox_start_rate: float + timestamp: datetime.datetime + timestamp_unix: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + concurrent_sandboxes = self.concurrent_sandboxes + + sandbox_start_rate = self.sandbox_start_rate + + timestamp = self.timestamp.isoformat() + + timestamp_unix = self.timestamp_unix + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "concurrentSandboxes": concurrent_sandboxes, + "sandboxStartRate": sandbox_start_rate, + "timestamp": timestamp, + "timestampUnix": timestamp_unix, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + concurrent_sandboxes = d.pop("concurrentSandboxes") + + sandbox_start_rate = d.pop("sandboxStartRate") + + timestamp = isoparse(d.pop("timestamp")) + + timestamp_unix = d.pop("timestampUnix") + + team_metric = cls( + concurrent_sandboxes=concurrent_sandboxes, + sandbox_start_rate=sandbox_start_rate, + timestamp=timestamp, + timestamp_unix=timestamp_unix, + ) + + team_metric.additional_properties = d + return team_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/ucloud_sandbox/api/client/models/team_user.py b/ucloud_sandbox/api/client/models/team_user.py new file mode 100644 index 0000000..cd42f7f --- /dev/null +++ b/ucloud_sandbox/api/client/models/team_user.py @@ -0,0 +1,68 @@ +from collections.abc import Mapping +from typing import Any, TypeVar +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="TeamUser") + + +@_attrs_define +class TeamUser: + """ + Attributes: + email (str): Email of the user + id (UUID): Identifier of the user + """ + + email: str + id: UUID + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + email = self.email + + id = str(self.id) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "email": email, + "id": id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + email = d.pop("email") + + id = UUID(d.pop("id")) + + team_user = cls( + email=email, + id=id, + ) + + team_user.additional_properties = d + return team_user + + @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/ucloud_sandbox/api/client/models/template.py b/ucloud_sandbox/api/client/models/template.py new file mode 100644 index 0000000..f29b16f --- /dev/null +++ b/ucloud_sandbox/api/client/models/template.py @@ -0,0 +1,217 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.template_build_status import TemplateBuildStatus + +if TYPE_CHECKING: + from ..models.team_user import TeamUser + + +T = TypeVar("T", bound="Template") + + +@_attrs_define +class Template: + """ + Attributes: + aliases (list[str]): Aliases of the template + build_count (int): Number of times the template was built + build_id (str): Identifier of the last successful build for given template + build_status (TemplateBuildStatus): Status of the template build + cpu_count (int): CPU cores for the sandbox + created_at (datetime.datetime): Time when the template was created + created_by (Union['TeamUser', None]): + disk_size_mb (int): Disk size for the sandbox in MiB + envd_version (str): Version of the envd running in the sandbox + last_spawned_at (Union[None, datetime.datetime]): Time when the template was last used + memory_mb (int): Memory for the sandbox in MiB + public (bool): Whether the template is public or only accessible by the team + spawn_count (int): Number of times the template was used + template_id (str): Identifier of the template + updated_at (datetime.datetime): Time when the template was last updated + """ + + aliases: list[str] + build_count: int + build_id: str + build_status: TemplateBuildStatus + cpu_count: int + created_at: datetime.datetime + created_by: Union["TeamUser", None] + disk_size_mb: int + envd_version: str + last_spawned_at: Union[None, datetime.datetime] + memory_mb: int + public: bool + spawn_count: int + template_id: str + updated_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.team_user import TeamUser + + aliases = self.aliases + + build_count = self.build_count + + build_id = self.build_id + + build_status = self.build_status.value + + cpu_count = self.cpu_count + + created_at = self.created_at.isoformat() + + created_by: Union[None, dict[str, Any]] + if isinstance(self.created_by, TeamUser): + created_by = self.created_by.to_dict() + else: + created_by = self.created_by + + disk_size_mb = self.disk_size_mb + + envd_version = self.envd_version + + last_spawned_at: Union[None, str] + if isinstance(self.last_spawned_at, datetime.datetime): + last_spawned_at = self.last_spawned_at.isoformat() + else: + last_spawned_at = self.last_spawned_at + + memory_mb = self.memory_mb + + public = self.public + + spawn_count = self.spawn_count + + template_id = self.template_id + + updated_at = self.updated_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "aliases": aliases, + "buildCount": build_count, + "buildID": build_id, + "buildStatus": build_status, + "cpuCount": cpu_count, + "createdAt": created_at, + "createdBy": created_by, + "diskSizeMB": disk_size_mb, + "envdVersion": envd_version, + "lastSpawnedAt": last_spawned_at, + "memoryMB": memory_mb, + "public": public, + "spawnCount": spawn_count, + "templateID": template_id, + "updatedAt": updated_at, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.team_user import TeamUser + + d = dict(src_dict) + aliases = cast(list[str], d.pop("aliases")) + + build_count = d.pop("buildCount") + + build_id = d.pop("buildID") + + build_status = TemplateBuildStatus(d.pop("buildStatus")) + + cpu_count = d.pop("cpuCount") + + created_at = isoparse(d.pop("createdAt")) + + def _parse_created_by(data: object) -> Union["TeamUser", None]: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + created_by_type_1 = TeamUser.from_dict(data) + + return created_by_type_1 + except: # noqa: E722 + pass + return cast(Union["TeamUser", None], data) + + created_by = _parse_created_by(d.pop("createdBy")) + + disk_size_mb = d.pop("diskSizeMB") + + envd_version = d.pop("envdVersion") + + def _parse_last_spawned_at(data: object) -> Union[None, datetime.datetime]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + last_spawned_at_type_0 = isoparse(data) + + return last_spawned_at_type_0 + except: # noqa: E722 + pass + return cast(Union[None, datetime.datetime], data) + + last_spawned_at = _parse_last_spawned_at(d.pop("lastSpawnedAt")) + + memory_mb = d.pop("memoryMB") + + public = d.pop("public") + + spawn_count = d.pop("spawnCount") + + template_id = d.pop("templateID") + + updated_at = isoparse(d.pop("updatedAt")) + + template = cls( + aliases=aliases, + build_count=build_count, + build_id=build_id, + build_status=build_status, + cpu_count=cpu_count, + created_at=created_at, + created_by=created_by, + disk_size_mb=disk_size_mb, + envd_version=envd_version, + last_spawned_at=last_spawned_at, + memory_mb=memory_mb, + public=public, + spawn_count=spawn_count, + template_id=template_id, + updated_at=updated_at, + ) + + template.additional_properties = d + return template + + @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/ucloud_sandbox/api/client/models/template_build.py b/ucloud_sandbox/api/client/models/template_build.py new file mode 100644 index 0000000..f42a3f6 --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_build.py @@ -0,0 +1,139 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, Union +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.template_build_status import TemplateBuildStatus +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuild") + + +@_attrs_define +class TemplateBuild: + """ + Attributes: + build_id (UUID): Identifier of the build + cpu_count (int): CPU cores for the sandbox + created_at (datetime.datetime): Time when the build was created + memory_mb (int): Memory for the sandbox in MiB + status (TemplateBuildStatus): Status of the template build + updated_at (datetime.datetime): Time when the build was last updated + disk_size_mb (Union[Unset, int]): Disk size for the sandbox in MiB + envd_version (Union[Unset, str]): Version of the envd running in the sandbox + finished_at (Union[Unset, datetime.datetime]): Time when the build was finished + """ + + build_id: UUID + cpu_count: int + created_at: datetime.datetime + memory_mb: int + status: TemplateBuildStatus + updated_at: datetime.datetime + disk_size_mb: Union[Unset, int] = UNSET + envd_version: Union[Unset, str] = UNSET + finished_at: Union[Unset, datetime.datetime] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + build_id = str(self.build_id) + + cpu_count = self.cpu_count + + created_at = self.created_at.isoformat() + + memory_mb = self.memory_mb + + status = self.status.value + + updated_at = self.updated_at.isoformat() + + disk_size_mb = self.disk_size_mb + + envd_version = self.envd_version + + finished_at: Union[Unset, str] = UNSET + if not isinstance(self.finished_at, Unset): + finished_at = self.finished_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "buildID": build_id, + "cpuCount": cpu_count, + "createdAt": created_at, + "memoryMB": memory_mb, + "status": status, + "updatedAt": updated_at, + } + ) + if disk_size_mb is not UNSET: + field_dict["diskSizeMB"] = disk_size_mb + if envd_version is not UNSET: + field_dict["envdVersion"] = envd_version + if finished_at is not UNSET: + field_dict["finishedAt"] = finished_at + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + build_id = UUID(d.pop("buildID")) + + cpu_count = d.pop("cpuCount") + + created_at = isoparse(d.pop("createdAt")) + + memory_mb = d.pop("memoryMB") + + status = TemplateBuildStatus(d.pop("status")) + + updated_at = isoparse(d.pop("updatedAt")) + + disk_size_mb = d.pop("diskSizeMB", UNSET) + + envd_version = d.pop("envdVersion", UNSET) + + _finished_at = d.pop("finishedAt", UNSET) + finished_at: Union[Unset, datetime.datetime] + if isinstance(_finished_at, Unset): + finished_at = UNSET + else: + finished_at = isoparse(_finished_at) + + template_build = cls( + build_id=build_id, + cpu_count=cpu_count, + created_at=created_at, + memory_mb=memory_mb, + status=status, + updated_at=updated_at, + disk_size_mb=disk_size_mb, + envd_version=envd_version, + finished_at=finished_at, + ) + + template_build.additional_properties = d + return template_build + + @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/ucloud_sandbox/api/client/models/template_build_file_upload.py b/ucloud_sandbox/api/client/models/template_build_file_upload.py new file mode 100644 index 0000000..a7d4e44 --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_build_file_upload.py @@ -0,0 +1,70 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildFileUpload") + + +@_attrs_define +class TemplateBuildFileUpload: + """ + Attributes: + present (bool): Whether the file is already present in the cache + url (Union[Unset, str]): Url where the file should be uploaded to + """ + + present: bool + url: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + present = self.present + + url = self.url + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "present": present, + } + ) + if url is not UNSET: + field_dict["url"] = url + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + present = d.pop("present") + + url = d.pop("url", UNSET) + + template_build_file_upload = cls( + present=present, + url=url, + ) + + template_build_file_upload.additional_properties = d + return template_build_file_upload + + @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/ucloud_sandbox/api/client/models/template_build_info.py b/ucloud_sandbox/api/client/models/template_build_info.py new file mode 100644 index 0000000..90b670c --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_build_info.py @@ -0,0 +1,126 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.template_build_status import TemplateBuildStatus +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.build_log_entry import BuildLogEntry + from ..models.build_status_reason import BuildStatusReason + + +T = TypeVar("T", bound="TemplateBuildInfo") + + +@_attrs_define +class TemplateBuildInfo: + """ + Attributes: + build_id (str): Identifier of the build + log_entries (list['BuildLogEntry']): Build logs structured + logs (list[str]): Build logs + status (TemplateBuildStatus): Status of the template build + template_id (str): Identifier of the template + reason (Union[Unset, BuildStatusReason]): + """ + + build_id: str + log_entries: list["BuildLogEntry"] + logs: list[str] + status: TemplateBuildStatus + template_id: str + reason: Union[Unset, "BuildStatusReason"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + build_id = self.build_id + + log_entries = [] + for log_entries_item_data in self.log_entries: + log_entries_item = log_entries_item_data.to_dict() + log_entries.append(log_entries_item) + + logs = self.logs + + status = self.status.value + + template_id = self.template_id + + reason: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.reason, Unset): + reason = self.reason.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "buildID": build_id, + "logEntries": log_entries, + "logs": logs, + "status": status, + "templateID": template_id, + } + ) + if reason is not UNSET: + field_dict["reason"] = reason + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.build_log_entry import BuildLogEntry + from ..models.build_status_reason import BuildStatusReason + + d = dict(src_dict) + build_id = d.pop("buildID") + + log_entries = [] + _log_entries = d.pop("logEntries") + for log_entries_item_data in _log_entries: + log_entries_item = BuildLogEntry.from_dict(log_entries_item_data) + + log_entries.append(log_entries_item) + + logs = cast(list[str], d.pop("logs")) + + status = TemplateBuildStatus(d.pop("status")) + + template_id = d.pop("templateID") + + _reason = d.pop("reason", UNSET) + reason: Union[Unset, BuildStatusReason] + if isinstance(_reason, Unset): + reason = UNSET + else: + reason = BuildStatusReason.from_dict(_reason) + + template_build_info = cls( + build_id=build_id, + log_entries=log_entries, + logs=logs, + status=status, + template_id=template_id, + reason=reason, + ) + + template_build_info.additional_properties = d + return template_build_info + + @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/ucloud_sandbox/api/client/models/template_build_request.py b/ucloud_sandbox/api/client/models/template_build_request.py new file mode 100644 index 0000000..b41e1d0 --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_build_request.py @@ -0,0 +1,115 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildRequest") + + +@_attrs_define +class TemplateBuildRequest: + """ + Attributes: + dockerfile (str): Dockerfile for the template + alias (Union[Unset, str]): Alias of the template + cpu_count (Union[Unset, int]): CPU cores for the sandbox + memory_mb (Union[Unset, int]): Memory for the sandbox in MiB + ready_cmd (Union[Unset, str]): Ready check command to execute in the template after the build + start_cmd (Union[Unset, str]): Start command to execute in the template after the build + team_id (Union[Unset, str]): Identifier of the team + """ + + dockerfile: str + alias: Union[Unset, str] = UNSET + cpu_count: Union[Unset, int] = UNSET + memory_mb: Union[Unset, int] = UNSET + ready_cmd: Union[Unset, str] = UNSET + start_cmd: Union[Unset, str] = UNSET + team_id: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + dockerfile = self.dockerfile + + alias = self.alias + + cpu_count = self.cpu_count + + memory_mb = self.memory_mb + + ready_cmd = self.ready_cmd + + start_cmd = self.start_cmd + + team_id = self.team_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "dockerfile": dockerfile, + } + ) + if alias is not UNSET: + field_dict["alias"] = alias + if cpu_count is not UNSET: + field_dict["cpuCount"] = cpu_count + if memory_mb is not UNSET: + field_dict["memoryMB"] = memory_mb + if ready_cmd is not UNSET: + field_dict["readyCmd"] = ready_cmd + if start_cmd is not UNSET: + field_dict["startCmd"] = start_cmd + if team_id is not UNSET: + field_dict["teamID"] = team_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + dockerfile = d.pop("dockerfile") + + alias = d.pop("alias", UNSET) + + cpu_count = d.pop("cpuCount", UNSET) + + memory_mb = d.pop("memoryMB", UNSET) + + ready_cmd = d.pop("readyCmd", UNSET) + + start_cmd = d.pop("startCmd", UNSET) + + team_id = d.pop("teamID", UNSET) + + template_build_request = cls( + dockerfile=dockerfile, + alias=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + ready_cmd=ready_cmd, + start_cmd=start_cmd, + team_id=team_id, + ) + + template_build_request.additional_properties = d + return template_build_request + + @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/ucloud_sandbox/api/client/models/template_build_request_v2.py b/ucloud_sandbox/api/client/models/template_build_request_v2.py new file mode 100644 index 0000000..1473424 --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_build_request_v2.py @@ -0,0 +1,88 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildRequestV2") + + +@_attrs_define +class TemplateBuildRequestV2: + """ + Attributes: + alias (str): Alias of the template + cpu_count (Union[Unset, int]): CPU cores for the sandbox + memory_mb (Union[Unset, int]): Memory for the sandbox in MiB + team_id (Union[Unset, str]): Identifier of the team + """ + + alias: str + cpu_count: Union[Unset, int] = UNSET + memory_mb: Union[Unset, int] = UNSET + team_id: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + alias = self.alias + + cpu_count = self.cpu_count + + memory_mb = self.memory_mb + + team_id = self.team_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "alias": alias, + } + ) + if cpu_count is not UNSET: + field_dict["cpuCount"] = cpu_count + if memory_mb is not UNSET: + field_dict["memoryMB"] = memory_mb + if team_id is not UNSET: + field_dict["teamID"] = team_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + alias = d.pop("alias") + + cpu_count = d.pop("cpuCount", UNSET) + + memory_mb = d.pop("memoryMB", UNSET) + + team_id = d.pop("teamID", UNSET) + + template_build_request_v2 = cls( + alias=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + team_id=team_id, + ) + + template_build_request_v2.additional_properties = d + return template_build_request_v2 + + @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/ucloud_sandbox/api/client/models/template_build_request_v3.py b/ucloud_sandbox/api/client/models/template_build_request_v3.py new file mode 100644 index 0000000..0c14ad5 --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_build_request_v3.py @@ -0,0 +1,88 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildRequestV3") + + +@_attrs_define +class TemplateBuildRequestV3: + """ + Attributes: + alias (str): Alias of the template + cpu_count (Union[Unset, int]): CPU cores for the sandbox + memory_mb (Union[Unset, int]): Memory for the sandbox in MiB + team_id (Union[Unset, str]): Identifier of the team + """ + + alias: str + cpu_count: Union[Unset, int] = UNSET + memory_mb: Union[Unset, int] = UNSET + team_id: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + alias = self.alias + + cpu_count = self.cpu_count + + memory_mb = self.memory_mb + + team_id = self.team_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "alias": alias, + } + ) + if cpu_count is not UNSET: + field_dict["cpuCount"] = cpu_count + if memory_mb is not UNSET: + field_dict["memoryMB"] = memory_mb + if team_id is not UNSET: + field_dict["teamID"] = team_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + alias = d.pop("alias") + + cpu_count = d.pop("cpuCount", UNSET) + + memory_mb = d.pop("memoryMB", UNSET) + + team_id = d.pop("teamID", UNSET) + + template_build_request_v3 = cls( + alias=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + team_id=team_id, + ) + + template_build_request_v3.additional_properties = d + return template_build_request_v3 + + @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/ucloud_sandbox/api/client/models/template_build_start_v2.py b/ucloud_sandbox/api/client/models/template_build_start_v2.py new file mode 100644 index 0000000..32adf84 --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_build_start_v2.py @@ -0,0 +1,184 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.aws_registry import AWSRegistry + from ..models.gcp_registry import GCPRegistry + from ..models.general_registry import GeneralRegistry + from ..models.template_step import TemplateStep + + +T = TypeVar("T", bound="TemplateBuildStartV2") + + +@_attrs_define +class TemplateBuildStartV2: + """ + Attributes: + force (Union[Unset, bool]): Whether the whole build should be forced to run regardless of the cache Default: + False. + from_image (Union[Unset, str]): Image to use as a base for the template build + from_image_registry (Union['AWSRegistry', 'GCPRegistry', 'GeneralRegistry', Unset]): + from_template (Union[Unset, str]): Template to use as a base for the template build + ready_cmd (Union[Unset, str]): Ready check command to execute in the template after the build + start_cmd (Union[Unset, str]): Start command to execute in the template after the build + steps (Union[Unset, list['TemplateStep']]): List of steps to execute in the template build + """ + + force: Union[Unset, bool] = False + from_image: Union[Unset, str] = UNSET + from_image_registry: Union[ + "AWSRegistry", "GCPRegistry", "GeneralRegistry", Unset + ] = UNSET + from_template: Union[Unset, str] = UNSET + ready_cmd: Union[Unset, str] = UNSET + start_cmd: Union[Unset, str] = UNSET + steps: Union[Unset, list["TemplateStep"]] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.aws_registry import AWSRegistry + from ..models.gcp_registry import GCPRegistry + + force = self.force + + from_image = self.from_image + + from_image_registry: Union[Unset, dict[str, Any]] + if isinstance(self.from_image_registry, Unset): + from_image_registry = UNSET + elif isinstance(self.from_image_registry, AWSRegistry): + from_image_registry = self.from_image_registry.to_dict() + elif isinstance(self.from_image_registry, GCPRegistry): + from_image_registry = self.from_image_registry.to_dict() + else: + from_image_registry = self.from_image_registry.to_dict() + + from_template = self.from_template + + ready_cmd = self.ready_cmd + + start_cmd = self.start_cmd + + steps: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.steps, Unset): + steps = [] + for steps_item_data in self.steps: + steps_item = steps_item_data.to_dict() + steps.append(steps_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if force is not UNSET: + field_dict["force"] = force + if from_image is not UNSET: + field_dict["fromImage"] = from_image + if from_image_registry is not UNSET: + field_dict["fromImageRegistry"] = from_image_registry + if from_template is not UNSET: + field_dict["fromTemplate"] = from_template + if ready_cmd is not UNSET: + field_dict["readyCmd"] = ready_cmd + if start_cmd is not UNSET: + field_dict["startCmd"] = start_cmd + if steps is not UNSET: + field_dict["steps"] = steps + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.aws_registry import AWSRegistry + from ..models.gcp_registry import GCPRegistry + from ..models.general_registry import GeneralRegistry + from ..models.template_step import TemplateStep + + d = dict(src_dict) + force = d.pop("force", UNSET) + + from_image = d.pop("fromImage", UNSET) + + def _parse_from_image_registry( + data: object, + ) -> Union["AWSRegistry", "GCPRegistry", "GeneralRegistry", Unset]: + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_from_image_registry_type_0 = AWSRegistry.from_dict( + data + ) + + return componentsschemas_from_image_registry_type_0 + except: # noqa: E722 + pass + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_from_image_registry_type_1 = GCPRegistry.from_dict( + data + ) + + return componentsschemas_from_image_registry_type_1 + except: # noqa: E722 + pass + if not isinstance(data, dict): + raise TypeError() + componentsschemas_from_image_registry_type_2 = GeneralRegistry.from_dict( + data + ) + + return componentsschemas_from_image_registry_type_2 + + from_image_registry = _parse_from_image_registry( + d.pop("fromImageRegistry", UNSET) + ) + + from_template = d.pop("fromTemplate", UNSET) + + ready_cmd = d.pop("readyCmd", UNSET) + + start_cmd = d.pop("startCmd", UNSET) + + steps = [] + _steps = d.pop("steps", UNSET) + for steps_item_data in _steps or []: + steps_item = TemplateStep.from_dict(steps_item_data) + + steps.append(steps_item) + + template_build_start_v2 = cls( + force=force, + from_image=from_image, + from_image_registry=from_image_registry, + from_template=from_template, + ready_cmd=ready_cmd, + start_cmd=start_cmd, + steps=steps, + ) + + template_build_start_v2.additional_properties = d + return template_build_start_v2 + + @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/ucloud_sandbox/api/client/models/template_build_status.py b/ucloud_sandbox/api/client/models/template_build_status.py new file mode 100644 index 0000000..6cae835 --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_build_status.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class TemplateBuildStatus(str, Enum): + BUILDING = "building" + ERROR = "error" + READY = "ready" + WAITING = "waiting" + + def __str__(self) -> str: + return str(self.value) diff --git a/ucloud_sandbox/api/client/models/template_legacy.py b/ucloud_sandbox/api/client/models/template_legacy.py new file mode 100644 index 0000000..69501e5 --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_legacy.py @@ -0,0 +1,207 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +if TYPE_CHECKING: + from ..models.team_user import TeamUser + + +T = TypeVar("T", bound="TemplateLegacy") + + +@_attrs_define +class TemplateLegacy: + """ + Attributes: + aliases (list[str]): Aliases of the template + build_count (int): Number of times the template was built + build_id (str): Identifier of the last successful build for given template + cpu_count (int): CPU cores for the sandbox + created_at (datetime.datetime): Time when the template was created + created_by (Union['TeamUser', None]): + disk_size_mb (int): Disk size for the sandbox in MiB + envd_version (str): Version of the envd running in the sandbox + last_spawned_at (Union[None, datetime.datetime]): Time when the template was last used + memory_mb (int): Memory for the sandbox in MiB + public (bool): Whether the template is public or only accessible by the team + spawn_count (int): Number of times the template was used + template_id (str): Identifier of the template + updated_at (datetime.datetime): Time when the template was last updated + """ + + aliases: list[str] + build_count: int + build_id: str + cpu_count: int + created_at: datetime.datetime + created_by: Union["TeamUser", None] + disk_size_mb: int + envd_version: str + last_spawned_at: Union[None, datetime.datetime] + memory_mb: int + public: bool + spawn_count: int + template_id: str + updated_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.team_user import TeamUser + + aliases = self.aliases + + build_count = self.build_count + + build_id = self.build_id + + cpu_count = self.cpu_count + + created_at = self.created_at.isoformat() + + created_by: Union[None, dict[str, Any]] + if isinstance(self.created_by, TeamUser): + created_by = self.created_by.to_dict() + else: + created_by = self.created_by + + disk_size_mb = self.disk_size_mb + + envd_version = self.envd_version + + last_spawned_at: Union[None, str] + if isinstance(self.last_spawned_at, datetime.datetime): + last_spawned_at = self.last_spawned_at.isoformat() + else: + last_spawned_at = self.last_spawned_at + + memory_mb = self.memory_mb + + public = self.public + + spawn_count = self.spawn_count + + template_id = self.template_id + + updated_at = self.updated_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "aliases": aliases, + "buildCount": build_count, + "buildID": build_id, + "cpuCount": cpu_count, + "createdAt": created_at, + "createdBy": created_by, + "diskSizeMB": disk_size_mb, + "envdVersion": envd_version, + "lastSpawnedAt": last_spawned_at, + "memoryMB": memory_mb, + "public": public, + "spawnCount": spawn_count, + "templateID": template_id, + "updatedAt": updated_at, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.team_user import TeamUser + + d = dict(src_dict) + aliases = cast(list[str], d.pop("aliases")) + + build_count = d.pop("buildCount") + + build_id = d.pop("buildID") + + cpu_count = d.pop("cpuCount") + + created_at = isoparse(d.pop("createdAt")) + + def _parse_created_by(data: object) -> Union["TeamUser", None]: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + created_by_type_1 = TeamUser.from_dict(data) + + return created_by_type_1 + except: # noqa: E722 + pass + return cast(Union["TeamUser", None], data) + + created_by = _parse_created_by(d.pop("createdBy")) + + disk_size_mb = d.pop("diskSizeMB") + + envd_version = d.pop("envdVersion") + + def _parse_last_spawned_at(data: object) -> Union[None, datetime.datetime]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + last_spawned_at_type_0 = isoparse(data) + + return last_spawned_at_type_0 + except: # noqa: E722 + pass + return cast(Union[None, datetime.datetime], data) + + last_spawned_at = _parse_last_spawned_at(d.pop("lastSpawnedAt")) + + memory_mb = d.pop("memoryMB") + + public = d.pop("public") + + spawn_count = d.pop("spawnCount") + + template_id = d.pop("templateID") + + updated_at = isoparse(d.pop("updatedAt")) + + template_legacy = cls( + aliases=aliases, + build_count=build_count, + build_id=build_id, + cpu_count=cpu_count, + created_at=created_at, + created_by=created_by, + disk_size_mb=disk_size_mb, + envd_version=envd_version, + last_spawned_at=last_spawned_at, + memory_mb=memory_mb, + public=public, + spawn_count=spawn_count, + template_id=template_id, + updated_at=updated_at, + ) + + template_legacy.additional_properties = d + return template_legacy + + @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/ucloud_sandbox/api/client/models/template_request_response_v3.py b/ucloud_sandbox/api/client/models/template_request_response_v3.py new file mode 100644 index 0000000..efe0e8c --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_request_response_v3.py @@ -0,0 +1,83 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="TemplateRequestResponseV3") + + +@_attrs_define +class TemplateRequestResponseV3: + """ + Attributes: + aliases (list[str]): Aliases of the template + build_id (str): Identifier of the last successful build for given template + public (bool): Whether the template is public or only accessible by the team + template_id (str): Identifier of the template + """ + + aliases: list[str] + build_id: str + public: bool + template_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + aliases = self.aliases + + build_id = self.build_id + + public = self.public + + template_id = self.template_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "aliases": aliases, + "buildID": build_id, + "public": public, + "templateID": template_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + aliases = cast(list[str], d.pop("aliases")) + + build_id = d.pop("buildID") + + public = d.pop("public") + + template_id = d.pop("templateID") + + template_request_response_v3 = cls( + aliases=aliases, + build_id=build_id, + public=public, + template_id=template_id, + ) + + template_request_response_v3.additional_properties = d + return template_request_response_v3 + + @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/ucloud_sandbox/api/client/models/template_step.py b/ucloud_sandbox/api/client/models/template_step.py new file mode 100644 index 0000000..45daaec --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_step.py @@ -0,0 +1,91 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateStep") + + +@_attrs_define +class TemplateStep: + """Step in the template build process + + Attributes: + type_ (str): Type of the step + args (Union[Unset, list[str]]): Arguments for the step + files_hash (Union[Unset, str]): Hash of the files used in the step + force (Union[Unset, bool]): Whether the step should be forced to run regardless of the cache Default: False. + """ + + type_: str + args: Union[Unset, list[str]] = UNSET + files_hash: Union[Unset, str] = UNSET + force: Union[Unset, bool] = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + args: Union[Unset, list[str]] = UNSET + if not isinstance(self.args, Unset): + args = self.args + + files_hash = self.files_hash + + force = self.force + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + } + ) + if args is not UNSET: + field_dict["args"] = args + if files_hash is not UNSET: + field_dict["filesHash"] = files_hash + if force is not UNSET: + field_dict["force"] = force + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = d.pop("type") + + args = cast(list[str], d.pop("args", UNSET)) + + files_hash = d.pop("filesHash", UNSET) + + force = d.pop("force", UNSET) + + template_step = cls( + type_=type_, + args=args, + files_hash=files_hash, + force=force, + ) + + template_step.additional_properties = d + return template_step + + @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/ucloud_sandbox/api/client/models/template_update_request.py b/ucloud_sandbox/api/client/models/template_update_request.py new file mode 100644 index 0000000..8a9f5be --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_update_request.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateUpdateRequest") + + +@_attrs_define +class TemplateUpdateRequest: + """ + Attributes: + public (Union[Unset, bool]): Whether the template is public or only accessible by the team + """ + + public: Union[Unset, bool] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + public = self.public + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if public is not UNSET: + field_dict["public"] = public + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + public = d.pop("public", UNSET) + + template_update_request = cls( + public=public, + ) + + template_update_request.additional_properties = d + return template_update_request + + @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/ucloud_sandbox/api/client/models/template_with_builds.py b/ucloud_sandbox/api/client/models/template_with_builds.py new file mode 100644 index 0000000..e047ce1 --- /dev/null +++ b/ucloud_sandbox/api/client/models/template_with_builds.py @@ -0,0 +1,148 @@ +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +if TYPE_CHECKING: + from ..models.template_build import TemplateBuild + + +T = TypeVar("T", bound="TemplateWithBuilds") + + +@_attrs_define +class TemplateWithBuilds: + """ + Attributes: + aliases (list[str]): Aliases of the template + builds (list['TemplateBuild']): List of builds for the template + created_at (datetime.datetime): Time when the template was created + last_spawned_at (Union[None, datetime.datetime]): Time when the template was last used + public (bool): Whether the template is public or only accessible by the team + spawn_count (int): Number of times the template was used + template_id (str): Identifier of the template + updated_at (datetime.datetime): Time when the template was last updated + """ + + aliases: list[str] + builds: list["TemplateBuild"] + created_at: datetime.datetime + last_spawned_at: Union[None, datetime.datetime] + public: bool + spawn_count: int + template_id: str + updated_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + aliases = self.aliases + + builds = [] + for builds_item_data in self.builds: + builds_item = builds_item_data.to_dict() + builds.append(builds_item) + + created_at = self.created_at.isoformat() + + last_spawned_at: Union[None, str] + if isinstance(self.last_spawned_at, datetime.datetime): + last_spawned_at = self.last_spawned_at.isoformat() + else: + last_spawned_at = self.last_spawned_at + + public = self.public + + spawn_count = self.spawn_count + + template_id = self.template_id + + updated_at = self.updated_at.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "aliases": aliases, + "builds": builds, + "createdAt": created_at, + "lastSpawnedAt": last_spawned_at, + "public": public, + "spawnCount": spawn_count, + "templateID": template_id, + "updatedAt": updated_at, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.template_build import TemplateBuild + + d = dict(src_dict) + aliases = cast(list[str], d.pop("aliases")) + + builds = [] + _builds = d.pop("builds") + for builds_item_data in _builds: + builds_item = TemplateBuild.from_dict(builds_item_data) + + builds.append(builds_item) + + created_at = isoparse(d.pop("createdAt")) + + def _parse_last_spawned_at(data: object) -> Union[None, datetime.datetime]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + last_spawned_at_type_0 = isoparse(data) + + return last_spawned_at_type_0 + except: # noqa: E722 + pass + return cast(Union[None, datetime.datetime], data) + + last_spawned_at = _parse_last_spawned_at(d.pop("lastSpawnedAt")) + + public = d.pop("public") + + spawn_count = d.pop("spawnCount") + + template_id = d.pop("templateID") + + updated_at = isoparse(d.pop("updatedAt")) + + template_with_builds = cls( + aliases=aliases, + builds=builds, + created_at=created_at, + last_spawned_at=last_spawned_at, + public=public, + spawn_count=spawn_count, + template_id=template_id, + updated_at=updated_at, + ) + + template_with_builds.additional_properties = d + return template_with_builds + + @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/ucloud_sandbox/api/client/models/update_team_api_key.py b/ucloud_sandbox/api/client/models/update_team_api_key.py new file mode 100644 index 0000000..34fb967 --- /dev/null +++ b/ucloud_sandbox/api/client/models/update_team_api_key.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="UpdateTeamAPIKey") + + +@_attrs_define +class UpdateTeamAPIKey: + """ + Attributes: + name (str): New name for the API key + """ + + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + update_team_api_key = cls( + name=name, + ) + + update_team_api_key.additional_properties = d + return update_team_api_key + + @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/ucloud_sandbox/api/client/py.typed b/ucloud_sandbox/api/client/py.typed new file mode 100644 index 0000000..1aad327 --- /dev/null +++ b/ucloud_sandbox/api/client/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 \ No newline at end of file diff --git a/ucloud_sandbox/api/client/types.py b/ucloud_sandbox/api/client/types.py new file mode 100644 index 0000000..1b96ca4 --- /dev/null +++ b/ucloud_sandbox/api/client/types.py @@ -0,0 +1,54 @@ +"""Contains some shared types for properties""" + +from collections.abc import Mapping, MutableMapping +from http import HTTPStatus +from typing import IO, BinaryIO, Generic, Literal, Optional, TypeVar, Union + +from attrs import define + + +class Unset: + def __bool__(self) -> Literal[False]: + return False + + +UNSET: Unset = Unset() + +# The types that `httpx.Client(files=)` can accept, copied from that library. +FileContent = Union[IO[bytes], bytes, str] +FileTypes = Union[ + # (filename, file (or bytes), content_type) + tuple[Optional[str], FileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], +] +RequestFiles = list[tuple[str, FileTypes]] + + +@define +class File: + """Contains information for file uploads""" + + payload: BinaryIO + file_name: Optional[str] = None + mime_type: Optional[str] = None + + def to_tuple(self) -> FileTypes: + """Return a tuple representation that httpx will accept for multipart/form-data""" + return self.file_name, self.payload, self.mime_type + + +T = TypeVar("T") + + +@define +class Response(Generic[T]): + """A response from an endpoint""" + + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: Optional[T] + + +__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"] diff --git a/ucloud_sandbox/api/client_async/__init__.py b/ucloud_sandbox/api/client_async/__init__.py new file mode 100644 index 0000000..e66347a --- /dev/null +++ b/ucloud_sandbox/api/client_async/__init__.py @@ -0,0 +1,50 @@ +import httpx +import logging + +from typing import Optional + +from typing_extensions import Self + +from ucloud_agentbox.connection_config import ConnectionConfig +from ucloud_agentbox.api import limits, AsyncApiClient + + +logger = logging.getLogger(__name__) + + +def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient: + return AsyncApiClient( + config, + transport=get_transport(config), + **kwargs, + ) + + +class AsyncTransportWithLogger(httpx.AsyncHTTPTransport): + singleton: Optional[Self] = None + + async def handle_async_request(self, request): + url = f"{request.url.scheme}://{request.url.host}{request.url.path}" + logger.info(f"Request: {request.method} {url}") + response = await super().handle_async_request(request) + + # data = connect.GzipCompressor.decompress(response.read()).decode() + logger.info(f"Response: {response.status_code} {url}") + + return response + + @property + def pool(self): + return self._pool + + +def get_transport(config: ConnectionConfig) -> AsyncTransportWithLogger: + if AsyncTransportWithLogger.singleton is not None: + return AsyncTransportWithLogger.singleton + + transport = AsyncTransportWithLogger( + limits=limits, + proxy=config.proxy, + ) + AsyncTransportWithLogger.singleton = transport + return transport diff --git a/ucloud_sandbox/api/client_sync/__init__.py b/ucloud_sandbox/api/client_sync/__init__.py new file mode 100644 index 0000000..d875a25 --- /dev/null +++ b/ucloud_sandbox/api/client_sync/__init__.py @@ -0,0 +1,52 @@ +from typing import Optional + +import httpx +import logging + +from typing_extensions import Self + +from ucloud_agentbox.api import ApiClient, limits +from ucloud_agentbox.connection_config import ConnectionConfig + +logger = logging.getLogger(__name__) + + +def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient: + return ApiClient( + config, + transport=get_transport(config), + **kwargs, + ) + + +class TransportWithLogger(httpx.HTTPTransport): + singleton: Optional[Self] = None + + def handle_request(self, request): + url = f"{request.url.scheme}://{request.url.host}{request.url.path}" + logger.info(f"Request: {request.method} {url}") + response = super().handle_request(request) + + # data = connect.GzipCompressor.decompress(response.read()).decode() + logger.info(f"Response: {response.status_code} {url}") + + return response + + @property + def pool(self): + return self._pool + + +_transport: Optional[TransportWithLogger] = None + + +def get_transport(config: ConnectionConfig) -> TransportWithLogger: + if TransportWithLogger.singleton is not None: + return TransportWithLogger.singleton + + transport = TransportWithLogger( + limits=limits, + proxy=config.proxy, + ) + TransportWithLogger.singleton = transport + return transport diff --git a/ucloud_sandbox/api/metadata.py b/ucloud_sandbox/api/metadata.py new file mode 100644 index 0000000..4e88035 --- /dev/null +++ b/ucloud_sandbox/api/metadata.py @@ -0,0 +1,17 @@ +import platform + +from importlib import metadata + +try: + package_version = metadata.version("ucloud_agentbox") +except metadata.PackageNotFoundError: + package_version = "1.0.0" + +default_headers = { + "lang": "python", + "lang_version": platform.python_version(), + "package_version": package_version, + "publisher": "ucloud", + "sdk_runtime": "python", + "system": platform.system(), +} diff --git a/ucloud_sandbox/connection_config.py b/ucloud_sandbox/connection_config.py new file mode 100644 index 0000000..a596f89 --- /dev/null +++ b/ucloud_sandbox/connection_config.py @@ -0,0 +1,217 @@ +import os + +from typing import Optional, Dict, TypedDict + +from httpx._types import ProxyTypes +from typing_extensions import Unpack + +from ucloud_agentbox.api.metadata import package_version + +REQUEST_TIMEOUT: float = 60.0 # 60 seconds + +KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds +KEEPALIVE_PING_HEADER = "Keepalive-Ping-Interval" + + +class ApiParams(TypedDict, total=False): + """ + Parameters for a request. + + In the case of a sandbox, it applies to all **requests made to the returned sandbox**. + """ + + request_timeout: Optional[float] + """Timeout for the request in **seconds**, defaults to 60 seconds.""" + + headers: Optional[Dict[str, str]] + """Additional headers to send with the request.""" + + api_key: Optional[str] + """AgentBox API Key to use for authentication, defaults to `AGENTBOX_API_KEY` environment variable.""" + + domain: Optional[str] + """AgentBox domain to use, defaults to `AGENTBOX_DOMAIN` environment variable.""" + + api_url: Optional[str] + """URL to use for the API, defaults to `https://api.`. For internal use only.""" + + debug: Optional[bool] + """Whether to use debug mode, defaults to `AGENTBOX_DEBUG` environment variable.""" + + proxy: Optional[ProxyTypes] + """Proxy to use for the request. In case of a sandbox it applies to all **requests made to the returned sandbox**.""" + + sandbox_url: Optional[str] + """URL to connect to sandbox, defaults to `AGENTBOX_SANDBOX_URL` environment variable.""" + + +class ConnectionConfig: + """ + Configuration for the connection to the API. + """ + + envd_port = 49983 + + @staticmethod + def _domain(): + return os.getenv("AGENTBOX_DOMAIN") or "sandbox.ucloudai.com" + + @staticmethod + def _debug(): + return os.getenv("AGENTBOX_DEBUG", "false").lower() == "true" + + @staticmethod + def _api_key(): + return os.getenv("AGENTBOX_API_KEY") + + @staticmethod + def _api_url(): + return os.getenv("AGENTBOX_API_URL") + + @staticmethod + def _sandbox_url(): + return os.getenv("AGENTBOX_SANDBOX_URL") + + @staticmethod + def _access_token(): + return os.getenv("AGENTBOX_ACCESS_TOKEN") + + def __init__( + self, + domain: Optional[str] = None, + debug: Optional[bool] = None, + api_key: Optional[str] = None, + api_url: Optional[str] = None, + sandbox_url: Optional[str] = None, + access_token: Optional[str] = None, + request_timeout: Optional[float] = None, + headers: Optional[Dict[str, str]] = None, + extra_sandbox_headers: Optional[Dict[str, str]] = None, + proxy: Optional[ProxyTypes] = None, + ): + self.domain = domain or ConnectionConfig._domain() + self.debug = debug or ConnectionConfig._debug() + self.api_key = api_key or ConnectionConfig._api_key() + self.access_token = access_token or ConnectionConfig._access_token() + self.headers = headers or {} + self.headers["User-Agent"] = f"ucloud-agentbox-sdk/{package_version}" + self.__extra_sandbox_headers = extra_sandbox_headers or {} + + self.proxy = proxy + + self.request_timeout = ConnectionConfig._get_request_timeout( + REQUEST_TIMEOUT, + request_timeout, + ) + + if request_timeout == 0: + self.request_timeout = None + elif request_timeout is not None: + self.request_timeout = request_timeout + else: + self.request_timeout = REQUEST_TIMEOUT + + self.api_url = ( + api_url + or ConnectionConfig._api_url() + or ("http://localhost:3000" if self.debug else f"https://api.{self.domain}") + ) + + self._sandbox_url = sandbox_url or ConnectionConfig._sandbox_url() + + @staticmethod + def _get_request_timeout( + default_timeout: Optional[float], + request_timeout: Optional[float], + ): + if request_timeout == 0: + return None + elif request_timeout is not None: + return request_timeout + else: + return default_timeout + + def get_request_timeout(self, request_timeout: Optional[float] = None): + return self._get_request_timeout(self.request_timeout, request_timeout) + + def get_sandbox_url(self, sandbox_id: str, sandbox_domain: str) -> str: + if self._sandbox_url: + return self._sandbox_url + + return f"{'http' if self.debug else 'https'}://{self.get_host(sandbox_id, sandbox_domain, self.envd_port)}" + + def get_host(self, sandbox_id: str, sandbox_domain: str, port: int) -> str: + """ + Get the host address to connect to the sandbox. + You can then use this address to connect to the sandbox port from outside the sandbox via HTTP or WebSocket. + + :param port: Port to connect to + :param sandbox_domain: Domain to connect to + :param sandbox_id: Sandbox to connect to + + :return: Host address to connect to + """ + if self.debug: + return f"localhost:{port}" + + return f"{port}-{sandbox_id}.{sandbox_domain}" + + def get_api_params( + self, + **opts: Unpack[ApiParams], + ) -> dict: + """ + Get the parameters for the API call. + + This is used to avoid passing the following attributes to the API call: + - access_token + - api_url + + It also returns a copy, so the original object is not modified. + + :return: Dictionary of parameters for the API call + """ + headers = opts.get("headers") + request_timeout = opts.get("request_timeout") + api_key = opts.get("api_key") + api_url = opts.get("api_url") + domain = opts.get("domain") + debug = opts.get("debug") + proxy = opts.get("proxy") + + req_headers = self.headers.copy() + if headers is not None: + req_headers.update(headers) + + return dict( + ApiParams( + api_key=api_key if api_key is not None else self.api_key, + api_url=api_url if api_url is not None else self.api_url, + domain=domain if domain is not None else self.domain, + debug=debug if debug is not None else self.debug, + request_timeout=self.get_request_timeout(request_timeout), + headers=req_headers, + proxy=proxy if proxy is not None else self.proxy, + ) + ) + + @property + def sandbox_headers(self): + """ + We need this separate as we use the same header for access token to API and envd access token to sandbox. + """ + return { + **self.headers, + **self.__extra_sandbox_headers, + } + + +Username = str +""" +User used for the operation in the sandbox. +""" + +default_username: Username = "user" +""" +Default user used for the operation in the sandbox. +""" diff --git a/ucloud_sandbox/envd/api.py b/ucloud_sandbox/envd/api.py new file mode 100644 index 0000000..5f89e3c --- /dev/null +++ b/ucloud_sandbox/envd/api.py @@ -0,0 +1,59 @@ +import httpx +import json + +from ucloud_agentbox.exceptions import ( + SandboxException, + NotFoundException, + AuthenticationException, + InvalidArgumentException, + NotEnoughSpaceException, + format_sandbox_timeout_exception, +) + + +ENVD_API_FILES_ROUTE = "/files" +ENVD_API_HEALTH_ROUTE = "/health" + + +def get_message(e: httpx.Response) -> str: + try: + message = e.json().get("message", e.text) + except json.JSONDecodeError: + message = e.text + + return message + + +def handle_envd_api_exception(res: httpx.Response): + if res.is_success: + return + + res.read() + + return format_envd_api_exception(res.status_code, get_message(res)) + + +async def ahandle_envd_api_exception(res: httpx.Response): + if res.is_success: + return + + await res.aread() + + return format_envd_api_exception(res.status_code, get_message(res)) + + +def format_envd_api_exception(status_code: int, message: str): + if status_code == 400: + return InvalidArgumentException(message) + elif status_code == 401: + return AuthenticationException(message) + elif status_code == 404: + return NotFoundException(message) + elif status_code == 429: + return SandboxException(f"{message}: The requests are being rate limited.") + elif status_code == 502: + return format_sandbox_timeout_exception(message) + elif status_code == 507: + return NotEnoughSpaceException(message) + else: + return SandboxException(f"{status_code}: {message}") diff --git a/ucloud_sandbox/envd/filesystem/filesystem_connect.py b/ucloud_sandbox/envd/filesystem/filesystem_connect.py new file mode 100644 index 0000000..3f08f9c --- /dev/null +++ b/ucloud_sandbox/envd/filesystem/filesystem_connect.py @@ -0,0 +1,193 @@ +# Code generated by protoc-gen-connect-python 0.1.0.dev2, DO NOT EDIT. +from typing import Any, Generator, Coroutine, AsyncGenerator, Optional +from httpcore import ConnectionPool, AsyncConnectionPool + +import e2b_connect as connect + +from ucloud_agentbox.envd.filesystem import filesystem_pb2 as filesystem_dot_filesystem__pb2 + +FilesystemName = "filesystem.Filesystem" + + +class FilesystemClient: + def __init__( + self, + base_url: str, + *, + pool: Optional[ConnectionPool] = None, + async_pool: Optional[AsyncConnectionPool] = None, + compressor=None, + json=False, + **opts, + ): + self._stat = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/Stat", + response_type=filesystem_dot_filesystem__pb2.StatResponse, + compressor=compressor, + json=json, + **opts, + ) + self._make_dir = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/MakeDir", + response_type=filesystem_dot_filesystem__pb2.MakeDirResponse, + compressor=compressor, + json=json, + **opts, + ) + self._move = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/Move", + response_type=filesystem_dot_filesystem__pb2.MoveResponse, + compressor=compressor, + json=json, + **opts, + ) + self._list_dir = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/ListDir", + response_type=filesystem_dot_filesystem__pb2.ListDirResponse, + compressor=compressor, + json=json, + **opts, + ) + self._remove = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/Remove", + response_type=filesystem_dot_filesystem__pb2.RemoveResponse, + compressor=compressor, + json=json, + **opts, + ) + self._watch_dir = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/WatchDir", + response_type=filesystem_dot_filesystem__pb2.WatchDirResponse, + compressor=compressor, + json=json, + **opts, + ) + self._create_watcher = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/CreateWatcher", + response_type=filesystem_dot_filesystem__pb2.CreateWatcherResponse, + compressor=compressor, + json=json, + **opts, + ) + self._get_watcher_events = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/GetWatcherEvents", + response_type=filesystem_dot_filesystem__pb2.GetWatcherEventsResponse, + compressor=compressor, + json=json, + **opts, + ) + self._remove_watcher = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{FilesystemName}/RemoveWatcher", + response_type=filesystem_dot_filesystem__pb2.RemoveWatcherResponse, + compressor=compressor, + json=json, + **opts, + ) + + def stat( + self, req: filesystem_dot_filesystem__pb2.StatRequest, **opts + ) -> filesystem_dot_filesystem__pb2.StatResponse: + return self._stat.call_unary(req, **opts) + + def astat( + self, req: filesystem_dot_filesystem__pb2.StatRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.StatResponse]: + return self._stat.acall_unary(req, **opts) + + def make_dir( + self, req: filesystem_dot_filesystem__pb2.MakeDirRequest, **opts + ) -> filesystem_dot_filesystem__pb2.MakeDirResponse: + return self._make_dir.call_unary(req, **opts) + + def amake_dir( + self, req: filesystem_dot_filesystem__pb2.MakeDirRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.MakeDirResponse]: + return self._make_dir.acall_unary(req, **opts) + + def move( + self, req: filesystem_dot_filesystem__pb2.MoveRequest, **opts + ) -> filesystem_dot_filesystem__pb2.MoveResponse: + return self._move.call_unary(req, **opts) + + def amove( + self, req: filesystem_dot_filesystem__pb2.MoveRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.MoveResponse]: + return self._move.acall_unary(req, **opts) + + def list_dir( + self, req: filesystem_dot_filesystem__pb2.ListDirRequest, **opts + ) -> filesystem_dot_filesystem__pb2.ListDirResponse: + return self._list_dir.call_unary(req, **opts) + + def alist_dir( + self, req: filesystem_dot_filesystem__pb2.ListDirRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.ListDirResponse]: + return self._list_dir.acall_unary(req, **opts) + + def remove( + self, req: filesystem_dot_filesystem__pb2.RemoveRequest, **opts + ) -> filesystem_dot_filesystem__pb2.RemoveResponse: + return self._remove.call_unary(req, **opts) + + def aremove( + self, req: filesystem_dot_filesystem__pb2.RemoveRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.RemoveResponse]: + return self._remove.acall_unary(req, **opts) + + def watch_dir( + self, req: filesystem_dot_filesystem__pb2.WatchDirRequest, **opts + ) -> Generator[filesystem_dot_filesystem__pb2.WatchDirResponse, Any, None]: + return self._watch_dir.call_server_stream(req, **opts) + + def awatch_dir( + self, req: filesystem_dot_filesystem__pb2.WatchDirRequest, **opts + ) -> AsyncGenerator[filesystem_dot_filesystem__pb2.WatchDirResponse, Any]: + return self._watch_dir.acall_server_stream(req, **opts) + + def create_watcher( + self, req: filesystem_dot_filesystem__pb2.CreateWatcherRequest, **opts + ) -> filesystem_dot_filesystem__pb2.CreateWatcherResponse: + return self._create_watcher.call_unary(req, **opts) + + def acreate_watcher( + self, req: filesystem_dot_filesystem__pb2.CreateWatcherRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.CreateWatcherResponse]: + return self._create_watcher.acall_unary(req, **opts) + + def get_watcher_events( + self, req: filesystem_dot_filesystem__pb2.GetWatcherEventsRequest, **opts + ) -> filesystem_dot_filesystem__pb2.GetWatcherEventsResponse: + return self._get_watcher_events.call_unary(req, **opts) + + def aget_watcher_events( + self, req: filesystem_dot_filesystem__pb2.GetWatcherEventsRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.GetWatcherEventsResponse]: + return self._get_watcher_events.acall_unary(req, **opts) + + def remove_watcher( + self, req: filesystem_dot_filesystem__pb2.RemoveWatcherRequest, **opts + ) -> filesystem_dot_filesystem__pb2.RemoveWatcherResponse: + return self._remove_watcher.call_unary(req, **opts) + + def aremove_watcher( + self, req: filesystem_dot_filesystem__pb2.RemoveWatcherRequest, **opts + ) -> Coroutine[Any, Any, filesystem_dot_filesystem__pb2.RemoveWatcherResponse]: + return self._remove_watcher.acall_unary(req, **opts) diff --git a/ucloud_sandbox/envd/filesystem/filesystem_pb2.py b/ucloud_sandbox/envd/filesystem/filesystem_pb2.py new file mode 100644 index 0000000..54bb90c --- /dev/null +++ b/ucloud_sandbox/envd/filesystem/filesystem_pb2.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: filesystem/filesystem.proto +# Protobuf Python Version: 5.26.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66ilesystem/filesystem.proto\x12\nfilesystem\x1a\x1fgoogle/protobuf/timestamp.proto\"G\n\x0bMoveRequest\x12\x16\n\x06source\x18\x01 \x01(\tR\x06source\x12 \n\x0b\x64\x65stination\x18\x02 \x01(\tR\x0b\x64\x65stination\";\n\x0cMoveResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"$\n\x0eMakeDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\">\n\x0fMakeDirResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"#\n\rRemoveRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\"\x10\n\x0eRemoveResponse\"!\n\x0bStatRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\";\n\x0cStatResponse\x12+\n\x05\x65ntry\x18\x01 \x01(\x0b\x32\x15.filesystem.EntryInfoR\x05\x65ntry\"\xd3\x02\n\tEntryInfo\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12(\n\x04type\x18\x02 \x01(\x0e\x32\x14.filesystem.FileTypeR\x04type\x12\x12\n\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n\x0bpermissions\x18\x06 \x01(\tR\x0bpermissions\x12\x14\n\x05owner\x18\x07 \x01(\tR\x05owner\x12\x14\n\x05group\x18\x08 \x01(\tR\x05group\x12?\n\rmodified_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0cmodifiedTime\x12*\n\x0esymlink_target\x18\n \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01\x42\x11\n\x0f_symlink_target\":\n\x0eListDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n\x05\x64\x65pth\x18\x02 \x01(\rR\x05\x64\x65pth\"B\n\x0fListDirResponse\x12/\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x15.filesystem.EntryInfoR\x07\x65ntries\"C\n\x0fWatchDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\"P\n\x0f\x46ilesystemEvent\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12)\n\x04type\x18\x02 \x01(\x0e\x32\x15.filesystem.EventTypeR\x04type\"\xfe\x01\n\x10WatchDirResponse\x12?\n\x05start\x18\x01 \x01(\x0b\x32\'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n\nfilesystem\x18\x02 \x01(\x0b\x32\x1b.filesystem.FilesystemEventH\x00R\nfilesystem\x12\x46\n\tkeepalive\x18\x03 \x01(\x0b\x32&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\x0c\n\nStartEvent\x1a\x0b\n\tKeepAliveB\x07\n\x05\x65vent\"H\n\x14\x43reateWatcherRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\"6\n\x15\x43reateWatcherResponse\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"8\n\x17GetWatcherEventsRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"O\n\x18GetWatcherEventsResponse\x12\x33\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1b.filesystem.FilesystemEventR\x06\x65vents\"5\n\x14RemoveWatcherRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId\"\x17\n\x15RemoveWatcherResponse*R\n\x08\x46ileType\x12\x19\n\x15\x46ILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x46ILE_TYPE_FILE\x10\x01\x12\x17\n\x13\x46ILE_TYPE_DIRECTORY\x10\x02*\x98\x01\n\tEventType\x12\x1a\n\x16\x45VENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11\x45VENT_TYPE_CREATE\x10\x01\x12\x14\n\x10\x45VENT_TYPE_WRITE\x10\x02\x12\x15\n\x11\x45VENT_TYPE_REMOVE\x10\x03\x12\x15\n\x11\x45VENT_TYPE_RENAME\x10\x04\x12\x14\n\x10\x45VENT_TYPE_CHMOD\x10\x05\x32\x9f\x05\n\nFilesystem\x12\x39\n\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12\x42\n\x07MakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x12\x39\n\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12\x42\n\x07ListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n\x08WatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseBi\n\x0e\x63om.filesystemB\x0f\x46ilesystemProtoP\x01\xa2\x02\x03\x46XX\xaa\x02\nFilesystem\xca\x02\nFilesystem\xe2\x02\x16\x46ilesystem\\GPBMetadata\xea\x02\nFilesystemb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'filesystem.filesystem_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n\016com.filesystemB\017FilesystemProtoP\001\242\002\003FXX\252\002\nFilesystem\312\002\nFilesystem\342\002\026Filesystem\\GPBMetadata\352\002\nFilesystem' + _globals['_FILETYPE']._serialized_start=1690 + _globals['_FILETYPE']._serialized_end=1772 + _globals['_EVENTTYPE']._serialized_start=1775 + _globals['_EVENTTYPE']._serialized_end=1927 + _globals['_MOVEREQUEST']._serialized_start=76 + _globals['_MOVEREQUEST']._serialized_end=147 + _globals['_MOVERESPONSE']._serialized_start=149 + _globals['_MOVERESPONSE']._serialized_end=208 + _globals['_MAKEDIRREQUEST']._serialized_start=210 + _globals['_MAKEDIRREQUEST']._serialized_end=246 + _globals['_MAKEDIRRESPONSE']._serialized_start=248 + _globals['_MAKEDIRRESPONSE']._serialized_end=310 + _globals['_REMOVEREQUEST']._serialized_start=312 + _globals['_REMOVEREQUEST']._serialized_end=347 + _globals['_REMOVERESPONSE']._serialized_start=349 + _globals['_REMOVERESPONSE']._serialized_end=365 + _globals['_STATREQUEST']._serialized_start=367 + _globals['_STATREQUEST']._serialized_end=400 + _globals['_STATRESPONSE']._serialized_start=402 + _globals['_STATRESPONSE']._serialized_end=461 + _globals['_ENTRYINFO']._serialized_start=464 + _globals['_ENTRYINFO']._serialized_end=803 + _globals['_LISTDIRREQUEST']._serialized_start=805 + _globals['_LISTDIRREQUEST']._serialized_end=863 + _globals['_LISTDIRRESPONSE']._serialized_start=865 + _globals['_LISTDIRRESPONSE']._serialized_end=931 + _globals['_WATCHDIRREQUEST']._serialized_start=933 + _globals['_WATCHDIRREQUEST']._serialized_end=1000 + _globals['_FILESYSTEMEVENT']._serialized_start=1002 + _globals['_FILESYSTEMEVENT']._serialized_end=1082 + _globals['_WATCHDIRRESPONSE']._serialized_start=1085 + _globals['_WATCHDIRRESPONSE']._serialized_end=1339 + _globals['_WATCHDIRRESPONSE_STARTEVENT']._serialized_start=1305 + _globals['_WATCHDIRRESPONSE_STARTEVENT']._serialized_end=1317 + _globals['_WATCHDIRRESPONSE_KEEPALIVE']._serialized_start=1319 + _globals['_WATCHDIRRESPONSE_KEEPALIVE']._serialized_end=1330 + _globals['_CREATEWATCHERREQUEST']._serialized_start=1341 + _globals['_CREATEWATCHERREQUEST']._serialized_end=1413 + _globals['_CREATEWATCHERRESPONSE']._serialized_start=1415 + _globals['_CREATEWATCHERRESPONSE']._serialized_end=1469 + _globals['_GETWATCHEREVENTSREQUEST']._serialized_start=1471 + _globals['_GETWATCHEREVENTSREQUEST']._serialized_end=1527 + _globals['_GETWATCHEREVENTSRESPONSE']._serialized_start=1529 + _globals['_GETWATCHEREVENTSRESPONSE']._serialized_end=1608 + _globals['_REMOVEWATCHERREQUEST']._serialized_start=1610 + _globals['_REMOVEWATCHERREQUEST']._serialized_end=1663 + _globals['_REMOVEWATCHERRESPONSE']._serialized_start=1665 + _globals['_REMOVEWATCHERRESPONSE']._serialized_end=1688 + _globals['_FILESYSTEM']._serialized_start=1930 + _globals['_FILESYSTEM']._serialized_end=2601 +# @@protoc_insertion_point(module_scope) diff --git a/ucloud_sandbox/envd/filesystem/filesystem_pb2.pyi b/ucloud_sandbox/envd/filesystem/filesystem_pb2.pyi new file mode 100644 index 0000000..4770979 --- /dev/null +++ b/ucloud_sandbox/envd/filesystem/filesystem_pb2.pyi @@ -0,0 +1,233 @@ +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ( + ClassVar as _ClassVar, + Iterable as _Iterable, + Mapping as _Mapping, + Optional as _Optional, + Union as _Union, +) + +DESCRIPTOR: _descriptor.FileDescriptor + +class FileType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + FILE_TYPE_UNSPECIFIED: _ClassVar[FileType] + FILE_TYPE_FILE: _ClassVar[FileType] + FILE_TYPE_DIRECTORY: _ClassVar[FileType] + +class EventType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + EVENT_TYPE_UNSPECIFIED: _ClassVar[EventType] + EVENT_TYPE_CREATE: _ClassVar[EventType] + EVENT_TYPE_WRITE: _ClassVar[EventType] + EVENT_TYPE_REMOVE: _ClassVar[EventType] + EVENT_TYPE_RENAME: _ClassVar[EventType] + EVENT_TYPE_CHMOD: _ClassVar[EventType] + +FILE_TYPE_UNSPECIFIED: FileType +FILE_TYPE_FILE: FileType +FILE_TYPE_DIRECTORY: FileType +EVENT_TYPE_UNSPECIFIED: EventType +EVENT_TYPE_CREATE: EventType +EVENT_TYPE_WRITE: EventType +EVENT_TYPE_REMOVE: EventType +EVENT_TYPE_RENAME: EventType +EVENT_TYPE_CHMOD: EventType + +class MoveRequest(_message.Message): + __slots__ = ("source", "destination") + SOURCE_FIELD_NUMBER: _ClassVar[int] + DESTINATION_FIELD_NUMBER: _ClassVar[int] + source: str + destination: str + def __init__( + self, source: _Optional[str] = ..., destination: _Optional[str] = ... + ) -> None: ... + +class MoveResponse(_message.Message): + __slots__ = ("entry",) + ENTRY_FIELD_NUMBER: _ClassVar[int] + entry: EntryInfo + def __init__(self, entry: _Optional[_Union[EntryInfo, _Mapping]] = ...) -> None: ... + +class MakeDirRequest(_message.Message): + __slots__ = ("path",) + PATH_FIELD_NUMBER: _ClassVar[int] + path: str + def __init__(self, path: _Optional[str] = ...) -> None: ... + +class MakeDirResponse(_message.Message): + __slots__ = ("entry",) + ENTRY_FIELD_NUMBER: _ClassVar[int] + entry: EntryInfo + def __init__(self, entry: _Optional[_Union[EntryInfo, _Mapping]] = ...) -> None: ... + +class RemoveRequest(_message.Message): + __slots__ = ("path",) + PATH_FIELD_NUMBER: _ClassVar[int] + path: str + def __init__(self, path: _Optional[str] = ...) -> None: ... + +class RemoveResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class StatRequest(_message.Message): + __slots__ = ("path",) + PATH_FIELD_NUMBER: _ClassVar[int] + path: str + def __init__(self, path: _Optional[str] = ...) -> None: ... + +class StatResponse(_message.Message): + __slots__ = ("entry",) + ENTRY_FIELD_NUMBER: _ClassVar[int] + entry: EntryInfo + def __init__(self, entry: _Optional[_Union[EntryInfo, _Mapping]] = ...) -> None: ... + +class EntryInfo(_message.Message): + __slots__ = ( + "name", + "type", + "path", + "size", + "mode", + "permissions", + "owner", + "group", + "modified_time", + "symlink_target", + ) + NAME_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + SIZE_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] + PERMISSIONS_FIELD_NUMBER: _ClassVar[int] + OWNER_FIELD_NUMBER: _ClassVar[int] + GROUP_FIELD_NUMBER: _ClassVar[int] + MODIFIED_TIME_FIELD_NUMBER: _ClassVar[int] + SYMLINK_TARGET_FIELD_NUMBER: _ClassVar[int] + name: str + type: FileType + path: str + size: int + mode: int + permissions: str + owner: str + group: str + modified_time: _timestamp_pb2.Timestamp + symlink_target: str + def __init__( + self, + name: _Optional[str] = ..., + type: _Optional[_Union[FileType, str]] = ..., + path: _Optional[str] = ..., + size: _Optional[int] = ..., + mode: _Optional[int] = ..., + permissions: _Optional[str] = ..., + owner: _Optional[str] = ..., + group: _Optional[str] = ..., + modified_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., + symlink_target: _Optional[str] = ..., + ) -> None: ... + +class ListDirRequest(_message.Message): + __slots__ = ("path", "depth") + PATH_FIELD_NUMBER: _ClassVar[int] + DEPTH_FIELD_NUMBER: _ClassVar[int] + path: str + depth: int + def __init__( + self, path: _Optional[str] = ..., depth: _Optional[int] = ... + ) -> None: ... + +class ListDirResponse(_message.Message): + __slots__ = ("entries",) + ENTRIES_FIELD_NUMBER: _ClassVar[int] + entries: _containers.RepeatedCompositeFieldContainer[EntryInfo] + def __init__( + self, entries: _Optional[_Iterable[_Union[EntryInfo, _Mapping]]] = ... + ) -> None: ... + +class WatchDirRequest(_message.Message): + __slots__ = ("path", "recursive") + PATH_FIELD_NUMBER: _ClassVar[int] + RECURSIVE_FIELD_NUMBER: _ClassVar[int] + path: str + recursive: bool + def __init__(self, path: _Optional[str] = ..., recursive: bool = ...) -> None: ... + +class FilesystemEvent(_message.Message): + __slots__ = ("name", "type") + NAME_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + name: str + type: EventType + def __init__( + self, name: _Optional[str] = ..., type: _Optional[_Union[EventType, str]] = ... + ) -> None: ... + +class WatchDirResponse(_message.Message): + __slots__ = ("start", "filesystem", "keepalive") + class StartEvent(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + + class KeepAlive(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + + START_FIELD_NUMBER: _ClassVar[int] + FILESYSTEM_FIELD_NUMBER: _ClassVar[int] + KEEPALIVE_FIELD_NUMBER: _ClassVar[int] + start: WatchDirResponse.StartEvent + filesystem: FilesystemEvent + keepalive: WatchDirResponse.KeepAlive + def __init__( + self, + start: _Optional[_Union[WatchDirResponse.StartEvent, _Mapping]] = ..., + filesystem: _Optional[_Union[FilesystemEvent, _Mapping]] = ..., + keepalive: _Optional[_Union[WatchDirResponse.KeepAlive, _Mapping]] = ..., + ) -> None: ... + +class CreateWatcherRequest(_message.Message): + __slots__ = ("path", "recursive") + PATH_FIELD_NUMBER: _ClassVar[int] + RECURSIVE_FIELD_NUMBER: _ClassVar[int] + path: str + recursive: bool + def __init__(self, path: _Optional[str] = ..., recursive: bool = ...) -> None: ... + +class CreateWatcherResponse(_message.Message): + __slots__ = ("watcher_id",) + WATCHER_ID_FIELD_NUMBER: _ClassVar[int] + watcher_id: str + def __init__(self, watcher_id: _Optional[str] = ...) -> None: ... + +class GetWatcherEventsRequest(_message.Message): + __slots__ = ("watcher_id",) + WATCHER_ID_FIELD_NUMBER: _ClassVar[int] + watcher_id: str + def __init__(self, watcher_id: _Optional[str] = ...) -> None: ... + +class GetWatcherEventsResponse(_message.Message): + __slots__ = ("events",) + EVENTS_FIELD_NUMBER: _ClassVar[int] + events: _containers.RepeatedCompositeFieldContainer[FilesystemEvent] + def __init__( + self, events: _Optional[_Iterable[_Union[FilesystemEvent, _Mapping]]] = ... + ) -> None: ... + +class RemoveWatcherRequest(_message.Message): + __slots__ = ("watcher_id",) + WATCHER_ID_FIELD_NUMBER: _ClassVar[int] + watcher_id: str + def __init__(self, watcher_id: _Optional[str] = ...) -> None: ... + +class RemoveWatcherResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... diff --git a/ucloud_sandbox/envd/process/process_connect.py b/ucloud_sandbox/envd/process/process_connect.py new file mode 100644 index 0000000..e55ce4a --- /dev/null +++ b/ucloud_sandbox/envd/process/process_connect.py @@ -0,0 +1,155 @@ +# Code generated by protoc-gen-connect-python 0.1.0.dev2, DO NOT EDIT. +from typing import Any, Generator, Coroutine, AsyncGenerator, Optional +from httpcore import ConnectionPool, AsyncConnectionPool + +import e2b_connect as connect + +from ucloud_agentbox.envd.process import process_pb2 as process_dot_process__pb2 + +ProcessName = "process.Process" + + +class ProcessClient: + def __init__( + self, + base_url: str, + *, + pool: Optional[ConnectionPool] = None, + async_pool: Optional[AsyncConnectionPool] = None, + compressor=None, + json=False, + **opts, + ): + self._list = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/List", + response_type=process_dot_process__pb2.ListResponse, + compressor=compressor, + json=json, + **opts, + ) + self._connect = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/Connect", + response_type=process_dot_process__pb2.ConnectResponse, + compressor=compressor, + json=json, + **opts, + ) + self._start = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/Start", + response_type=process_dot_process__pb2.StartResponse, + compressor=compressor, + json=json, + **opts, + ) + self._update = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/Update", + response_type=process_dot_process__pb2.UpdateResponse, + compressor=compressor, + json=json, + **opts, + ) + self._stream_input = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/StreamInput", + response_type=process_dot_process__pb2.StreamInputResponse, + compressor=compressor, + json=json, + **opts, + ) + self._send_input = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/SendInput", + response_type=process_dot_process__pb2.SendInputResponse, + compressor=compressor, + json=json, + **opts, + ) + self._send_signal = connect.Client( + pool=pool, + async_pool=async_pool, + url=f"{base_url}/{ProcessName}/SendSignal", + response_type=process_dot_process__pb2.SendSignalResponse, + compressor=compressor, + json=json, + **opts, + ) + + def list( + self, req: process_dot_process__pb2.ListRequest, **opts + ) -> process_dot_process__pb2.ListResponse: + return self._list.call_unary(req, **opts) + + def alist( + self, req: process_dot_process__pb2.ListRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.ListResponse]: + return self._list.acall_unary(req, **opts) + + def connect( + self, req: process_dot_process__pb2.ConnectRequest, **opts + ) -> Generator[process_dot_process__pb2.ConnectResponse, Any, None]: + return self._connect.call_server_stream(req, **opts) + + def aconnect( + self, req: process_dot_process__pb2.ConnectRequest, **opts + ) -> AsyncGenerator[process_dot_process__pb2.ConnectResponse, Any]: + return self._connect.acall_server_stream(req, **opts) + + def start( + self, req: process_dot_process__pb2.StartRequest, **opts + ) -> Generator[process_dot_process__pb2.StartResponse, Any, None]: + return self._start.call_server_stream(req, **opts) + + def astart( + self, req: process_dot_process__pb2.StartRequest, **opts + ) -> AsyncGenerator[process_dot_process__pb2.StartResponse, Any]: + return self._start.acall_server_stream(req, **opts) + + def update( + self, req: process_dot_process__pb2.UpdateRequest, **opts + ) -> process_dot_process__pb2.UpdateResponse: + return self._update.call_unary(req, **opts) + + def aupdate( + self, req: process_dot_process__pb2.UpdateRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.UpdateResponse]: + return self._update.acall_unary(req, **opts) + + def stream_input( + self, req: process_dot_process__pb2.StreamInputRequest, **opts + ) -> process_dot_process__pb2.StreamInputResponse: + return self._stream_input.call_client_stream(req, **opts) + + def astream_input( + self, req: process_dot_process__pb2.StreamInputRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.StreamInputResponse]: + return self._stream_input.acall_client_stream(req, **opts) + + def send_input( + self, req: process_dot_process__pb2.SendInputRequest, **opts + ) -> process_dot_process__pb2.SendInputResponse: + return self._send_input.call_unary(req, **opts) + + def asend_input( + self, req: process_dot_process__pb2.SendInputRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.SendInputResponse]: + return self._send_input.acall_unary(req, **opts) + + def send_signal( + self, req: process_dot_process__pb2.SendSignalRequest, **opts + ) -> process_dot_process__pb2.SendSignalResponse: + return self._send_signal.call_unary(req, **opts) + + def asend_signal( + self, req: process_dot_process__pb2.SendSignalRequest, **opts + ) -> Coroutine[Any, Any, process_dot_process__pb2.SendSignalResponse]: + return self._send_signal.acall_unary(req, **opts) diff --git a/ucloud_sandbox/envd/process/process_pb2.py b/ucloud_sandbox/envd/process/process_pb2.py new file mode 100644 index 0000000..ac8943e --- /dev/null +++ b/ucloud_sandbox/envd/process/process_pb2.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: process/process.proto +# Protobuf Python Version: 5.26.1 +"""Generated protocol buffer code.""" + +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\x15process/process.proto\x12\x07process"\\\n\x03PTY\x12%\n\x04size\x18\x01 \x01(\x0b\x32\x11.process.PTY.SizeR\x04size\x1a.\n\x04Size\x12\x12\n\x04\x63ols\x18\x01 \x01(\rR\x04\x63ols\x12\x12\n\x04rows\x18\x02 \x01(\rR\x04rows"\xc3\x01\n\rProcessConfig\x12\x10\n\x03\x63md\x18\x01 \x01(\tR\x03\x63md\x12\x12\n\x04\x61rgs\x18\x02 \x03(\tR\x04\x61rgs\x12\x34\n\x04\x65nvs\x18\x03 \x03(\x0b\x32 .process.ProcessConfig.EnvsEntryR\x04\x65nvs\x12\x15\n\x03\x63wd\x18\x04 \x01(\tH\x00R\x03\x63wd\x88\x01\x01\x1a\x37\n\tEnvsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x06\n\x04_cwd"\r\n\x0bListRequest"n\n\x0bProcessInfo\x12.\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x16.process.ProcessConfigR\x06\x63onfig\x12\x10\n\x03pid\x18\x02 \x01(\rR\x03pid\x12\x15\n\x03tag\x18\x03 \x01(\tH\x00R\x03tag\x88\x01\x01\x42\x06\n\x04_tag"B\n\x0cListResponse\x12\x32\n\tprocesses\x18\x01 \x03(\x0b\x32\x14.process.ProcessInfoR\tprocesses"\xb1\x01\n\x0cStartRequest\x12\x30\n\x07process\x18\x01 \x01(\x0b\x32\x16.process.ProcessConfigR\x07process\x12#\n\x03pty\x18\x02 \x01(\x0b\x32\x0c.process.PTYH\x00R\x03pty\x88\x01\x01\x12\x15\n\x03tag\x18\x03 \x01(\tH\x01R\x03tag\x88\x01\x01\x12\x19\n\x05stdin\x18\x04 \x01(\x08H\x02R\x05stdin\x88\x01\x01\x42\x06\n\x04_ptyB\x06\n\x04_tagB\x08\n\x06_stdin"p\n\rUpdateRequest\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process\x12#\n\x03pty\x18\x02 \x01(\x0b\x32\x0c.process.PTYH\x00R\x03pty\x88\x01\x01\x42\x06\n\x04_pty"\x10\n\x0eUpdateResponse"\x87\x04\n\x0cProcessEvent\x12\x38\n\x05start\x18\x01 \x01(\x0b\x32 .process.ProcessEvent.StartEventH\x00R\x05start\x12\x35\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x1f.process.ProcessEvent.DataEventH\x00R\x04\x64\x61ta\x12\x32\n\x03\x65nd\x18\x03 \x01(\x0b\x32\x1e.process.ProcessEvent.EndEventH\x00R\x03\x65nd\x12?\n\tkeepalive\x18\x04 \x01(\x0b\x32\x1f.process.ProcessEvent.KeepAliveH\x00R\tkeepalive\x1a\x1e\n\nStartEvent\x12\x10\n\x03pid\x18\x01 \x01(\rR\x03pid\x1a]\n\tDataEvent\x12\x18\n\x06stdout\x18\x01 \x01(\x0cH\x00R\x06stdout\x12\x18\n\x06stderr\x18\x02 \x01(\x0cH\x00R\x06stderr\x12\x12\n\x03pty\x18\x03 \x01(\x0cH\x00R\x03ptyB\x08\n\x06output\x1a|\n\x08\x45ndEvent\x12\x1b\n\texit_code\x18\x01 \x01(\x11R\x08\x65xitCode\x12\x16\n\x06\x65xited\x18\x02 \x01(\x08R\x06\x65xited\x12\x16\n\x06status\x18\x03 \x01(\tR\x06status\x12\x19\n\x05\x65rror\x18\x04 \x01(\tH\x00R\x05\x65rror\x88\x01\x01\x42\x08\n\x06_error\x1a\x0b\n\tKeepAliveB\x07\n\x05\x65vent"<\n\rStartResponse\x12+\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x15.process.ProcessEventR\x05\x65vent">\n\x0f\x43onnectResponse\x12+\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x15.process.ProcessEventR\x05\x65vent"s\n\x10SendInputRequest\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process\x12+\n\x05input\x18\x02 \x01(\x0b\x32\x15.process.ProcessInputR\x05input"\x13\n\x11SendInputResponse"C\n\x0cProcessInput\x12\x16\n\x05stdin\x18\x01 \x01(\x0cH\x00R\x05stdin\x12\x12\n\x03pty\x18\x02 \x01(\x0cH\x00R\x03ptyB\x07\n\x05input"\xea\x02\n\x12StreamInputRequest\x12>\n\x05start\x18\x01 \x01(\x0b\x32&.process.StreamInputRequest.StartEventH\x00R\x05start\x12;\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32%.process.StreamInputRequest.DataEventH\x00R\x04\x64\x61ta\x12\x45\n\tkeepalive\x18\x03 \x01(\x0b\x32%.process.StreamInputRequest.KeepAliveH\x00R\tkeepalive\x1a@\n\nStartEvent\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process\x1a\x38\n\tDataEvent\x12+\n\x05input\x18\x02 \x01(\x0b\x32\x15.process.ProcessInputR\x05input\x1a\x0b\n\tKeepAliveB\x07\n\x05\x65vent"\x15\n\x13StreamInputResponse"p\n\x11SendSignalRequest\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process\x12\'\n\x06signal\x18\x02 \x01(\x0e\x32\x0f.process.SignalR\x06signal"\x14\n\x12SendSignalResponse"D\n\x0e\x43onnectRequest\x12\x32\n\x07process\x18\x01 \x01(\x0b\x32\x18.process.ProcessSelectorR\x07process"E\n\x0fProcessSelector\x12\x12\n\x03pid\x18\x01 \x01(\rH\x00R\x03pid\x12\x12\n\x03tag\x18\x02 \x01(\tH\x00R\x03tagB\n\n\x08selector*H\n\x06Signal\x12\x16\n\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n\x0eSIGNAL_SIGTERM\x10\x0f\x12\x12\n\x0eSIGNAL_SIGKILL\x10\t2\xca\x03\n\x07Process\x12\x33\n\x04List\x12\x14.process.ListRequest\x1a\x15.process.ListResponse\x12>\n\x07\x43onnect\x12\x17.process.ConnectRequest\x1a\x18.process.ConnectResponse0\x01\x12\x38\n\x05Start\x12\x15.process.StartRequest\x1a\x16.process.StartResponse0\x01\x12\x39\n\x06Update\x12\x16.process.UpdateRequest\x1a\x17.process.UpdateResponse\x12J\n\x0bStreamInput\x12\x1b.process.StreamInputRequest\x1a\x1c.process.StreamInputResponse(\x01\x12\x42\n\tSendInput\x12\x19.process.SendInputRequest\x1a\x1a.process.SendInputResponse\x12\x45\n\nSendSignal\x12\x1a.process.SendSignalRequest\x1a\x1b.process.SendSignalResponseBW\n\x0b\x63om.processB\x0cProcessProtoP\x01\xa2\x02\x03PXX\xaa\x02\x07Process\xca\x02\x07Process\xe2\x02\x13Process\\GPBMetadata\xea\x02\x07Processb\x06proto3' +) + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "process.process_pb2", _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals["DESCRIPTOR"]._loaded_options = None + _globals[ + "DESCRIPTOR" + ]._serialized_options = b"\n\013com.processB\014ProcessProtoP\001\242\002\003PXX\252\002\007Process\312\002\007Process\342\002\023Process\\GPBMetadata\352\002\007Process" + _globals["_PROCESSCONFIG_ENVSENTRY"]._loaded_options = None + _globals["_PROCESSCONFIG_ENVSENTRY"]._serialized_options = b"8\001" + _globals["_SIGNAL"]._serialized_start = 2353 + _globals["_SIGNAL"]._serialized_end = 2425 + _globals["_PTY"]._serialized_start = 34 + _globals["_PTY"]._serialized_end = 126 + _globals["_PTY_SIZE"]._serialized_start = 80 + _globals["_PTY_SIZE"]._serialized_end = 126 + _globals["_PROCESSCONFIG"]._serialized_start = 129 + _globals["_PROCESSCONFIG"]._serialized_end = 324 + _globals["_PROCESSCONFIG_ENVSENTRY"]._serialized_start = 261 + _globals["_PROCESSCONFIG_ENVSENTRY"]._serialized_end = 316 + _globals["_LISTREQUEST"]._serialized_start = 326 + _globals["_LISTREQUEST"]._serialized_end = 339 + _globals["_PROCESSINFO"]._serialized_start = 341 + _globals["_PROCESSINFO"]._serialized_end = 451 + _globals["_LISTRESPONSE"]._serialized_start = 453 + _globals["_LISTRESPONSE"]._serialized_end = 519 + _globals["_STARTREQUEST"]._serialized_start = 522 + _globals["_STARTREQUEST"]._serialized_end = 699 + _globals["_UPDATEREQUEST"]._serialized_start = 701 + _globals["_UPDATEREQUEST"]._serialized_end = 813 + _globals["_UPDATERESPONSE"]._serialized_start = 815 + _globals["_UPDATERESPONSE"]._serialized_end = 831 + _globals["_PROCESSEVENT"]._serialized_start = 834 + _globals["_PROCESSEVENT"]._serialized_end = 1353 + _globals["_PROCESSEVENT_STARTEVENT"]._serialized_start = 1080 + _globals["_PROCESSEVENT_STARTEVENT"]._serialized_end = 1110 + _globals["_PROCESSEVENT_DATAEVENT"]._serialized_start = 1112 + _globals["_PROCESSEVENT_DATAEVENT"]._serialized_end = 1205 + _globals["_PROCESSEVENT_ENDEVENT"]._serialized_start = 1207 + _globals["_PROCESSEVENT_ENDEVENT"]._serialized_end = 1331 + _globals["_PROCESSEVENT_KEEPALIVE"]._serialized_start = 1333 + _globals["_PROCESSEVENT_KEEPALIVE"]._serialized_end = 1344 + _globals["_STARTRESPONSE"]._serialized_start = 1355 + _globals["_STARTRESPONSE"]._serialized_end = 1415 + _globals["_CONNECTRESPONSE"]._serialized_start = 1417 + _globals["_CONNECTRESPONSE"]._serialized_end = 1479 + _globals["_SENDINPUTREQUEST"]._serialized_start = 1481 + _globals["_SENDINPUTREQUEST"]._serialized_end = 1596 + _globals["_SENDINPUTRESPONSE"]._serialized_start = 1598 + _globals["_SENDINPUTRESPONSE"]._serialized_end = 1617 + _globals["_PROCESSINPUT"]._serialized_start = 1619 + _globals["_PROCESSINPUT"]._serialized_end = 1686 + _globals["_STREAMINPUTREQUEST"]._serialized_start = 1689 + _globals["_STREAMINPUTREQUEST"]._serialized_end = 2051 + _globals["_STREAMINPUTREQUEST_STARTEVENT"]._serialized_start = 1907 + _globals["_STREAMINPUTREQUEST_STARTEVENT"]._serialized_end = 1971 + _globals["_STREAMINPUTREQUEST_DATAEVENT"]._serialized_start = 1973 + _globals["_STREAMINPUTREQUEST_DATAEVENT"]._serialized_end = 2029 + _globals["_STREAMINPUTREQUEST_KEEPALIVE"]._serialized_start = 1333 + _globals["_STREAMINPUTREQUEST_KEEPALIVE"]._serialized_end = 1344 + _globals["_STREAMINPUTRESPONSE"]._serialized_start = 2053 + _globals["_STREAMINPUTRESPONSE"]._serialized_end = 2074 + _globals["_SENDSIGNALREQUEST"]._serialized_start = 2076 + _globals["_SENDSIGNALREQUEST"]._serialized_end = 2188 + _globals["_SENDSIGNALRESPONSE"]._serialized_start = 2190 + _globals["_SENDSIGNALRESPONSE"]._serialized_end = 2210 + _globals["_CONNECTREQUEST"]._serialized_start = 2212 + _globals["_CONNECTREQUEST"]._serialized_end = 2280 + _globals["_PROCESSSELECTOR"]._serialized_start = 2282 + _globals["_PROCESSSELECTOR"]._serialized_end = 2351 + _globals["_PROCESS"]._serialized_start = 2428 + _globals["_PROCESS"]._serialized_end = 2886 +# @@protoc_insertion_point(module_scope) diff --git a/ucloud_sandbox/envd/process/process_pb2.pyi b/ucloud_sandbox/envd/process/process_pb2.pyi new file mode 100644 index 0000000..b0b76c4 --- /dev/null +++ b/ucloud_sandbox/envd/process/process_pb2.pyi @@ -0,0 +1,304 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ( + ClassVar as _ClassVar, + Iterable as _Iterable, + Mapping as _Mapping, + Optional as _Optional, + Union as _Union, +) + +DESCRIPTOR: _descriptor.FileDescriptor + +class Signal(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SIGNAL_UNSPECIFIED: _ClassVar[Signal] + SIGNAL_SIGTERM: _ClassVar[Signal] + SIGNAL_SIGKILL: _ClassVar[Signal] + +SIGNAL_UNSPECIFIED: Signal +SIGNAL_SIGTERM: Signal +SIGNAL_SIGKILL: Signal + +class PTY(_message.Message): + __slots__ = ("size",) + class Size(_message.Message): + __slots__ = ("cols", "rows") + COLS_FIELD_NUMBER: _ClassVar[int] + ROWS_FIELD_NUMBER: _ClassVar[int] + cols: int + rows: int + def __init__( + self, cols: _Optional[int] = ..., rows: _Optional[int] = ... + ) -> None: ... + + SIZE_FIELD_NUMBER: _ClassVar[int] + size: PTY.Size + def __init__(self, size: _Optional[_Union[PTY.Size, _Mapping]] = ...) -> None: ... + +class ProcessConfig(_message.Message): + __slots__ = ("cmd", "args", "envs", "cwd") + class EnvsEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__( + self, key: _Optional[str] = ..., value: _Optional[str] = ... + ) -> None: ... + + CMD_FIELD_NUMBER: _ClassVar[int] + ARGS_FIELD_NUMBER: _ClassVar[int] + ENVS_FIELD_NUMBER: _ClassVar[int] + CWD_FIELD_NUMBER: _ClassVar[int] + cmd: str + args: _containers.RepeatedScalarFieldContainer[str] + envs: _containers.ScalarMap[str, str] + cwd: str + def __init__( + self, + cmd: _Optional[str] = ..., + args: _Optional[_Iterable[str]] = ..., + envs: _Optional[_Mapping[str, str]] = ..., + cwd: _Optional[str] = ..., + ) -> None: ... + +class ListRequest(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class ProcessInfo(_message.Message): + __slots__ = ("config", "pid", "tag") + CONFIG_FIELD_NUMBER: _ClassVar[int] + PID_FIELD_NUMBER: _ClassVar[int] + TAG_FIELD_NUMBER: _ClassVar[int] + config: ProcessConfig + pid: int + tag: str + def __init__( + self, + config: _Optional[_Union[ProcessConfig, _Mapping]] = ..., + pid: _Optional[int] = ..., + tag: _Optional[str] = ..., + ) -> None: ... + +class ListResponse(_message.Message): + __slots__ = ("processes",) + PROCESSES_FIELD_NUMBER: _ClassVar[int] + processes: _containers.RepeatedCompositeFieldContainer[ProcessInfo] + def __init__( + self, processes: _Optional[_Iterable[_Union[ProcessInfo, _Mapping]]] = ... + ) -> None: ... + +class StartRequest(_message.Message): + __slots__ = ("process", "pty", "tag", "stdin") + PROCESS_FIELD_NUMBER: _ClassVar[int] + PTY_FIELD_NUMBER: _ClassVar[int] + TAG_FIELD_NUMBER: _ClassVar[int] + STDIN_FIELD_NUMBER: _ClassVar[int] + process: ProcessConfig + pty: PTY + tag: str + stdin: bool + def __init__( + self, + process: _Optional[_Union[ProcessConfig, _Mapping]] = ..., + pty: _Optional[_Union[PTY, _Mapping]] = ..., + tag: _Optional[str] = ..., + stdin: bool = ..., + ) -> None: ... + +class UpdateRequest(_message.Message): + __slots__ = ("process", "pty") + PROCESS_FIELD_NUMBER: _ClassVar[int] + PTY_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + pty: PTY + def __init__( + self, + process: _Optional[_Union[ProcessSelector, _Mapping]] = ..., + pty: _Optional[_Union[PTY, _Mapping]] = ..., + ) -> None: ... + +class UpdateResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class ProcessEvent(_message.Message): + __slots__ = ("start", "data", "end", "keepalive") + class StartEvent(_message.Message): + __slots__ = ("pid",) + PID_FIELD_NUMBER: _ClassVar[int] + pid: int + def __init__(self, pid: _Optional[int] = ...) -> None: ... + + class DataEvent(_message.Message): + __slots__ = ("stdout", "stderr", "pty") + STDOUT_FIELD_NUMBER: _ClassVar[int] + STDERR_FIELD_NUMBER: _ClassVar[int] + PTY_FIELD_NUMBER: _ClassVar[int] + stdout: bytes + stderr: bytes + pty: bytes + def __init__( + self, + stdout: _Optional[bytes] = ..., + stderr: _Optional[bytes] = ..., + pty: _Optional[bytes] = ..., + ) -> None: ... + + class EndEvent(_message.Message): + __slots__ = ("exit_code", "exited", "status", "error") + EXIT_CODE_FIELD_NUMBER: _ClassVar[int] + EXITED_FIELD_NUMBER: _ClassVar[int] + STATUS_FIELD_NUMBER: _ClassVar[int] + ERROR_FIELD_NUMBER: _ClassVar[int] + exit_code: int + exited: bool + status: str + error: str + def __init__( + self, + exit_code: _Optional[int] = ..., + exited: bool = ..., + status: _Optional[str] = ..., + error: _Optional[str] = ..., + ) -> None: ... + + class KeepAlive(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + + START_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + END_FIELD_NUMBER: _ClassVar[int] + KEEPALIVE_FIELD_NUMBER: _ClassVar[int] + start: ProcessEvent.StartEvent + data: ProcessEvent.DataEvent + end: ProcessEvent.EndEvent + keepalive: ProcessEvent.KeepAlive + def __init__( + self, + start: _Optional[_Union[ProcessEvent.StartEvent, _Mapping]] = ..., + data: _Optional[_Union[ProcessEvent.DataEvent, _Mapping]] = ..., + end: _Optional[_Union[ProcessEvent.EndEvent, _Mapping]] = ..., + keepalive: _Optional[_Union[ProcessEvent.KeepAlive, _Mapping]] = ..., + ) -> None: ... + +class StartResponse(_message.Message): + __slots__ = ("event",) + EVENT_FIELD_NUMBER: _ClassVar[int] + event: ProcessEvent + def __init__( + self, event: _Optional[_Union[ProcessEvent, _Mapping]] = ... + ) -> None: ... + +class ConnectResponse(_message.Message): + __slots__ = ("event",) + EVENT_FIELD_NUMBER: _ClassVar[int] + event: ProcessEvent + def __init__( + self, event: _Optional[_Union[ProcessEvent, _Mapping]] = ... + ) -> None: ... + +class SendInputRequest(_message.Message): + __slots__ = ("process", "input") + PROCESS_FIELD_NUMBER: _ClassVar[int] + INPUT_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + input: ProcessInput + def __init__( + self, + process: _Optional[_Union[ProcessSelector, _Mapping]] = ..., + input: _Optional[_Union[ProcessInput, _Mapping]] = ..., + ) -> None: ... + +class SendInputResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class ProcessInput(_message.Message): + __slots__ = ("stdin", "pty") + STDIN_FIELD_NUMBER: _ClassVar[int] + PTY_FIELD_NUMBER: _ClassVar[int] + stdin: bytes + pty: bytes + def __init__( + self, stdin: _Optional[bytes] = ..., pty: _Optional[bytes] = ... + ) -> None: ... + +class StreamInputRequest(_message.Message): + __slots__ = ("start", "data", "keepalive") + class StartEvent(_message.Message): + __slots__ = ("process",) + PROCESS_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + def __init__( + self, process: _Optional[_Union[ProcessSelector, _Mapping]] = ... + ) -> None: ... + + class DataEvent(_message.Message): + __slots__ = ("input",) + INPUT_FIELD_NUMBER: _ClassVar[int] + input: ProcessInput + def __init__( + self, input: _Optional[_Union[ProcessInput, _Mapping]] = ... + ) -> None: ... + + class KeepAlive(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + + START_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + KEEPALIVE_FIELD_NUMBER: _ClassVar[int] + start: StreamInputRequest.StartEvent + data: StreamInputRequest.DataEvent + keepalive: StreamInputRequest.KeepAlive + def __init__( + self, + start: _Optional[_Union[StreamInputRequest.StartEvent, _Mapping]] = ..., + data: _Optional[_Union[StreamInputRequest.DataEvent, _Mapping]] = ..., + keepalive: _Optional[_Union[StreamInputRequest.KeepAlive, _Mapping]] = ..., + ) -> None: ... + +class StreamInputResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class SendSignalRequest(_message.Message): + __slots__ = ("process", "signal") + PROCESS_FIELD_NUMBER: _ClassVar[int] + SIGNAL_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + signal: Signal + def __init__( + self, + process: _Optional[_Union[ProcessSelector, _Mapping]] = ..., + signal: _Optional[_Union[Signal, str]] = ..., + ) -> None: ... + +class SendSignalResponse(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class ConnectRequest(_message.Message): + __slots__ = ("process",) + PROCESS_FIELD_NUMBER: _ClassVar[int] + process: ProcessSelector + def __init__( + self, process: _Optional[_Union[ProcessSelector, _Mapping]] = ... + ) -> None: ... + +class ProcessSelector(_message.Message): + __slots__ = ("pid", "tag") + PID_FIELD_NUMBER: _ClassVar[int] + TAG_FIELD_NUMBER: _ClassVar[int] + pid: int + tag: str + def __init__( + self, pid: _Optional[int] = ..., tag: _Optional[str] = ... + ) -> None: ... diff --git a/ucloud_sandbox/envd/rpc.py b/ucloud_sandbox/envd/rpc.py new file mode 100644 index 0000000..81df92d --- /dev/null +++ b/ucloud_sandbox/envd/rpc.py @@ -0,0 +1,61 @@ +import base64 + +from typing import Optional +from packaging.version import Version +from e2b_connect.client import Code, ConnectException + +from ucloud_agentbox.exceptions import ( + SandboxException, + InvalidArgumentException, + NotFoundException, + TimeoutException, + format_sandbox_timeout_exception, + AuthenticationException, + RateLimitException, +) +from ucloud_agentbox.connection_config import Username, default_username +from ucloud_agentbox.envd.versions import ENVD_DEFAULT_USER + + +def handle_rpc_exception(e: Exception): + if isinstance(e, ConnectException): + if e.status == Code.invalid_argument: + return InvalidArgumentException(e.message) + elif e.status == Code.unauthenticated: + return AuthenticationException(e.message) + elif e.status == Code.not_found: + return NotFoundException(e.message) + elif e.status == Code.unavailable: + return format_sandbox_timeout_exception(e.message) + elif e.status == Code.resource_exhausted: + return RateLimitException( + f"{e.message}: Rate limit exceeded, please try again later." + ) + elif e.status == Code.canceled: + return TimeoutException( + f"{e.message}: This error is likely due to exceeding 'request_timeout'. You can pass the request timeout value as an option when making the request." + ) + elif e.status == Code.deadline_exceeded: + return TimeoutException( + f"{e.message}: This error is likely due to exceeding 'timeout' — the total time a long running request (like process or directory watch) can be active. It can be modified by passing 'timeout' when making the request. Use '0' to disable the timeout." + ) + else: + return SandboxException(f"{e.status}: {e.message}") + else: + return e + + +def authentication_header( + envd_version: Version, user: Optional[Username] = None +) -> dict[str, str]: + if user is None and envd_version < ENVD_DEFAULT_USER: + user = default_username + + if not user: + return {} + + value = f"{user}:" + + encoded = base64.b64encode(value.encode("utf-8")).decode("utf-8") + + return {"Authorization": f"Basic {encoded}"} diff --git a/ucloud_sandbox/envd/versions.py b/ucloud_sandbox/envd/versions.py new file mode 100644 index 0000000..965e7f8 --- /dev/null +++ b/ucloud_sandbox/envd/versions.py @@ -0,0 +1,6 @@ +from packaging.version import Version + +ENVD_VERSION_RECURSIVE_WATCH = Version("0.1.4") +ENVD_DEBUG_FALLBACK = Version("99.99.99") +ENVD_COMMANDS_STDIN = Version("0.3.0") +ENVD_DEFAULT_USER = Version("0.4.0") diff --git a/ucloud_sandbox/exceptions.py b/ucloud_sandbox/exceptions.py new file mode 100644 index 0000000..ec75a99 --- /dev/null +++ b/ucloud_sandbox/exceptions.py @@ -0,0 +1,95 @@ +def format_sandbox_timeout_exception(message: str): + return TimeoutException( + f"{message}: This error is likely due to sandbox timeout. You can modify the sandbox timeout by passing 'timeout' when starting the sandbox or calling '.set_timeout' on the sandbox with the desired timeout." + ) + + +def format_request_timeout_error() -> Exception: + return TimeoutException( + "Request timed out — the 'request_timeout' option can be used to increase this timeout", + ) + + +def format_execution_timeout_error() -> Exception: + return TimeoutException( + "Execution timed out — the 'timeout' option can be used to increase this timeout", + ) + + +class SandboxException(Exception): + """ + Base class for all sandbox errors. + + Raised when a general sandbox exception occurs. + """ + + pass + + +class TimeoutException(SandboxException): + """ + Raised when a timeout occurs. + + The `unavailable` exception type is caused by sandbox timeout.\n + The `canceled` exception type is caused by exceeding request timeout.\n + The `deadline_exceeded` exception type is caused by exceeding the timeout for process, watch, etc.\n + The `unknown` exception type is sometimes caused by the sandbox timeout when the request is not processed correctly.\n + """ + + pass + + +class InvalidArgumentException(SandboxException): + """ + Raised when an invalid argument is provided. + """ + + pass + + +class NotEnoughSpaceException(SandboxException): + """ + Raised when there is not enough disk space. + """ + + pass + + +class NotFoundException(SandboxException): + """ + Raised when a resource is not found. + """ + + pass + + +class AuthenticationException(Exception): + """ + Raised when authentication fails. + """ + + pass + + +class TemplateException(SandboxException): + """ + Exception raised when the template uses old envd version. It isn't compatible with the new SDK. + """ + + +class RateLimitException(SandboxException): + """ + Raised when the API rate limit is exceeded. + """ + + +class BuildException(Exception): + """ + Raised when the build fails. + """ + + +class FileUploadException(BuildException): + """ + Raised when the file upload fails. + """ diff --git a/ucloud_sandbox/sandbox/commands/command_handle.py b/ucloud_sandbox/sandbox/commands/command_handle.py new file mode 100644 index 0000000..f44f491 --- /dev/null +++ b/ucloud_sandbox/sandbox/commands/command_handle.py @@ -0,0 +1,69 @@ +from dataclasses import dataclass +from typing import Optional + +from ucloud_agentbox.exceptions import SandboxException + +Stdout = str +""" +Command stdout output. +""" +Stderr = str +""" +Command stderr output. +""" +PtyOutput = bytes +""" +Pty output. +""" + + +@dataclass +class PtySize: + """ + Pseudo-terminal size. + """ + + rows: int + """ + Number of rows. + """ + cols: int + """ + Number of columns. + """ + + +@dataclass +class CommandResult: + """ + Command execution result. + """ + + stderr: str + """ + Command stderr output. + """ + stdout: str + """ + Command stdout output. + """ + exit_code: int + """ + Command exit code. + + `0` if the command finished successfully. + """ + error: Optional[str] + """ + Error message from command execution if it failed. + """ + + +@dataclass +class CommandExitException(SandboxException, CommandResult): + """ + Exception raised when a command exits with a non-zero exit code. + """ + + def __str__(self): + return f"Command exited with code {self.exit_code} and error:\n{self.stderr}" diff --git a/ucloud_sandbox/sandbox/commands/main.py b/ucloud_sandbox/sandbox/commands/main.py new file mode 100644 index 0000000..67d52ed --- /dev/null +++ b/ucloud_sandbox/sandbox/commands/main.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from typing import Dict, List, Optional + + +@dataclass +class ProcessInfo: + """ + Information about a command, PTY session or start command running in the sandbox as process. + """ + + pid: int + """ + Process ID. + """ + + tag: Optional[str] + """ + Custom tag used for identifying special commands like start command in the custom template. + """ + + cmd: str + """ + Command that was executed. + """ + + args: List[str] + """ + Command arguments. + """ + + envs: Dict[str, str] + """ + Environment variables used for the command. + """ + + cwd: Optional[str] + """ + Executed command working directory. + """ diff --git a/ucloud_sandbox/sandbox/filesystem/filesystem.py b/ucloud_sandbox/sandbox/filesystem/filesystem.py new file mode 100644 index 0000000..87b2eee --- /dev/null +++ b/ucloud_sandbox/sandbox/filesystem/filesystem.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import IO, Optional, Union, TypedDict + +from ucloud_agentbox.envd.filesystem import filesystem_pb2 + + +class FileType(Enum): + """ + Enum representing the type of filesystem object. + """ + + FILE = "file" + """ + Filesystem object is a file. + """ + DIR = "dir" + """ + Filesystem object is a directory. + """ + + +def map_file_type(ft: filesystem_pb2.FileType): + if ft == filesystem_pb2.FileType.FILE_TYPE_FILE: + return FileType.FILE + elif ft == filesystem_pb2.FileType.FILE_TYPE_DIRECTORY: + return FileType.DIR + + +@dataclass +class WriteInfo: + """ + Sandbox filesystem object information. + """ + + name: str + """ + Name of the filesystem object. + """ + type: Optional[FileType] + """ + Type of the filesystem object. + """ + path: str + """ + Path to the filesystem object. + """ + + +@dataclass +class EntryInfo(WriteInfo): + """ + Extended sandbox filesystem object information. + """ + + size: int + """ + Size of the filesystem object in bytes. + """ + mode: int + """ + File mode and permission bits. + """ + permissions: str + """ + String representation of file permissions (e.g. 'rwxr-xr-x'). + """ + owner: str + """ + Owner of the filesystem object. + """ + group: str + """ + Group owner of the filesystem object. + """ + modified_time: datetime + """ + Last modification time of the filesystem object. + """ + symlink_target: Optional[str] = None + """ + Target of the symlink if the filesystem object is a symlink. + If the filesystem object is not a symlink, this field is None. + """ + + +class WriteEntry(TypedDict): + """ + Contains path and data of the file to be written to the filesystem. + """ + + path: str + data: Union[str, bytes, IO] diff --git a/ucloud_sandbox/sandbox/filesystem/watch_handle.py b/ucloud_sandbox/sandbox/filesystem/watch_handle.py new file mode 100644 index 0000000..dc91152 --- /dev/null +++ b/ucloud_sandbox/sandbox/filesystem/watch_handle.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from enum import Enum + +from ucloud_agentbox.envd.filesystem.filesystem_pb2 import EventType + + +class FilesystemEventType(Enum): + """ + Enum representing the type of filesystem event. + """ + + CHMOD = "chmod" + """ + Filesystem object permissions were changed. + """ + CREATE = "create" + """ + Filesystem object was created. + """ + REMOVE = "remove" + """ + Filesystem object was removed. + """ + RENAME = "rename" + """ + Filesystem object was renamed. + """ + WRITE = "write" + """ + Filesystem object was written to. + """ + + +def map_event_type(event: EventType): + if event == EventType.EVENT_TYPE_CHMOD: + return FilesystemEventType.CHMOD + elif event == EventType.EVENT_TYPE_CREATE: + return FilesystemEventType.CREATE + elif event == EventType.EVENT_TYPE_REMOVE: + return FilesystemEventType.REMOVE + elif event == EventType.EVENT_TYPE_RENAME: + return FilesystemEventType.RENAME + elif event == EventType.EVENT_TYPE_WRITE: + return FilesystemEventType.WRITE + + +@dataclass +class FilesystemEvent: + """ + Contains information about the filesystem event - the name of the file and the type of the event. + """ + + name: str + """ + Relative path to the filesystem object. + """ + type: FilesystemEventType + """ + Filesystem operation event type. + """ diff --git a/ucloud_sandbox/sandbox/main.py b/ucloud_sandbox/sandbox/main.py new file mode 100644 index 0000000..bb3f695 --- /dev/null +++ b/ucloud_sandbox/sandbox/main.py @@ -0,0 +1,194 @@ +import urllib.parse +from typing import Optional, TypedDict + +from packaging.version import Version + +from ucloud_agentbox.connection_config import ConnectionConfig, default_username +from ucloud_agentbox.envd.api import ENVD_API_FILES_ROUTE +from ucloud_agentbox.envd.versions import ENVD_DEFAULT_USER +from ucloud_agentbox.sandbox.signature import get_signature + + +class SandboxOpts(TypedDict): + sandbox_id: str + sandbox_domain: Optional[str] + envd_version: Version + envd_access_token: Optional[str] + sandbox_url: Optional[str] + traffic_access_token: Optional[str] + connection_config: ConnectionConfig + + +class SandboxBase: + default_sandbox_timeout = 300 + + default_template = "base" + + def __init__( + self, + sandbox_id: str, + envd_version: Version, + envd_access_token: Optional[str], + sandbox_domain: Optional[str], + connection_config: ConnectionConfig, + traffic_access_token: Optional[str] = None, + ): + self.__connection_config = connection_config + self.__sandbox_id = sandbox_id + self.__sandbox_domain = sandbox_domain or self.connection_config.domain + self.__envd_version = envd_version + self.__envd_access_token = envd_access_token + self.__traffic_access_token = traffic_access_token + self.__envd_api_url = self.connection_config.get_sandbox_url( + self.sandbox_id, self.sandbox_domain + ) + + @property + def _envd_access_token(self) -> Optional[str]: + """Private property to access the envd token""" + return self.__envd_access_token + + + + @property + def connection_config(self) -> ConnectionConfig: + return self.__connection_config + + @property + def _envd_version(self) -> Version: + return self.__envd_version + + @property + def traffic_access_token(self) -> Optional[str]: + return self.__traffic_access_token + + @property + def sandbox_domain(self) -> Optional[str]: + return self.__sandbox_domain + + @property + def envd_api_url(self) -> str: + return self.__envd_api_url + + @property + def sandbox_id(self) -> str: + """ + Unique identifier of the sandbox. + """ + return self.__sandbox_id + + def _file_url( + self, + path: str, + user: Optional[str] = None, + signature: Optional[str] = None, + signature_expiration: Optional[int] = None, + ) -> str: + url = urllib.parse.urljoin(self.envd_api_url, ENVD_API_FILES_ROUTE) + query = {"path": path} if path else {} + + if user: + query["username"] = user + + if signature: + query["signature"] = signature + + if signature_expiration: + if signature is None: + raise ValueError("signature_expiration requires signature to be set") + query["signature_expiration"] = str(signature_expiration) + + params = urllib.parse.urlencode( + query, + quote_via=urllib.parse.quote, + ) + url = urllib.parse.urljoin(url, f"?{params}") + + return url + + def download_url( + self, + path: str, + user: Optional[str] = None, + use_signature_expiration: Optional[int] = None, + ) -> str: + """ + Get the URL to download a file from the sandbox. + + :param path: Path to the file to download + :param user: User to download the file as + :param use_signature_expiration: Expiration time for the signed URL in seconds + + :return: URL for downloading file + """ + + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + use_signature = self._envd_access_token is not None + if use_signature: + signature = get_signature( + path, + "read", + username, + self._envd_access_token, + use_signature_expiration, + ) + return self._file_url( + path, username, signature["signature"], signature["expiration"] + ) + else: + return self._file_url(path, username) + + def upload_url( + self, + path: str, + user: Optional[str] = None, + use_signature_expiration: Optional[int] = None, + ) -> str: + """ + Get the URL to upload a file to the sandbox. + + You have to send a POST request to this URL with the file as multipart/form-data. + + :param path: Path to the file to upload + :param user: User to upload the file as + :param use_signature_expiration: Expiration time for the signed URL in seconds + + :return: URL for uploading file + """ + + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + use_signature = self._envd_access_token is not None + if use_signature: + signature = get_signature( + path, + "write", + username, + self._envd_access_token, + use_signature_expiration, + ) + return self._file_url( + path, username, signature["signature"], signature["expiration"] + ) + else: + return self._file_url(path, username) + + def get_host(self, port: int) -> str: + """ + Get the host address to connect to the sandbox. + You can then use this address to connect to the sandbox port from outside the sandbox via HTTP or WebSocket. + + :param port: Port to connect to + + :return: Host address to connect to + """ + return self.connection_config.get_host( + self.sandbox_id, self.sandbox_domain, port + ) + + diff --git a/ucloud_sandbox/sandbox/network.py b/ucloud_sandbox/sandbox/network.py new file mode 100644 index 0000000..0765c58 --- /dev/null +++ b/ucloud_sandbox/sandbox/network.py @@ -0,0 +1,8 @@ +""" +Network configuration helpers for AgentBox sandboxes. +""" + +""" +CIDR range that represents all traffic. +""" +ALL_TRAFFIC = "0.0.0.0/0" diff --git a/ucloud_sandbox/sandbox/sandbox_api.py b/ucloud_sandbox/sandbox/sandbox_api.py new file mode 100644 index 0000000..518e7aa --- /dev/null +++ b/ucloud_sandbox/sandbox/sandbox_api.py @@ -0,0 +1,181 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, List, Optional, TypedDict, Union + +from typing_extensions import NotRequired, Unpack + +from ucloud_agentbox.connection_config import ConnectionConfig +from ucloud_agentbox.api.client.models import ListedSandbox, SandboxDetail, SandboxState +from ucloud_agentbox.connection_config import ApiParams + + +class SandboxNetworkOpts(TypedDict): + """ + Sandbox network configuration options. + """ + + allow_out: NotRequired[List[str]] + """ + Allow outbound traffic from the sandbox to the specified addresses. + If `allow_out` is not specified, all outbound traffic is allowed. + + Examples: + - To allow traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + """ + + deny_out: NotRequired[List[str]] + """ + Deny outbound traffic from the sandbox to the specified addresses. + + Examples: + - To deny traffic to specific addresses: `["1.1.1.1", "8.8.8.0/24"]` + """ + + allow_public_traffic: NotRequired[bool] + """ + Controls whether sandbox URLs should be publicly accessible or require authentication. + Defaults to True. + """ + + mask_request_host: NotRequired[str] + """ + Allows specifying a custom host mask for all sandbox requests. + Supports ${PORT} variable. Defaults to "${PORT}-sandboxid.sandbox.ucloudai.com". + + Examples: + - Custom subdomain: `"${PORT}-myapp.example.com"` + """ + + +@dataclass +class SandboxInfo: + """Information about a sandbox.""" + + sandbox_id: str + """Sandbox ID.""" + sandbox_domain: Optional[str] + """Domain where the sandbox is hosted.""" + template_id: str + """Template ID.""" + name: Optional[str] + """Template name.""" + metadata: Dict[str, str] + """Saved sandbox metadata.""" + started_at: datetime + """Sandbox start time.""" + end_at: datetime + """Sandbox expiration date.""" + state: SandboxState + """Sandbox state.""" + cpu_count: int + """Sandbox CPU count.""" + memory_mb: int + """Sandbox Memory size in MiB.""" + envd_version: str + """Envd version.""" + _envd_access_token: Optional[str] + """Envd access token.""" + + @classmethod + def _from_sandbox_data( + cls, + sandbox: Union[ListedSandbox, SandboxDetail], + envd_access_token: Optional[str] = None, + sandbox_domain: Optional[str] = None, + ): + return cls( + sandbox_domain=sandbox_domain, + sandbox_id=sandbox.sandbox_id, + template_id=sandbox.template_id, + name=(sandbox.alias if isinstance(sandbox.alias, str) else None), + metadata=(sandbox.metadata if isinstance(sandbox.metadata, dict) else {}), + started_at=sandbox.started_at, + end_at=sandbox.end_at, + state=sandbox.state, + cpu_count=sandbox.cpu_count, + memory_mb=sandbox.memory_mb, + envd_version=sandbox.envd_version, + _envd_access_token=envd_access_token, + ) + + @classmethod + def _from_listed_sandbox(cls, listed_sandbox: ListedSandbox): + return cls._from_sandbox_data(listed_sandbox) + + @classmethod + def _from_sandbox_detail(cls, sandbox_detail: SandboxDetail): + return cls._from_sandbox_data( + sandbox_detail, + ( + sandbox_detail.envd_access_token + if isinstance(sandbox_detail.envd_access_token, str) + else None + ), + sandbox_domain=( + sandbox_detail.domain + if isinstance(sandbox_detail.domain, str) + else None + ), + ) + + +@dataclass +class SandboxQuery: + """Query parameters for listing sandboxes.""" + + metadata: Optional[dict[str, str]] = None + """Filter sandboxes by metadata.""" + + state: Optional[list[SandboxState]] = None + """Filter sandboxes by state.""" + + +@dataclass +class SandboxMetrics: + """Sandbox metrics.""" + + cpu_count: int + """Number of CPUs.""" + cpu_used_pct: float + """CPU usage percentage.""" + disk_total: int + """Total disk space in bytes.""" + disk_used: int + """Disk used in bytes.""" + mem_total: int + """Total memory in bytes.""" + mem_used: int + """Memory used in bytes.""" + timestamp: datetime + """Timestamp of the metric entry.""" + + +class SandboxPaginatorBase: + def __init__( + self, + query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ): + self._config = ConnectionConfig(**opts) + + self.query = query + self.limit = limit + + self._has_next = True + self._next_token = next_token + + @property + def has_next(self) -> bool: + """ + Returns True if there are more items to fetch. + """ + return self._has_next + + @property + def next_token(self) -> Optional[str]: + """ + Returns the next token to use for pagination. + """ + return self._next_token diff --git a/ucloud_sandbox/sandbox/signature.py b/ucloud_sandbox/sandbox/signature.py new file mode 100644 index 0000000..d00fb4e --- /dev/null +++ b/ucloud_sandbox/sandbox/signature.py @@ -0,0 +1,45 @@ +import base64 +import hashlib +import time + +from typing import Optional, TypedDict, Literal + +Operation = Literal["read", "write"] + + +class Signature(TypedDict): + signature: str + expiration: Optional[int] # Unix timestamp or None + + +def get_signature( + path: str, + operation: Operation, + user: Optional[str], + envd_access_token: Optional[str], + expiration_in_seconds: Optional[int] = None, +) -> Signature: + """ + Generate a v1 signature for sandbox file URLs. + """ + if not envd_access_token: + raise ValueError("Access token is not set and signature cannot be generated!") + + expiration = ( + int(time.time()) + expiration_in_seconds if expiration_in_seconds else None + ) + + # if user is None, set it to empty string to handle default user + if user is None: + user = "" + + raw = ( + f"{path}:{operation}:{user}:{envd_access_token}" + if expiration is None + else f"{path}:{operation}:{user}:{envd_access_token}:{expiration}" + ) + + digest = hashlib.sha256(raw.encode("utf-8")).digest() + encoded = base64.b64encode(digest).rstrip(b"=").decode("ascii") + + return {"signature": f"v1_{encoded}", "expiration": expiration} diff --git a/ucloud_sandbox/sandbox/utils.py b/ucloud_sandbox/sandbox/utils.py new file mode 100644 index 0000000..daf9d4a --- /dev/null +++ b/ucloud_sandbox/sandbox/utils.py @@ -0,0 +1,34 @@ +from typing import TypeVar, Any, cast, Optional, Type +import functools + +T = TypeVar("T") + + +class class_method_variant(object): + def __init__(self, class_method_name): + self.class_method_name = class_method_name + + method: Any + + def __call__(self, method: T) -> T: + self.method = method + return cast(T, self) + + def __get__(self, obj, objtype: Optional[Type[Any]] = None): + @functools.wraps(self.method) + def _wrapper(*args, **kwargs): + if obj is not None: + # Method was called as an instance method, e.g. + # instance.method(...) + return self.method(obj, *args, **kwargs) + elif len(args) > 0 and objtype is not None and isinstance(args[0], objtype): + # Method was called as a class method with the instance as the + # first argument, e.g. Class.method(instance, ...) which in + # Python is the same thing as calling an instance method + return self.method(args[0], *args[1:], **kwargs) + else: + # Method was called as a class method, e.g. Class.method(...) + class_method = getattr(objtype, self.class_method_name) + return class_method(*args, **kwargs) + + return _wrapper diff --git a/ucloud_sandbox/sandbox_async/commands/command.py b/ucloud_sandbox/sandbox_async/commands/command.py new file mode 100644 index 0000000..d11d11c --- /dev/null +++ b/ucloud_sandbox/sandbox_async/commands/command.py @@ -0,0 +1,336 @@ +from typing import Dict, List, Literal, Optional, Union, overload + +import e2b_connect +import httpcore +from packaging.version import Version +from ucloud_agentbox.connection_config import ( + ConnectionConfig, + Username, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from ucloud_agentbox.envd.process import process_connect, process_pb2 +from ucloud_agentbox.envd.rpc import authentication_header, handle_rpc_exception +from ucloud_agentbox.envd.versions import ENVD_COMMANDS_STDIN +from ucloud_agentbox.exceptions import SandboxException +from ucloud_agentbox.sandbox.commands.main import ProcessInfo +from ucloud_agentbox.sandbox.commands.command_handle import CommandResult +from ucloud_agentbox.sandbox_async.commands.command_handle import AsyncCommandHandle, Stderr, Stdout +from ucloud_agentbox.sandbox_async.utils import OutputHandler + + +class Commands: + """ + Module for executing commands in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + connection_config: ConnectionConfig, + pool: httpcore.AsyncConnectionPool, + envd_version: Version, + ) -> None: + self._connection_config = connection_config + self._envd_version = envd_version + self._rpc = process_connect.ProcessClient( + envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + async_pool=pool, + json=True, + headers=connection_config.sandbox_headers, + ) + + async def list( + self, + request_timeout: Optional[float] = None, + ) -> List[ProcessInfo]: + """ + Lists all running commands and PTY sessions. + + :param request_timeout: Timeout for the request in **seconds** + + :return: List of running commands and PTY sessions + """ + try: + res = await self._rpc.alist( + process_pb2.ListRequest(), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return [ + ProcessInfo( + pid=p.pid, + tag=p.tag, + cmd=p.config.cmd, + args=list(p.config.args), + envs=dict(p.config.envs), + cwd=p.config.cwd, + ) + for p in res.processes + ] + except Exception as e: + raise handle_rpc_exception(e) + + async def kill( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Kill a running command specified by its process ID. + It uses `SIGKILL` signal to kill the command. + + :param pid: Process ID of the command. You can get the list of processes using `sandbox.commands.list()` + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the command was killed, `False` if the command was not found + """ + try: + await self._rpc.asend_signal( + process_pb2.SendSignalRequest( + process=process_pb2.ProcessSelector(pid=pid), + signal=process_pb2.Signal.SIGNAL_SIGKILL, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise handle_rpc_exception(e) + + async def send_stdin( + self, + pid: int, + data: str, + request_timeout: Optional[float] = None, + ) -> None: + """ + Send data to command stdin. + + :param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`. + :param data: Data to send to the command + :param request_timeout: Timeout for the request in **seconds** + """ + try: + await self._rpc.asend_input( + process_pb2.SendInputRequest( + process=process_pb2.ProcessSelector(pid=pid), + input=process_pb2.ProcessInput( + stdin=data.encode(), + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise handle_rpc_exception(e) + + @overload + async def run( + self, + cmd: str, + background: Union[Literal[False], None] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> CommandResult: + """ + Start a new command and wait until it finishes executing. + + :param cmd: Command to execute + :param background: **`False` if the command should be executed in the foreground**, `True` if the command should be executed in the background + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param on_stdout: Callback for command stdout output + :param on_stderr: Callback for command stderr output + :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()` + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `CommandResult` result of the command execution + """ + ... + + @overload + async def run( + self, + cmd: str, + background: Literal[True], + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> AsyncCommandHandle: + """ + Start a new command and return a handle to interact with it. + + :param cmd: Command to execute + :param background: `False` if the command should be executed in the foreground, **`True` if the command should be executed in the background** + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param on_stdout: Callback for command stdout output + :param on_stderr: Callback for command stderr output + :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()` + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `AsyncCommandHandle` handle to interact with the running command + """ + ... + + async def run( + self, + cmd: str, + background: Union[bool, None] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ): + # Check version for stdin support + if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN: + raise SandboxException( + f"Sandbox envd version {self._envd_version} can't specify stdin, it's always turned on. " + f"Please rebuild your template if you need this feature." + ) + + # Default to `False` + stdin = stdin or False + + proc = await self._start( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + stdin, + on_stdout=on_stdout, + on_stderr=on_stderr, + ) + + return proc if background else await proc.wait() + + async def _start( + self, + cmd: str, + envs: Optional[Dict[str, str]], + user: Username, + cwd: Optional[str], + timeout: Optional[float], + request_timeout: Optional[float], + stdin: bool, + on_stdout: Optional[OutputHandler[Stdout]], + on_stderr: Optional[OutputHandler[Stderr]], + ) -> AsyncCommandHandle: + events = self._rpc.astart( + process_pb2.StartRequest( + process=process_pb2.ProcessConfig( + cmd="/bin/bash", + envs=envs, + args=["-l", "-c", cmd], + cwd=cwd, + ), + stdin=stdin, + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = await events.__anext__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to start process: expected start event, got {start_event}" + ) + + return AsyncCommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + on_stdout=on_stdout, + on_stderr=on_stderr, + ) + except Exception as e: + raise handle_rpc_exception(e) + + async def connect( + self, + pid: int, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + ) -> AsyncCommandHandle: + """ + Connects to a running command. + You can use `AsyncCommandHandle.wait()` to wait for the command to finish and get execution results. + + :param pid: Process ID of the command to connect to. You can get the list of processes using `sandbox.commands.list()` + :param request_timeout: Request timeout in **seconds** + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param on_stdout: Callback for command stdout output + :param on_stderr: Callback for command stderr output + + :return: `AsyncCommandHandle` handle to interact with the running command + """ + events = self._rpc.aconnect( + process_pb2.ConnectRequest( + process=process_pb2.ProcessSelector(pid=pid), + ), + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers={ + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + ) + + try: + start_event = await events.__anext__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to connect to process: expected start event, got {start_event}" + ) + + return AsyncCommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + on_stdout=on_stdout, + on_stderr=on_stderr, + ) + except Exception as e: + raise handle_rpc_exception(e) diff --git a/ucloud_sandbox/sandbox_async/commands/command_handle.py b/ucloud_sandbox/sandbox_async/commands/command_handle.py new file mode 100644 index 0000000..57191f5 --- /dev/null +++ b/ucloud_sandbox/sandbox_async/commands/command_handle.py @@ -0,0 +1,196 @@ +import asyncio +import inspect +from typing import ( + Optional, + Callable, + Any, + AsyncGenerator, + Union, + Tuple, + Coroutine, +) + +from ucloud_agentbox.envd.rpc import handle_rpc_exception +from ucloud_agentbox.envd.process import process_pb2 +from ucloud_agentbox.sandbox.commands.command_handle import ( + CommandExitException, + CommandResult, + Stderr, + Stdout, + PtyOutput, +) +from ucloud_agentbox.sandbox_async.utils import OutputHandler + + +class AsyncCommandHandle: + """ + Command execution handle. + + It provides methods for waiting for the command to finish, retrieving stdout/stderr, and killing the command. + """ + + @property + def pid(self): + """ + Command process ID. + """ + return self._pid + + @property + def stdout(self): + """ + Command stdout output. + """ + return self._stdout + + @property + def stderr(self): + """ + Command stderr output. + """ + return self._stderr + + @property + def error(self): + """ + Command execution error message. + """ + if self._result is None: + return None + return self._result.error + + @property + def exit_code(self): + """ + Command execution exit code. + + `0` if the command finished successfully. + + It is `None` if the command is still running. + """ + if self._result is None: + return None + return self._result.exit_code + + def __init__( + self, + pid: int, + handle_kill: Callable[[], Coroutine[Any, Any, bool]], + events: AsyncGenerator[ + Union[process_pb2.StartResponse, process_pb2.ConnectResponse], Any + ], + on_stdout: Optional[OutputHandler[Stdout]] = None, + on_stderr: Optional[OutputHandler[Stderr]] = None, + on_pty: Optional[OutputHandler[PtyOutput]] = None, + ): + self._pid = pid + self._handle_kill = handle_kill + self._events = events + + self._stdout: str = "" + self._stderr: str = "" + + self._on_stdout = on_stdout + self._on_stderr = on_stderr + self._on_pty = on_pty + + self._result: Optional[CommandResult] = None + self._iteration_exception: Optional[Exception] = None + + self._wait = asyncio.create_task(self._handle_events()) + + async def _iterate_events( + self, + ) -> AsyncGenerator[ + Union[ + Tuple[Stdout, None, None], + Tuple[None, Stderr, None], + Tuple[None, None, PtyOutput], + ], + None, + ]: + async for event in self._events: + if event.event.HasField("data"): + if event.event.data.stdout: + out = event.event.data.stdout.decode("utf-8", "replace") + self._stdout += out + yield out, None, None + if event.event.data.stderr: + out = event.event.data.stderr.decode("utf-8", "replace") + self._stderr += out + yield None, out, None + if event.event.data.pty: + yield None, None, event.event.data.pty + if event.event.HasField("end"): + self._result = CommandResult( + stdout=self._stdout, + stderr=self._stderr, + exit_code=event.event.end.exit_code, + error=event.event.end.error, + ) + + async def disconnect(self) -> None: + """ + Disconnects from the command. + + The command is not killed, but SDK stops receiving events from the command. + You can reconnect to the command using `sandbox.commands.connect` method. + """ + self._wait.cancel() + # BUG: In Python 3.8 closing async generator can throw RuntimeError. + # await self._events.aclose() + + async def _handle_events(self): + try: + async for stdout, stderr, pty in self._iterate_events(): + if stdout is not None and self._on_stdout: + cb = self._on_stdout(stdout) + if inspect.isawaitable(cb): + await cb + elif stderr is not None and self._on_stderr: + cb = self._on_stderr(stderr) + if inspect.isawaitable(cb): + await cb + elif pty is not None and self._on_pty: + cb = self._on_pty(pty) + if inspect.isawaitable(cb): + await cb + except StopAsyncIteration: + pass + except Exception as e: + self._iteration_exception = handle_rpc_exception(e) + + async def wait(self) -> CommandResult: + """ + Wait for the command to finish and return the result. + If the command exits with a non-zero exit code, it throws a `CommandExitException`. + + :return: `CommandResult` result of command execution + """ + await self._wait + if self._iteration_exception: + raise self._iteration_exception + + if self._result is None: + raise Exception("Command ended without an end event") + + if self._result.exit_code != 0: + raise CommandExitException( + stdout=self._stdout, + stderr=self._stderr, + exit_code=self._result.exit_code, + error=self._result.error, + ) + + return self._result + + async def kill(self) -> bool: + """ + Kills the command. + + It uses `SIGKILL` signal to kill the command + + :return: `True` if the command was killed successfully, `False` if the command was not found + """ + result = await self._handle_kill() + return result diff --git a/ucloud_sandbox/sandbox_async/commands/pty.py b/ucloud_sandbox/sandbox_async/commands/pty.py new file mode 100644 index 0000000..ca1c736 --- /dev/null +++ b/ucloud_sandbox/sandbox_async/commands/pty.py @@ -0,0 +1,193 @@ +from typing import Dict, Optional + +import e2b_connect +import httpcore + +from packaging.version import Version +from ucloud_agentbox.envd.process import process_connect, process_pb2 +from ucloud_agentbox.connection_config import ( + Username, + ConnectionConfig, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from ucloud_agentbox.exceptions import SandboxException +from ucloud_agentbox.envd.rpc import authentication_header, handle_rpc_exception +from ucloud_agentbox.sandbox.commands.command_handle import PtySize +from ucloud_agentbox.sandbox_async.commands.command_handle import ( + AsyncCommandHandle, + OutputHandler, + PtyOutput, +) + + +class Pty: + """ + Module for interacting with PTYs (pseudo-terminals) in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + connection_config: ConnectionConfig, + pool: httpcore.AsyncConnectionPool, + envd_version: Version, + ) -> None: + self._connection_config = connection_config + self._envd_version = envd_version + self._rpc = process_connect.ProcessClient( + envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + async_pool=pool, + json=True, + headers=connection_config.sandbox_headers, + ) + + async def kill( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Kill PTY. + + :param pid: Process ID of the PTY + :param request_timeout: Timeout for the request in **seconds** + + :return: `true` if the PTY was killed, `false` if the PTY was not found + """ + try: + await self._rpc.asend_signal( + process_pb2.SendSignalRequest( + process=process_pb2.ProcessSelector(pid=pid), + signal=process_pb2.Signal.SIGNAL_SIGKILL, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise handle_rpc_exception(e) + + async def send_stdin( + self, + pid: int, + data: bytes, + request_timeout: Optional[float] = None, + ) -> None: + """ + Send input to a PTY. + + :param pid: Process ID of the PTY + :param data: Input data to send + :param request_timeout: Timeout for the request in **seconds** + """ + try: + await self._rpc.asend_input( + process_pb2.SendInputRequest( + process=process_pb2.ProcessSelector(pid=pid), + input=process_pb2.ProcessInput( + pty=data, + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise handle_rpc_exception(e) + + async def create( + self, + size: PtySize, + on_data: OutputHandler[PtyOutput], + user: Optional[Username] = None, + cwd: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> AsyncCommandHandle: + """ + Start a new PTY (pseudo-terminal). + + :param size: Size of the PTY + :param on_data: Callback to handle PTY data + :param user: User to use for the PTY + :param cwd: Working directory for the PTY + :param envs: Environment variables for the PTY + :param timeout: Timeout for the PTY in **seconds** + :param request_timeout: Timeout for the request in **seconds** + + :return: Handle to interact with the PTY + """ + envs = envs or {} + envs["TERM"] = "xterm-256color" + events = self._rpc.astart( + process_pb2.StartRequest( + process=process_pb2.ProcessConfig( + cmd="/bin/bash", + envs=envs, + args=["-i", "-l"], + cwd=cwd, + ), + pty=process_pb2.PTY( + size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols) + ), + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = await events.__anext__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to start process: expected start event, got {start_event}" + ) + + return AsyncCommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + on_pty=on_data, + ) + except Exception as e: + raise handle_rpc_exception(e) + + async def resize( + self, + pid: int, + size: PtySize, + request_timeout: Optional[float] = None, + ): + """ + Resize PTY. + Call this when the terminal window is resized and the number of columns and rows has changed. + + :param pid: Process ID of the PTY + :param size: New size of the PTY + :param request_timeout: Timeout for the request in **seconds** + """ + await self._rpc.aupdate( + process_pb2.UpdateRequest( + process=process_pb2.ProcessSelector(pid=pid), + pty=process_pb2.PTY( + size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols), + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) diff --git a/ucloud_sandbox/sandbox_async/filesystem/filesystem.py b/ucloud_sandbox/sandbox_async/filesystem/filesystem.py new file mode 100644 index 0000000..e3fffe2 --- /dev/null +++ b/ucloud_sandbox/sandbox_async/filesystem/filesystem.py @@ -0,0 +1,531 @@ +import httpcore +import httpx +from io import IOBase +from packaging.version import Version +from typing import AsyncIterator, IO, List, Literal, Optional, overload, Union +from ucloud_agentbox.sandbox.filesystem.filesystem import WriteEntry +import e2b_connect as connect +from ucloud_agentbox.connection_config import ( + ConnectionConfig, + Username, + default_username, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from ucloud_agentbox.envd.api import ENVD_API_FILES_ROUTE, ahandle_envd_api_exception +from ucloud_agentbox.envd.filesystem import filesystem_connect, filesystem_pb2 +from ucloud_agentbox.envd.rpc import authentication_header, handle_rpc_exception +from ucloud_agentbox.envd.versions import ENVD_VERSION_RECURSIVE_WATCH, ENVD_DEFAULT_USER +from ucloud_agentbox.exceptions import SandboxException, TemplateException, InvalidArgumentException +from ucloud_agentbox.sandbox.filesystem.filesystem import ( + WriteInfo, + EntryInfo, + map_file_type, +) +from ucloud_agentbox.sandbox.filesystem.watch_handle import FilesystemEvent +from ucloud_agentbox.sandbox_async.filesystem.watch_handle import AsyncWatchHandle +from ucloud_agentbox.sandbox_async.utils import OutputHandler + + +class Filesystem: + """ + Module for interacting with the filesystem in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + envd_version: Version, + connection_config: ConnectionConfig, + pool: httpcore.AsyncConnectionPool, + envd_api: httpx.AsyncClient, + ) -> None: + self._envd_api_url = envd_api_url + self._envd_version = envd_version + self._connection_config = connection_config + self._pool = pool + self._envd_api = envd_api + + self._rpc = filesystem_connect.FilesystemClient( + envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + async_pool=pool, + json=True, + headers=connection_config.sandbox_headers, + ) + + @overload + async def read( + self, + path: str, + format: Literal["text"] = "text", + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> str: + """ + Read file content as a `str`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`text` by default + :param request_timeout: Timeout for the request in **seconds** + + :return: File content as a `str` + """ + ... + + @overload + async def read( + self, + path: str, + format: Literal["bytes"], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bytearray: + """ + Read file content as a `bytearray`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`bytes` + :param request_timeout: Timeout for the request in **seconds** + + :return: File content as a `bytearray` + """ + ... + + @overload + async def read( + self, + path: str, + format: Literal["stream"], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> AsyncIterator[bytes]: + """ + Read file content as a `AsyncIterator[bytes]`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`stream` + :param request_timeout: Timeout for the request in **seconds** + + :return: File content as an `AsyncIterator[bytes]` + """ + ... + + async def read( + self, + path: str, + format: Literal["text", "bytes", "stream"] = "text", + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ): + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + params = {"path": path} + if username: + params["username"] = username + + r = await self._envd_api.get( + ENVD_API_FILES_ROUTE, + params=params, + timeout=self._connection_config.get_request_timeout(request_timeout), + ) + + err = await ahandle_envd_api_exception(r) + if err: + raise err + + if format == "text": + return r.text + elif format == "bytes": + return bytearray(r.content) + elif format == "stream": + return r.aiter_bytes() + + async def write( + self, + path: str, + data: Union[str, bytes, IO], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> WriteInfo: + """ + Write content to a file on the path. + Writing to a file that doesn't exist creates the file. + Writing to a file that already exists overwrites the file. + Writing to a file at path that doesn't exist creates the necessary directories. + + :param path: Path to the file + :param data: Data to write to the file, can be a `str`, `bytes`, or `IO`. + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the written file + """ + result = await self.write_files( + [WriteEntry(path=path, data=data)], user, request_timeout + ) + + if len(result) != 1: + raise SandboxException("Received unexpected response from write operation") + + return result[0] + + async def write_files( + self, + files: List[WriteEntry], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> List[WriteInfo]: + """ + Writes multiple files. + + Writes a list of files to the filesystem. + When writing to a file that doesn't exist, the file will get created. + When writing to a file that already exists, the file will get overwritten. + When writing to a file that's in a directory that doesn't exist, you'll get an error. + + :param files: list of files to write as `WriteEntry` objects, each containing `path` and `data` + :param user: Run the operation as this user + :param request_timeout: Timeout for the request + :return: Information about the written files + """ + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + params = {} + if username: + params["username"] = username + if len(files) == 1: + params["path"] = files[0]["path"] + + # Prepare the files for the multipart/form-data request + httpx_files = [] + for file in files: + file_path, file_data = file["path"], file["data"] + if isinstance(file_data, str) or isinstance(file_data, bytes): + httpx_files.append(("file", (file_path, file_data))) + elif isinstance(file_data, IOBase): + httpx_files.append(("file", (file_path, file_data.read()))) + else: + raise InvalidArgumentException( + f"Unsupported data type for file {file_path}" + ) + + # Allow passing empty list of files + if len(httpx_files) == 0: + return [] + + r = await self._envd_api.post( + ENVD_API_FILES_ROUTE, + files=httpx_files, + params=params, + timeout=self._connection_config.get_request_timeout(request_timeout), + ) + + err = await ahandle_envd_api_exception(r) + if err: + raise err + + write_files = r.json() + + if not isinstance(write_files, list) or len(write_files) == 0: + raise SandboxException("Expected to receive information about written file") + + return [WriteInfo(**file) for file in write_files] + + async def list( + self, + path: str, + depth: Optional[int] = 1, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> List[EntryInfo]: + """ + List entries in a directory. + + :param path: Path to the directory + :param depth: Depth of the directory to list + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: List of entries in the directory + """ + if depth is not None and depth < 1: + raise InvalidArgumentException("depth should be at least 1") + + try: + res = await self._rpc.alist_dir( + filesystem_pb2.ListDirRequest(path=path, depth=depth), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + entries: List[EntryInfo] = [] + for entry in res.entries: + event_type = map_file_type(entry.type) + + if event_type: + entries.append( + EntryInfo( + name=entry.name, + type=event_type, + path=entry.path, + size=entry.size, + mode=entry.mode, + permissions=entry.permissions, + owner=entry.owner, + group=entry.group, + modified_time=entry.modified_time.ToDatetime(), + # Optional, we can't directly access symlink_target otherwise if will be "" instead of None + symlink_target=( + entry.symlink_target + if entry.HasField("symlink_target") + else None + ), + ) + ) + + return entries + except Exception as e: + raise handle_rpc_exception(e) + + async def exists( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Check if a file or a directory exists. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the file or directory exists, `False` otherwise + """ + try: + await self._rpc.astat( + filesystem_pb2.StatRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return True + + except Exception as e: + if isinstance(e, connect.ConnectException): + if e.status == connect.Code.not_found: + return False + raise handle_rpc_exception(e) + + async def get_info( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> EntryInfo: + """ + Get information about a file or directory. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the file or directory like name, type, and path + """ + try: + r = await self._rpc.astat( + filesystem_pb2.StatRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return EntryInfo( + name=r.entry.name, + type=map_file_type(r.entry.type), + path=r.entry.path, + size=r.entry.size, + mode=r.entry.mode, + permissions=r.entry.permissions, + owner=r.entry.owner, + group=r.entry.group, + modified_time=r.entry.modified_time.ToDatetime(), + symlink_target=( + r.entry.symlink_target + if r.entry.HasField("symlink_target") + else None + ), + ) + except Exception as e: + raise handle_rpc_exception(e) + + async def remove( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> None: + """ + Remove a file or a directory. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + """ + try: + await self._rpc.aremove( + filesystem_pb2.RemoveRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + except Exception as e: + raise handle_rpc_exception(e) + + async def rename( + self, + old_path: str, + new_path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> EntryInfo: + """ + Rename a file or directory. + + :param old_path: Path to the file or directory to rename + :param new_path: New path to the file or directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the renamed file or directory + """ + try: + r = await self._rpc.amove( + filesystem_pb2.MoveRequest( + source=old_path, + destination=new_path, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return EntryInfo( + name=r.entry.name, + type=map_file_type(r.entry.type), + path=r.entry.path, + size=r.entry.size, + mode=r.entry.mode, + permissions=r.entry.permissions, + owner=r.entry.owner, + group=r.entry.group, + modified_time=r.entry.modified_time.ToDatetime(), + # Optional, we can't directly access symlink_target otherwise if will be "" instead of None + symlink_target=( + r.entry.symlink_target + if r.entry.HasField("symlink_target") + else None + ), + ) + except Exception as e: + raise handle_rpc_exception(e) + + async def make_dir( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Create a new directory and all directories along the way if needed on the specified path. + + :param path: Path to a new directory. For example '/dirA/dirB' when creating 'dirB'. + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the directory was created, `False` if the directory already exists + """ + try: + await self._rpc.amake_dir( + filesystem_pb2.MakeDirRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return True + except Exception as e: + if isinstance(e, connect.ConnectException): + if e.status == connect.Code.already_exists: + return False + raise handle_rpc_exception(e) + + async def watch_dir( + self, + path: str, + on_event: OutputHandler[FilesystemEvent], + on_exit: Optional[OutputHandler[Exception]] = None, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + timeout: Optional[float] = 60, + recursive: bool = False, + ) -> AsyncWatchHandle: + """ + Watch directory for filesystem events. + + :param path: Path to a directory to watch + :param on_event: Callback to call on each event in the directory + :param on_exit: Callback to call when the watching ends + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + :param timeout: Timeout for the watch operation in **seconds**. Using `0` will not limit the watch time + :param recursive: Watch directory recursively + + :return: `AsyncWatchHandle` object for stopping watching directory + """ + if recursive and self._envd_version < ENVD_VERSION_RECURSIVE_WATCH: + raise TemplateException( + "You need to update the template to use recursive watching. " + "You can do this by running `e2b template build` in the directory with the template." + ) + + events = self._rpc.awatch_dir( + filesystem_pb2.WatchDirRequest(path=path, recursive=recursive), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + timeout=timeout, + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + ) + + try: + start_event = await events.__anext__() + + if not start_event.HasField("start"): + raise SandboxException( + f"Failed to start watch: expected start event, got {start_event}", + ) + + return AsyncWatchHandle(events=events, on_event=on_event, on_exit=on_exit) + except Exception as e: + raise handle_rpc_exception(e) diff --git a/ucloud_sandbox/sandbox_async/filesystem/watch_handle.py b/ucloud_sandbox/sandbox_async/filesystem/watch_handle.py new file mode 100644 index 0000000..3e89fb8 --- /dev/null +++ b/ucloud_sandbox/sandbox_async/filesystem/watch_handle.py @@ -0,0 +1,62 @@ +import asyncio +import inspect + +from typing import Any, AsyncGenerator, Optional + +from ucloud_agentbox.envd.rpc import handle_rpc_exception +from ucloud_agentbox.envd.filesystem.filesystem_pb2 import WatchDirResponse +from ucloud_agentbox.sandbox.filesystem.watch_handle import FilesystemEvent, map_event_type +from ucloud_agentbox.sandbox_async.utils import OutputHandler + + +class AsyncWatchHandle: + """ + Handle for watching a directory in the sandbox filesystem. + + Use `.stop()` to stop watching the directory. + """ + + def __init__( + self, + events: AsyncGenerator[WatchDirResponse, Any], + on_event: OutputHandler[FilesystemEvent], + on_exit: Optional[OutputHandler[Exception]] = None, + ): + self._events = events + self._on_event = on_event + self._on_exit = on_exit + + self._wait = asyncio.create_task(self._handle_events()) + + async def stop(self): + """ + Stop watching the directory. + """ + self._wait.cancel() + # BUG: In Python 3.8 closing async generator can throw RuntimeError. + # await self._events.aclose() + + async def _iterate_events(self): + try: + async for event in self._events: + if event.HasField("filesystem"): + event_type = map_event_type(event.filesystem.type) + if event_type: + yield FilesystemEvent( + name=event.filesystem.name, + type=event_type, + ) + except Exception as e: + raise handle_rpc_exception(e) + + async def _handle_events(self): + try: + async for event in self._iterate_events(): + cb = self._on_event(event) + if inspect.isawaitable(cb): + await cb + except Exception as e: + if self._on_exit: + cb = self._on_exit(e) + if inspect.isawaitable(cb): + await cb diff --git a/ucloud_sandbox/sandbox_async/main.py b/ucloud_sandbox/sandbox_async/main.py new file mode 100644 index 0000000..646f5a3 --- /dev/null +++ b/ucloud_sandbox/sandbox_async/main.py @@ -0,0 +1,686 @@ +import datetime +import json +import logging +import uuid +from typing import Dict, List, Optional, overload + +import httpx +from packaging.version import Version +from typing_extensions import Self, Unpack + +from ucloud_agentbox.api.client.types import Unset +from ucloud_agentbox.connection_config import ApiParams, ConnectionConfig +from ucloud_agentbox.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception +from ucloud_agentbox.envd.versions import ENVD_DEBUG_FALLBACK +from ucloud_agentbox.exceptions import SandboxException, format_request_timeout_error +from ucloud_agentbox.sandbox.main import SandboxOpts +from ucloud_agentbox.sandbox.sandbox_api import SandboxMetrics, SandboxNetworkOpts +from ucloud_agentbox.sandbox.utils import class_method_variant +from ucloud_agentbox.sandbox_async.commands.command import Commands +from ucloud_agentbox.sandbox_async.commands.pty import Pty +from ucloud_agentbox.sandbox_async.filesystem.filesystem import Filesystem +from ucloud_agentbox.sandbox_async.sandbox_api import SandboxApi, SandboxInfo +from ucloud_agentbox.api.client_async import get_transport + +logger = logging.getLogger(__name__) + + +class AsyncSandbox(SandboxApi): + """ + UCloud AgentBox sandbox is a secure and isolated cloud environment. + + The sandbox allows you to: + - Access Linux OS + - Create, list, and delete files and directories + - Run commands + - Run isolated code + - Access the internet + + Use the `AsyncSandbox.create()` to create a new sandbox. + + Example: + ```python + from ucloud_agentbox import AsyncSandbox + + sandbox = await AsyncSandbox.create() + ``` + """ + + @property + def files(self) -> Filesystem: + """ + Module for interacting with the sandbox filesystem. + """ + return self._filesystem + + @property + def commands(self) -> Commands: + """ + Module for running commands in the sandbox. + """ + return self._commands + + @property + def pty(self) -> Pty: + """ + Module for interacting with the sandbox pseudo-terminal. + """ + return self._pty + + def __init__( + self, + **opts: Unpack[SandboxOpts], + ): + """ + Use `AsyncSandbox.create()` to create a new sandbox instead. + """ + super().__init__(**opts) + + self._transport = get_transport(self.connection_config) + self._envd_api = httpx.AsyncClient( + base_url=self.connection_config.get_sandbox_url( + self.sandbox_id, self.sandbox_domain + ), + transport=self._transport, + headers=self.connection_config.sandbox_headers, + ) + self._filesystem = Filesystem( + self.envd_api_url, + self._envd_version, + self.connection_config, + self._transport.pool, + self._envd_api, + ) + self._commands = Commands( + self.envd_api_url, + self.connection_config, + self._transport.pool, + self._envd_version, + ) + self._pty = Pty( + self.envd_api_url, + self.connection_config, + self._transport.pool, + self._envd_version, + ) + + async def is_running(self, request_timeout: Optional[float] = None) -> bool: + """ + Check if the sandbox is running. + + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the sandbox is running, `False` otherwise + + Example + ```python + sandbox = await AsyncSandbox.create() + await sandbox.is_running() # Returns True + + await sandbox.kill() + await sandbox.is_running() # Returns False + ``` + """ + try: + r = await self._envd_api.get( + ENVD_API_HEALTH_ROUTE, + timeout=self.connection_config.get_request_timeout(request_timeout), + ) + + if r.status_code == 502: + return False + + err = await ahandle_envd_api_exception(r) + + if err: + raise err + + except httpx.TimeoutException: + raise format_request_timeout_error() + + return True + + @classmethod + async def create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: bool = True, + allow_internet_access: bool = True, + network: Optional[SandboxNetworkOpts] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Create a new sandbox. + + By default, the sandbox is created from the default `base` sandbox template. + + :param template: Sandbox template name or ID + :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param metadata: Custom metadata for the sandbox + :param envs: Custom environment variables for the sandbox + :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. + :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. + :param network: Sandbox network configuration + + :return: A Sandbox instance for the new sandbox + + Use this method instead of using the constructor to create a new sandbox. + """ + if not template: + template = cls.default_template + + sandbox = await cls._create( + template=template, + timeout=timeout, + auto_pause=False, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + network=network, + **opts, + ) + + return sandbox + + @overload + async def connect( + self, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param timeout: Timeout for the sandbox in **seconds** + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = await AsyncSandbox.create() + await sandbox.beta_pause() + + # Another code block + same_sandbox = await sandbox.connect() + ``` + """ + ... + + @overload + @classmethod + async def connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the sandbox in **seconds** + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = await AsyncSandbox.create() + await AsyncSandbox.beta_pause(sandbox.sandbox_id) + + # Another code block + same_sandbox = await AsyncSandbox.connect(sandbox.sandbox_id)) + ``` + """ + ... + + @class_method_variant("_cls_connect") + async def connect( + self, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param timeout: Timeout for the sandbox in **seconds** + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = await AsyncSandbox.create() + await sandbox.beta_pause() + + # Another code block + same_sandbox = await sandbox.connect() + ``` + """ + await SandboxApi._cls_connect( + sandbox_id=self.sandbox_id, + timeout=timeout, + **opts, + ) + + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.kill() + + @overload + async def kill( + self, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox. + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + ... + + @overload + @staticmethod + async def kill( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + ... + + @class_method_variant("_cls_kill") + async def kill( + self, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox specified by sandbox ID. + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + return await SandboxApi._cls_kill( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + async def set_timeout( + self, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the sandbox. + After the timeout expires, the sandbox will be automatically killed. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param timeout: Timeout for the sandbox in **seconds** + """ + ... + + @overload + @staticmethod + async def set_timeout( + sandbox_id: str, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the specified sandbox. + After the timeout expires, the sandbox will be automatically killed. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the sandbox in **seconds** + """ + ... + + @class_method_variant("_cls_set_timeout") + async def set_timeout( + self, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the specified sandbox. + After the timeout expires, the sandbox will be automatically killed. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param timeout: Timeout for the sandbox in **seconds** + """ + await SandboxApi._cls_set_timeout( + sandbox_id=self.sandbox_id, + timeout=timeout, + **self.connection_config.get_api_params(**opts), + ) + + @overload + async def get_info( + self, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :return: Sandbox info + """ + ... + + @overload + @staticmethod + async def get_info( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + :param sandbox_id: Sandbox ID + + :return: Sandbox info + """ + ... + + @class_method_variant("_cls_get_info") + async def get_info( + self, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :return: Sandbox info + """ + + return await SandboxApi._cls_get_info( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + async def get_metrics( + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @overload + @staticmethod + async def get_metrics( + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @class_method_variant("_cls_get_metrics") + async def get_metrics( + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + if self._envd_version < Version("0.1.5"): + raise SandboxException( + "Metrics are not supported in this version of the sandbox, please rebuild your template." + ) + + if self._envd_version < Version("0.2.4"): + logger.warning( + "Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics." + ) + + return await SandboxApi._cls_get_metrics( + sandbox_id=self.sandbox_id, + start=start, + end=end, + **self.connection_config.get_api_params(**opts), + ) + + @classmethod + async def beta_create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + auto_pause: bool = False, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: bool = True, + allow_internet_access: bool = True, + **opts: Unpack[ApiParams], + ) -> Self: + """ + [BETA] This feature is in beta and may change in the future. + + Create a new sandbox. + + By default, the sandbox is created from the default `base` sandbox template. + + :param template: Sandbox template name or ID + :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param auto_pause: Automatically pause the sandbox after the timeout expires. Defaults to `False`. + :param metadata: Custom metadata for the sandbox + :param envs: Custom environment variables for the sandbox + :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. + :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. + + :return: A Sandbox instance for the new sandbox + + Use this method instead of using the constructor to create a new sandbox. + """ + + if not template: + template = cls.default_template + + sandbox = await cls._create( + template=template, + timeout=timeout, + auto_pause=auto_pause, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + **opts, + ) + + return sandbox + + @overload + async def beta_pause( + self, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox. + + :return: Sandbox ID that can be used to resume the sandbox + """ + ... + + @overload + @staticmethod + async def beta_pause( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + + :return: Sandbox ID that can be used to resume the sandbox + """ + ... + + @class_method_variant("_cls_pause") + async def beta_pause( + self, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox. + + :return: Sandbox ID that can be used to resume the sandbox + """ + + await SandboxApi._cls_pause( + sandbox_id=self.sandbox_id, + **opts, + ) + + + + @classmethod + async def _cls_connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + sandbox = await SandboxApi._cls_connect( + sandbox_id=sandbox_id, + timeout=timeout, + **opts, + ) + + sandbox_headers = {} + envd_access_token = sandbox.envd_access_token + if envd_access_token is not None and not isinstance(envd_access_token, Unset): + sandbox_headers["X-Access-Token"] = envd_access_token + + connection_config = ConnectionConfig( + extra_sandbox_headers=sandbox_headers, + **opts, + ) + + return cls( + sandbox_id=sandbox.sandbox_id, + sandbox_domain=sandbox.domain, + envd_version=Version(sandbox.envd_version), + envd_access_token=envd_access_token, + traffic_access_token=sandbox.traffic_access_token, + connection_config=connection_config, + ) + + @classmethod + async def _create( + cls, + template: Optional[str], + timeout: Optional[int], + auto_pause: bool, + allow_internet_access: bool, + metadata: Optional[Dict[str, str]], + envs: Optional[Dict[str, str]], + secure: bool, + network: Optional[SandboxNetworkOpts] = None, + **opts: Unpack[ApiParams], + ) -> Self: + extra_sandbox_headers = {} + + debug = opts.get("debug") + if debug: + sandbox_id = "debug_sandbox_id" + sandbox_domain = None + envd_version = ENVD_DEBUG_FALLBACK + envd_access_token = None + traffic_access_token = None + else: + response = await SandboxApi._create_sandbox( + template=template or cls.default_template, + timeout=timeout or cls.default_sandbox_timeout, + auto_pause=auto_pause, + metadata=metadata, + env_vars=envs, + secure=secure, + allow_internet_access=allow_internet_access, + network=network, + **opts, + ) + + sandbox_id = response.sandbox_id + sandbox_domain = response.sandbox_domain + envd_version = Version(response.envd_version) + envd_access_token = response.envd_access_token + traffic_access_token = response.traffic_access_token + + if envd_access_token is not None and not isinstance( + envd_access_token, Unset + ): + extra_sandbox_headers["X-Access-Token"] = envd_access_token + + extra_sandbox_headers["E2b-Sandbox-Id"] = sandbox_id + extra_sandbox_headers["E2b-Sandbox-Port"] = str(ConnectionConfig.envd_port) + + connection_config = ConnectionConfig( + extra_sandbox_headers=extra_sandbox_headers, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=sandbox_domain, + envd_version=envd_version, + envd_access_token=envd_access_token, + traffic_access_token=traffic_access_token, + connection_config=connection_config, + ) diff --git a/ucloud_sandbox/sandbox_async/paginator.py b/ucloud_sandbox/sandbox_async/paginator.py new file mode 100644 index 0000000..f510b7a --- /dev/null +++ b/ucloud_sandbox/sandbox_async/paginator.py @@ -0,0 +1,69 @@ +import urllib.parse +from typing import Optional, List + +from ucloud_agentbox.api.client.api.sandboxes import get_v2_sandboxes +from ucloud_agentbox.api.client.types import UNSET +from ucloud_agentbox.exceptions import SandboxException +from ucloud_agentbox.sandbox.sandbox_api import SandboxPaginatorBase, SandboxInfo +from ucloud_agentbox.api import handle_api_exception +from ucloud_agentbox.api.client.models.error import Error +from ucloud_agentbox.api.client_async import get_api_client + + +class AsyncSandboxPaginator(SandboxPaginatorBase): + """ + Paginator for listing sandboxes. + + Example: + ```python + paginator = AsyncSandbox.list() + + while paginator.has_next: + sandboxes = await paginator.next_items() + print(sandboxes) + ``` + """ + + async def next_items(self) -> List[SandboxInfo]: + """ + Returns the next page of sandboxes. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :returns: List of sandboxes + """ + if not self.has_next: + raise Exception("No more items to fetch") + + # Convert filters to the format expected by the API + metadata: Optional[str] = None + if self.query and self.query.metadata: + quoted_metadata = { + urllib.parse.quote(k): urllib.parse.quote(v) + for k, v in self.query.metadata.items() + } + metadata = urllib.parse.urlencode(quoted_metadata) + + api_client = get_api_client(self._config) + res = await get_v2_sandboxes.asyncio_detailed( + client=api_client, + metadata=metadata if metadata else UNSET, + state=self.query.state if self.query and self.query.state else UNSET, + limit=self.limit if self.limit else UNSET, + next_token=self._next_token if self._next_token else UNSET, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + self._next_token = res.headers.get("x-next-token") + self._has_next = bool(self._next_token) + + if res.parsed is None: + return [] + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed] diff --git a/ucloud_sandbox/sandbox_async/sandbox_api.py b/ucloud_sandbox/sandbox_async/sandbox_api.py new file mode 100644 index 0000000..de391f7 --- /dev/null +++ b/ucloud_sandbox/sandbox_async/sandbox_api.py @@ -0,0 +1,322 @@ +import datetime +from typing import Dict, List, Optional + +from packaging.version import Version +from typing_extensions import Unpack + +from ucloud_agentbox.api import SandboxCreateResponse, handle_api_exception +from ucloud_agentbox.api.client.api.sandboxes import ( + delete_sandboxes_sandbox_id, + get_sandboxes_sandbox_id, + get_sandboxes_sandbox_id_metrics, + post_sandboxes, + post_sandboxes_sandbox_id_connect, + post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_timeout, +) +from ucloud_agentbox.api.client.models import ( + ConnectSandbox, + Error, + NewSandbox, + PostSandboxesSandboxIDTimeoutBody, + Sandbox, + SandboxNetworkConfig, +) +from ucloud_agentbox.api.client.types import UNSET +from ucloud_agentbox.api.client_async import get_api_client +from ucloud_agentbox.connection_config import ApiParams, ConnectionConfig +from ucloud_agentbox.exceptions import NotFoundException, SandboxException, TemplateException +from ucloud_agentbox.sandbox.main import SandboxBase +from ucloud_agentbox.sandbox.sandbox_api import ( + SandboxInfo, + SandboxMetrics, + SandboxNetworkOpts, + SandboxQuery, +) +from ucloud_agentbox.sandbox_async.paginator import AsyncSandboxPaginator + + +class SandboxApi(SandboxBase): + @staticmethod + def list( + query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSandboxPaginator: + """ + List all running sandboxes. + + :param query: Filter the list of sandboxes by metadata or state, e.g. `SandboxListQuery(metadata={"key": "value"})` or `SandboxListQuery(state=[SandboxState.RUNNING])` + :param limit: Maximum number of sandboxes to return per page + :param next_token: Token for pagination + + :return: List of running sandboxes + """ + return AsyncSandboxPaginator( + query=query, + limit=limit, + next_token=next_token, + **opts, + ) + + @classmethod + async def _cls_get_info( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get the sandbox info. + :param sandbox_id: Sandbox ID + + :return: Sandbox info + """ + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await get_sandboxes_sandbox_id.asyncio_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return SandboxInfo._from_sandbox_detail(res.parsed) + + @classmethod + async def _cls_kill( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip killing the sandbox in debug mode + return True + + api_client = get_api_client(config) + res = await delete_sandboxes_sandbox_id.asyncio_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + + @classmethod + async def _cls_set_timeout( + cls, + sandbox_id: str, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip setting the timeout in debug mode + return + + api_client = get_api_client(config) + res = await post_sandboxes_sandbox_id_timeout.asyncio_detailed( + sandbox_id, + client=api_client, + body=PostSandboxesSandboxIDTimeoutBody(timeout=timeout), + ) + + if res.status_code == 404: + raise NotFoundException(f"Paused sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + @classmethod + async def _create_sandbox( + cls, + template: str, + timeout: int, + auto_pause: bool, + allow_internet_access: bool, + metadata: Optional[Dict[str, str]], + env_vars: Optional[Dict[str, str]], + secure: bool, + network: Optional[SandboxNetworkOpts] = None, + **opts: Unpack[ApiParams], + ) -> SandboxCreateResponse: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await post_sandboxes.asyncio_detailed( + body=NewSandbox( + template_id=template, + auto_pause=auto_pause, + metadata=metadata or {}, + timeout=timeout, + env_vars=env_vars or {}, + secure=secure, + allow_internet_access=allow_internet_access, + network=SandboxNetworkConfig(**network) if network else UNSET, + ), + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + if Version(res.parsed.envd_version) < Version("0.1.0"): + await SandboxApi._cls_kill(res.parsed.sandbox_id) + raise TemplateException( + "You need to update the template to use the new SDK. " + "You can do this by running `e2b template build` in the directory with the template." + ) + + return SandboxCreateResponse( + sandbox_id=res.parsed.sandbox_id, + sandbox_domain=res.parsed.domain, + envd_version=res.parsed.envd_version, + envd_access_token=res.parsed.envd_access_token, + traffic_access_token=res.parsed.traffic_access_token, + ) + + @classmethod + async def _cls_get_metrics( + cls, + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + config = ConnectionConfig(**opts) + + if config.debug: + # Skip getting the metrics in debug mode + return [] + + api_client = get_api_client(config) + res = await get_sandboxes_sandbox_id_metrics.asyncio_detailed( + sandbox_id, + start=int(start.timestamp() * 1000) if start else UNSET, + end=int(end.timestamp() * 1000) if end else UNSET, + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + return [] + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + # Convert to typed SandboxMetrics objects + return [ + SandboxMetrics( + cpu_count=metric.cpu_count, + cpu_used_pct=metric.cpu_used_pct, + disk_total=metric.disk_total, + disk_used=metric.disk_used, + mem_total=metric.mem_total, + mem_used=metric.mem_used, + timestamp=metric.timestamp, + ) + for metric in res.parsed + ] + + @classmethod + async def _cls_pause( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> str: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await post_sandboxes_sandbox_id_pause.asyncio_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code == 409: + return sandbox_id + + if res.status_code >= 300: + raise handle_api_exception(res) + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return sandbox_id + + @classmethod + async def _cls_connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Sandbox: + timeout = timeout or SandboxBase.default_sandbox_timeout + + # Sandbox is not running, resume it + config = ConnectionConfig(**opts) + + api_client = get_api_client( + config, + headers={ + "E2b-Sandbox-Id": sandbox_id, + "E2b-Sandbox-Port": str(config.envd_port), + }, + ) + res = await post_sandboxes_sandbox_id_connect.asyncio_detailed( + sandbox_id, + client=api_client, + body=ConnectSandbox(timeout=timeout), + ) + + if res.status_code == 404: + raise NotFoundException(f"Paused sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return res.parsed diff --git a/ucloud_sandbox/sandbox_async/utils.py b/ucloud_sandbox/sandbox_async/utils.py new file mode 100644 index 0000000..9ed3256 --- /dev/null +++ b/ucloud_sandbox/sandbox_async/utils.py @@ -0,0 +1,7 @@ +from typing import TypeVar, Union, Callable, Awaitable + +T = TypeVar("T") +OutputHandler = Union[ + Callable[[T], None], + Callable[[T], Awaitable[None]], +] diff --git a/ucloud_sandbox/sandbox_sync/commands/command.py b/ucloud_sandbox/sandbox_sync/commands/command.py new file mode 100644 index 0000000..4a156e8 --- /dev/null +++ b/ucloud_sandbox/sandbox_sync/commands/command.py @@ -0,0 +1,328 @@ +from typing import Callable, Dict, List, Literal, Optional, Union, overload + +import e2b_connect +import httpcore +from packaging.version import Version +from ucloud_agentbox.connection_config import ( + ConnectionConfig, + Username, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from ucloud_agentbox.envd.process import process_connect, process_pb2 +from ucloud_agentbox.envd.rpc import authentication_header, handle_rpc_exception +from ucloud_agentbox.envd.versions import ENVD_COMMANDS_STDIN +from ucloud_agentbox.exceptions import SandboxException +from ucloud_agentbox.sandbox.commands.main import ProcessInfo +from ucloud_agentbox.sandbox.commands.command_handle import CommandResult +from ucloud_agentbox.sandbox_sync.commands.command_handle import CommandHandle + + +class Commands: + """ + Module for executing commands in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + connection_config: ConnectionConfig, + pool: httpcore.ConnectionPool, + envd_version: Version, + ) -> None: + self._connection_config = connection_config + self._envd_version = envd_version + self._rpc = process_connect.ProcessClient( + envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + pool=pool, + json=True, + headers=connection_config.sandbox_headers, + ) + + def list( + self, + request_timeout: Optional[float] = None, + ) -> List[ProcessInfo]: + """ + Lists all running commands and PTY sessions. + + :param request_timeout: Timeout for the request in **seconds** + + :return: List of running commands and PTY sessions + """ + try: + res = self._rpc.list( + process_pb2.ListRequest(), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return [ + ProcessInfo( + pid=p.pid, + tag=p.tag, + cmd=p.config.cmd, + args=list(p.config.args), + envs=dict(p.config.envs), + cwd=p.config.cwd, + ) + for p in res.processes + ] + except Exception as e: + raise handle_rpc_exception(e) + + def kill( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Kills a running command specified by its process ID. + It uses `SIGKILL` signal to kill the command. + + :param pid: Process ID of the command. You can get the list of processes using `sandbox.commands.list()` + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the command was killed, `False` if the command was not found + """ + try: + self._rpc.send_signal( + process_pb2.SendSignalRequest( + process=process_pb2.ProcessSelector(pid=pid), + signal=process_pb2.Signal.SIGNAL_SIGKILL, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise handle_rpc_exception(e) + + def send_stdin( + self, + pid: int, + data: str, + request_timeout: Optional[float] = None, + ): + """ + Send data to command stdin. + + :param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`. + :param data: Data to send to the command + :param request_timeout: Timeout for the request in **seconds** + """ + try: + self._rpc.send_input( + process_pb2.SendInputRequest( + process=process_pb2.ProcessSelector(pid=pid), + input=process_pb2.ProcessInput( + stdin=data.encode(), + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise handle_rpc_exception(e) + + @overload + def run( + self, + cmd: str, + background: Union[Literal[False], None] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[Callable[[str], None]] = None, + on_stderr: Optional[Callable[[str], None]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> CommandResult: + """ + Start a new command and wait until it finishes executing. + + :param cmd: Command to execute + :param background: **`False` if the command should be executed in the foreground**, `True` if the command should be executed in the background + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param on_stdout: Callback for command stdout output + :param on_stderr: Callback for command stderr output + :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()` + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `CommandResult` result of the command execution + """ + ... + + @overload + def run( + self, + cmd: str, + background: Literal[True], + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: None = None, + on_stderr: None = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> CommandHandle: + """ + Start a new command and return a handle to interact with it. + + :param cmd: Command to execute + :param background: `False` if the command should be executed in the foreground, **`True` if the command should be executed in the background** + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()` + :param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `CommandHandle` handle to interact with the running command + """ + ... + + def run( + self, + cmd: str, + background: Union[bool, None] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[Username] = None, + cwd: Optional[str] = None, + on_stdout: Optional[Callable[[str], None]] = None, + on_stderr: Optional[Callable[[str], None]] = None, + stdin: Optional[bool] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ): + # Check version for stdin support + if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN: + raise SandboxException( + f"Sandbox envd version {self._envd_version} can't specify stdin, it's always turned on. " + f"Please rebuild your template if you need this feature." + ) + + # Default to `False` + stdin = stdin or False + + proc = self._start( + cmd, + envs, + user, + cwd, + stdin, + timeout, + request_timeout, + ) + + return ( + proc + if background + else proc.wait( + on_stdout=on_stdout, + on_stderr=on_stderr, + ) + ) + + def _start( + self, + cmd: str, + envs: Optional[Dict[str, str]], + user: Username, + cwd: Optional[str], + stdin: bool, + timeout: Optional[float], + request_timeout: Optional[float], + ): + events = self._rpc.start( + process_pb2.StartRequest( + process=process_pb2.ProcessConfig( + cmd="/bin/bash", + envs=envs, + args=["-l", "-c", cmd], + cwd=cwd, + ), + stdin=stdin, + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = events.__next__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to start process: expected start event, got {start_event}" + ) + + return CommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + ) + except Exception as e: + raise handle_rpc_exception(e) + + def connect( + self, + pid: int, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ): + """ + Connects to a running command. + You can use `CommandHandle.wait()` to wait for the command to finish and get execution results. + + :param pid: Process ID of the command to connect to. You can get the list of processes using `sandbox.commands.list()` + :param timeout: Timeout for the connection in **seconds**. Using `0` will not limit the connection time + :param request_timeout: Timeout for the request in **seconds** + + :return: `CommandHandle` handle to interact with the running command + """ + events = self._rpc.connect( + process_pb2.ConnectRequest( + process=process_pb2.ProcessSelector(pid=pid), + ), + headers={ + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = events.__next__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to connect to process: expected start event, got {start_event}" + ) + + return CommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + ) + except Exception as e: + raise handle_rpc_exception(e) diff --git a/ucloud_sandbox/sandbox_sync/commands/command_handle.py b/ucloud_sandbox/sandbox_sync/commands/command_handle.py new file mode 100644 index 0000000..2eed469 --- /dev/null +++ b/ucloud_sandbox/sandbox_sync/commands/command_handle.py @@ -0,0 +1,150 @@ +from typing import Optional, Callable, Any, Generator, Union, Tuple + +from ucloud_agentbox.envd.rpc import handle_rpc_exception +from ucloud_agentbox.envd.process import process_pb2 +from ucloud_agentbox.sandbox.commands.command_handle import ( + CommandExitException, + CommandResult, + Stderr, + Stdout, + PtyOutput, +) + + +class CommandHandle: + """ + Command execution handle. + + It provides methods for waiting for the command to finish, retrieving stdout/stderr, and killing the command. + """ + + @property + def pid(self): + """ + Command process ID. + """ + return self._pid + + def __init__( + self, + pid: int, + handle_kill: Callable[[], bool], + events: Generator[ + Union[process_pb2.StartResponse, process_pb2.ConnectResponse], Any, None + ], + ): + self._pid = pid + self._handle_kill = handle_kill + self._events = events + + self._stdout: str = "" + self._stderr: str = "" + + self._result: Optional[CommandResult] = None + self._iteration_exception: Optional[Exception] = None + + def __iter__(self): + """ + Iterate over the command output. + + :return: Generator of command outputs + """ + return self._handle_events() + + def _handle_events( + self, + ) -> Generator[ + Union[ + Tuple[Stdout, None, None], + Tuple[None, Stderr, None], + Tuple[None, None, PtyOutput], + ], + None, + None, + ]: + try: + for event in self._events: + if event.event.HasField("data"): + if event.event.data.stdout: + out = event.event.data.stdout.decode("utf-8", "replace") + self._stdout += out + yield out, None, None + if event.event.data.stderr: + out = event.event.data.stderr.decode("utf-8", "replace") + self._stderr += out + yield None, out, None + if event.event.data.pty: + yield None, None, event.event.data.pty + if event.event.HasField("end"): + self._result = CommandResult( + stdout=self._stdout, + stderr=self._stderr, + exit_code=event.event.end.exit_code, + error=event.event.end.error, + ) + except Exception as e: + raise handle_rpc_exception(e) + + def disconnect(self) -> None: + """ + Disconnect from the command. + + The command is not killed, but SDK stops receiving events from the command. + You can reconnect to the command using `sandbox.commands.connect` method. + """ + self._events.close() + + def wait( + self, + on_pty: Optional[Callable[[PtyOutput], None]] = None, + on_stdout: Optional[Callable[[str], None]] = None, + on_stderr: Optional[Callable[[str], None]] = None, + ) -> CommandResult: + """ + Wait for the command to finish and returns the result. + If the command exits with a non-zero exit code, it throws a `CommandExitException`. + + :param on_pty: Callback for pty output + :param on_stdout: Callback for stdout output + :param on_stderr: Callback for stderr output + + :return: `CommandResult` result of command execution + """ + try: + for stdout, stderr, pty in self: + if stdout is not None and on_stdout: + on_stdout(stdout) + elif stderr is not None and on_stderr: + on_stderr(stderr) + elif pty is not None and on_pty: + on_pty(pty) + except StopIteration: + pass + except Exception as e: + self._iteration_exception = handle_rpc_exception(e) + + if self._iteration_exception: + raise self._iteration_exception + + if self._result is None: + raise Exception("Command ended without an end event") + + if self._result.exit_code != 0: + raise CommandExitException( + stdout=self._stdout, + stderr=self._stderr, + exit_code=self._result.exit_code, + error=self._result.error, + ) + + return self._result + + def kill(self) -> bool: + """ + Kills the command. + + It uses `SIGKILL` signal to kill the command. + + :return: Whether the command was killed successfully + """ + return self._handle_kill() diff --git a/ucloud_sandbox/sandbox_sync/commands/pty.py b/ucloud_sandbox/sandbox_sync/commands/pty.py new file mode 100644 index 0000000..a7e1451 --- /dev/null +++ b/ucloud_sandbox/sandbox_sync/commands/pty.py @@ -0,0 +1,186 @@ +import e2b_connect +import httpcore + +from typing import Dict, Optional + +from packaging.version import Version +from ucloud_agentbox.envd.process import process_connect, process_pb2 +from ucloud_agentbox.connection_config import ( + Username, + ConnectionConfig, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from ucloud_agentbox.exceptions import SandboxException +from ucloud_agentbox.envd.rpc import authentication_header, handle_rpc_exception +from ucloud_agentbox.sandbox.commands.command_handle import PtySize +from ucloud_agentbox.sandbox_sync.commands.command_handle import CommandHandle + + +class Pty: + """ + Module for interacting with PTYs (pseudo-terminals) in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + connection_config: ConnectionConfig, + pool: httpcore.ConnectionPool, + envd_version: Version, + ) -> None: + self._connection_config = connection_config + self._envd_version = envd_version + self._rpc = process_connect.ProcessClient( + envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + pool=pool, + json=True, + headers=connection_config.sandbox_headers, + ) + + def kill( + self, + pid: int, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Kill PTY. + + :param pid: Process ID of the PTY + :param request_timeout: Timeout for the request in **seconds** + + :return: `true` if the PTY was killed, `false` if the PTY was not found + """ + try: + self._rpc.send_signal( + process_pb2.SendSignalRequest( + process=process_pb2.ProcessSelector(pid=pid), + signal=process_pb2.Signal.SIGNAL_SIGKILL, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise handle_rpc_exception(e) + + def send_stdin( + self, + pid: int, + data: bytes, + request_timeout: Optional[float] = None, + ) -> None: + """ + Send input to a PTY. + + :param pid: Process ID of the PTY + :param data: Input data to send + :param request_timeout: Timeout for the request in **seconds** + """ + try: + self._rpc.send_input( + process_pb2.SendInputRequest( + process=process_pb2.ProcessSelector(pid=pid), + input=process_pb2.ProcessInput( + pty=data, + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + except Exception as e: + raise handle_rpc_exception(e) + + def create( + self, + size: PtySize, + user: Optional[Username] = None, + cwd: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + timeout: Optional[float] = 60, + request_timeout: Optional[float] = None, + ) -> CommandHandle: + """ + Start a new PTY (pseudo-terminal). + + :param size: Size of the PTY + :param user: User to use for the PTY + :param cwd: Working directory for the PTY + :param envs: Environment variables for the PTY + :param timeout: Timeout for the PTY in **seconds** + :param request_timeout: Timeout for the request in **seconds** + + :return: Handle to interact with the PTY + """ + envs = envs or {} + envs["TERM"] = "xterm-256color" + events = self._rpc.start( + process_pb2.StartRequest( + process=process_pb2.ProcessConfig( + cmd="/bin/bash", + envs=envs, + args=["-i", "-l"], + cwd=cwd, + ), + pty=process_pb2.PTY( + size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols) + ), + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + timeout=timeout, + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) + + try: + start_event = events.__next__() + + if not start_event.HasField("event"): + raise SandboxException( + f"Failed to start process: expected start event, got {start_event}" + ) + + return CommandHandle( + pid=start_event.event.start.pid, + handle_kill=lambda: self.kill(start_event.event.start.pid), + events=events, + ) + except Exception as e: + raise handle_rpc_exception(e) + + def resize( + self, + pid: int, + size: PtySize, + request_timeout: Optional[float] = None, + ) -> None: + """ + Resize PTY. + Call this when the terminal window is resized and the number of columns and rows has changed. + + :param pid: Process ID of the PTY + :param size: New size of the PTY + :param request_timeout: Timeout for the request in **seconds**s + """ + self._rpc.update( + process_pb2.UpdateRequest( + process=process_pb2.ProcessSelector(pid=pid), + pty=process_pb2.PTY( + size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols), + ), + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + ) diff --git a/ucloud_sandbox/sandbox_sync/filesystem/filesystem.py b/ucloud_sandbox/sandbox_sync/filesystem/filesystem.py new file mode 100644 index 0000000..b4ae1f7 --- /dev/null +++ b/ucloud_sandbox/sandbox_sync/filesystem/filesystem.py @@ -0,0 +1,518 @@ +from io import IOBase +from typing import IO, Iterator, List, Literal, Optional, overload, Union + +from ucloud_agentbox.sandbox.filesystem.filesystem import WriteEntry + +import e2b_connect +import httpcore +import httpx +from packaging.version import Version + +from ucloud_agentbox.envd.versions import ENVD_VERSION_RECURSIVE_WATCH, ENVD_DEFAULT_USER +from ucloud_agentbox.exceptions import SandboxException, TemplateException, InvalidArgumentException +from ucloud_agentbox.connection_config import ( + ConnectionConfig, + Username, + default_username, + KEEPALIVE_PING_HEADER, + KEEPALIVE_PING_INTERVAL_SEC, +) +from ucloud_agentbox.envd.api import ENVD_API_FILES_ROUTE, handle_envd_api_exception +from ucloud_agentbox.envd.filesystem import filesystem_connect, filesystem_pb2 +from ucloud_agentbox.envd.rpc import authentication_header, handle_rpc_exception +from ucloud_agentbox.sandbox.filesystem.filesystem import ( + WriteInfo, + EntryInfo, + map_file_type, +) +from ucloud_agentbox.sandbox_sync.filesystem.watch_handle import WatchHandle + + +class Filesystem: + """ + Module for interacting with the filesystem in the sandbox. + """ + + def __init__( + self, + envd_api_url: str, + envd_version: Version, + connection_config: ConnectionConfig, + pool: httpcore.ConnectionPool, + envd_api: httpx.Client, + ) -> None: + self._envd_api_url = envd_api_url + self._envd_version = envd_version + self._connection_config = connection_config + self._pool = pool + self._envd_api = envd_api + + self._rpc = filesystem_connect.FilesystemClient( + envd_api_url, + # TODO: Fix and enable compression again — the headers compression is not solved for streaming. + # compressor=e2b_connect.GzipCompressor, + pool=pool, + json=True, + headers=connection_config.sandbox_headers, + ) + + @overload + def read( + self, + path: str, + format: Literal["text"] = "text", + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> str: + """ + Read file content as a `str`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`text` by default + :param request_timeout: Timeout for the request in **seconds** + + :return: File content as a `str` + """ + ... + + @overload + def read( + self, + path: str, + format: Literal["bytes"], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bytearray: + """ + Read file content as a `bytearray`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`bytes` + :param request_timeout: Timeout for the request in **seconds** + + :return: File content as a `bytearray` + """ + ... + + @overload + def read( + self, + path: str, + format: Literal["stream"], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> Iterator[bytes]: + """ + Read file content as a `Iterator[bytes]`. + + :param path: Path to the file + :param user: Run the operation as this user + :param format: Format of the file content—`stream` + :param request_timeout: Timeout for the request in **seconds** + + :return: File content as an `Iterator[bytes]` + """ + ... + + def read( + self, + path: str, + format: Literal["text", "bytes", "stream"] = "text", + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ): + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + params = {"path": path} + if username: + params["username"] = username + + r = self._envd_api.get( + ENVD_API_FILES_ROUTE, + params=params, + timeout=self._connection_config.get_request_timeout(request_timeout), + ) + + err = handle_envd_api_exception(r) + if err: + raise err + + if format == "text": + return r.text + elif format == "bytes": + return bytearray(r.content) + elif format == "stream": + return r.iter_bytes() + + def write( + self, + path: str, + data: Union[str, bytes, IO], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> WriteInfo: + """ + Write content to a file on the path. + Writing to a file that doesn't exist creates the file. + Writing to a file that already exists overwrites the file. + Writing to a file at path that doesn't exist creates the necessary directories. + + :param path: Path to the file + :param data: Data to write to the file, can be a `str`, `bytes`, or `IO`. + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the written file + """ + result = self.write_files( + [WriteEntry(path=path, data=data)], + user=user, + request_timeout=request_timeout, + ) + + if len(result) != 1: + raise SandboxException("Received unexpected response from write operation") + + return result[0] + + def write_files( + self, + files: List[WriteEntry], + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> List[WriteInfo]: + """ + Writes a list of files to the filesystem. + When writing to a file that doesn't exist, the file will get created. + When writing to a file that already exists, the file will get overwritten. + When writing to a file that's in a directory that doesn't exist, you'll get an error. + + :param files: list of files to write as `WriteEntry` objects, each containing `path` and `data` + :param user: Run the operation as this user + :param request_timeout: Timeout for the request + :return: Information about the written files + """ + username = user + if username is None and self._envd_version < ENVD_DEFAULT_USER: + username = default_username + + params = {} + if username: + params["username"] = username + if len(files) == 1: + params["path"] = files[0]["path"] + + # Prepare the files for the multipart/form-data request + httpx_files = [] + for file in files: + file_path, file_data = file["path"], file["data"] + if isinstance(file_data, str) or isinstance(file_data, bytes): + httpx_files.append(("file", (file_path, file_data))) + elif isinstance(file_data, IOBase): + httpx_files.append(("file", (file_path, file_data.read()))) + else: + raise InvalidArgumentException( + f"Unsupported data type for file {file_path}" + ) + + # Allow passing empty list of files + if len(httpx_files) == 0: + return [] + + r = self._envd_api.post( + ENVD_API_FILES_ROUTE, + files=httpx_files, + params=params, + timeout=self._connection_config.get_request_timeout(request_timeout), + ) + + err = handle_envd_api_exception(r) + if err: + raise err + + write_files = r.json() + + if not isinstance(write_files, list) or len(write_files) == 0: + raise SandboxException("Expected to receive information about written file") + + return [WriteInfo(**file) for file in write_files] + + def list( + self, + path: str, + depth: Optional[int] = 1, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> List[EntryInfo]: + """ + List entries in a directory. + + :param path: Path to the directory + :param depth: Depth of the directory to list + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: List of entries in the directory + """ + if depth is not None and depth < 1: + raise InvalidArgumentException("depth should be at least 1") + + try: + res = self._rpc.list_dir( + filesystem_pb2.ListDirRequest(path=path, depth=depth), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + entries: List[EntryInfo] = [] + for entry in res.entries: + event_type = map_file_type(entry.type) + + if event_type: + entries.append( + EntryInfo( + name=entry.name, + type=event_type, + path=entry.path, + size=entry.size, + mode=entry.mode, + permissions=entry.permissions, + owner=entry.owner, + group=entry.group, + modified_time=entry.modified_time.ToDatetime(), + # Optional, we can't directly access symlink_target otherwise if will be "" instead of None + symlink_target=( + entry.symlink_target + if entry.HasField("symlink_target") + else None + ), + ) + ) + + return entries + except Exception as e: + raise handle_rpc_exception(e) + + def exists( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Check if a file or a directory exists. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the file or directory exists, `False` otherwise + """ + try: + self._rpc.stat( + filesystem_pb2.StatRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + return True + + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.not_found: + return False + raise handle_rpc_exception(e) + + def get_info( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> EntryInfo: + """ + Get information about a file or directory. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the file or directory like name, type, and path + """ + try: + r = self._rpc.stat( + filesystem_pb2.StatRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return EntryInfo( + name=r.entry.name, + type=map_file_type(r.entry.type), + path=r.entry.path, + size=r.entry.size, + mode=r.entry.mode, + permissions=r.entry.permissions, + owner=r.entry.owner, + group=r.entry.group, + modified_time=r.entry.modified_time.ToDatetime(), + # Optional, we can't directly access symlink_target otherwise if will be "" instead of None + symlink_target=( + r.entry.symlink_target + if r.entry.HasField("symlink_target") + else None + ), + ) + except Exception as e: + raise handle_rpc_exception(e) + + def remove( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> None: + """ + Remove a file or a directory. + + :param path: Path to a file or a directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + """ + try: + self._rpc.remove( + filesystem_pb2.RemoveRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + except Exception as e: + raise handle_rpc_exception(e) + + def rename( + self, + old_path: str, + new_path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> EntryInfo: + """ + Rename a file or directory. + + :param old_path: Path to the file or directory to rename + :param new_path: New path to the file or directory + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: Information about the renamed file or directory + """ + try: + r = self._rpc.move( + filesystem_pb2.MoveRequest( + source=old_path, + destination=new_path, + ), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return EntryInfo( + name=r.entry.name, + type=map_file_type(r.entry.type), + path=r.entry.path, + size=r.entry.size, + mode=r.entry.mode, + permissions=r.entry.permissions, + owner=r.entry.owner, + group=r.entry.group, + modified_time=r.entry.modified_time.ToDatetime(), + # Optional, we can't directly access symlink_target otherwise if will be "" instead of None + symlink_target=( + r.entry.symlink_target + if r.entry.HasField("symlink_target") + else None + ), + ) + except Exception as e: + raise handle_rpc_exception(e) + + def make_dir( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + ) -> bool: + """ + Create a new directory and all directories along the way if needed on the specified path. + + :param path: Path to a new directory. For example '/dirA/dirB' when creating 'dirB'. + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the directory was created, `False` if the directory already exists + """ + try: + self._rpc.make_dir( + filesystem_pb2.MakeDirRequest(path=path), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers=authentication_header(self._envd_version, user), + ) + + return True + except Exception as e: + if isinstance(e, e2b_connect.ConnectException): + if e.status == e2b_connect.Code.already_exists: + return False + raise handle_rpc_exception(e) + + def watch_dir( + self, + path: str, + user: Optional[Username] = None, + request_timeout: Optional[float] = None, + recursive: bool = False, + ) -> WatchHandle: + """ + Watch directory for filesystem events. + + :param path: Path to a directory to watch + :param user: Run the operation as this user + :param request_timeout: Timeout for the request in **seconds** + :param recursive: Watch directory recursively + + :return: `WatchHandle` object for stopping watching directory + """ + if recursive and self._envd_version < ENVD_VERSION_RECURSIVE_WATCH: + raise TemplateException( + "You need to update the template to use recursive watching. " + "You can do this by running `e2b template build` in the directory with the template." + ) + + try: + r = self._rpc.create_watcher( + filesystem_pb2.CreateWatcherRequest(path=path, recursive=recursive), + request_timeout=self._connection_config.get_request_timeout( + request_timeout + ), + headers={ + **authentication_header(self._envd_version, user), + KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC), + }, + ) + except Exception as e: + raise handle_rpc_exception(e) + + return WatchHandle(self._rpc, r.watcher_id) diff --git a/ucloud_sandbox/sandbox_sync/filesystem/watch_handle.py b/ucloud_sandbox/sandbox_sync/filesystem/watch_handle.py new file mode 100644 index 0000000..a35669d --- /dev/null +++ b/ucloud_sandbox/sandbox_sync/filesystem/watch_handle.py @@ -0,0 +1,69 @@ +from typing import List + +from e2b import SandboxException +from ucloud_agentbox.envd.filesystem import filesystem_connect +from ucloud_agentbox.envd.filesystem.filesystem_pb2 import ( + GetWatcherEventsRequest, + RemoveWatcherRequest, +) +from ucloud_agentbox.envd.rpc import handle_rpc_exception +from ucloud_agentbox.sandbox.filesystem.watch_handle import FilesystemEvent, map_event_type + + +class WatchHandle: + """ + Handle for watching filesystem events. + It is used to get the latest events that have occurred in the watched directory. + + Use `.stop()` to stop watching the directory. + """ + + def __init__( + self, + rpc: filesystem_connect.FilesystemClient, + watcher_id: str, + ): + self._rpc = rpc + self._watcher_id = watcher_id + self._closed = False + + def stop(self): + """ + Stop watching the directory. + After you stop the watcher you won't be able to get the events anymore. + """ + try: + self._rpc.remove_watcher(RemoveWatcherRequest(watcher_id=self._watcher_id)) + except Exception as e: + raise handle_rpc_exception(e) + + self._closed = True + + def get_new_events(self) -> List[FilesystemEvent]: + """ + Get the latest events that have occurred in the watched directory since the last call, or from the beginning of the watching, up until now. + + :return: List of filesystem events + """ + if self._closed: + raise SandboxException("The watcher is already stopped") + + try: + r = self._rpc.get_watcher_events( + GetWatcherEventsRequest(watcher_id=self._watcher_id) + ) + except Exception as e: + raise handle_rpc_exception(e) + + events = [] + for event in r.events: + event_type = map_event_type(event.type) + if event_type: + events.append( + FilesystemEvent( + name=event.name, + type=event_type, + ) + ) + + return events diff --git a/ucloud_sandbox/sandbox_sync/main.py b/ucloud_sandbox/sandbox_sync/main.py new file mode 100644 index 0000000..764107f --- /dev/null +++ b/ucloud_sandbox/sandbox_sync/main.py @@ -0,0 +1,680 @@ +import datetime +import json +import logging +import uuid +from typing import Dict, List, Optional, overload + +import httpx +from packaging.version import Version +from typing_extensions import Self, Unpack + +from ucloud_agentbox.api.client.types import Unset +from ucloud_agentbox.connection_config import ApiParams, ConnectionConfig +from ucloud_agentbox.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception +from ucloud_agentbox.envd.versions import ENVD_DEBUG_FALLBACK +from ucloud_agentbox.exceptions import SandboxException, format_request_timeout_error +from ucloud_agentbox.sandbox.main import SandboxOpts +from ucloud_agentbox.sandbox.sandbox_api import SandboxMetrics, SandboxNetworkOpts +from ucloud_agentbox.sandbox.utils import class_method_variant +from ucloud_agentbox.sandbox_sync.commands.command import Commands +from ucloud_agentbox.sandbox_sync.commands.pty import Pty +from ucloud_agentbox.sandbox_sync.filesystem.filesystem import Filesystem +from ucloud_agentbox.sandbox_sync.sandbox_api import SandboxApi, SandboxInfo +from ucloud_agentbox.api.client_sync import get_transport + +logger = logging.getLogger(__name__) + + +class Sandbox(SandboxApi): + """ + UCloud AgentBox sandbox is a secure and isolated cloud environment. + + The sandbox allows you to: + - Access Linux OS + - Create, list, and delete files and directories + - Run commands + - Run isolated code + - Access the internet + + Use the `Sandbox.create()` to create a new sandbox. + + Example: + ```python + from ucloud_agentbox import Sandbox + + sandbox = Sandbox.create() + ``` + """ + + @property + def files(self) -> Filesystem: + """ + Module for interacting with the sandbox filesystem. + """ + return self._filesystem + + @property + def commands(self) -> Commands: + """ + Module for running commands in the sandbox. + """ + return self._commands + + @property + def pty(self) -> Pty: + """ + Module for interacting with the sandbox pseudo-terminal. + """ + return self._pty + + def __init__(self, **opts: Unpack[SandboxOpts]): + """ + :deprecated: This constructor is deprecated + + Use `Sandbox.create()` to create a new sandbox instead. + """ + super().__init__(**opts) + + self._transport = get_transport(self.connection_config) + + self._envd_api = httpx.Client( + base_url=self.envd_api_url, + transport=self._transport, + headers=self.connection_config.sandbox_headers, + ) + self._filesystem = Filesystem( + self.envd_api_url, + self._envd_version, + self.connection_config, + self._transport.pool, + self._envd_api, + ) + self._commands = Commands( + self.envd_api_url, + self.connection_config, + self._transport.pool, + self._envd_version, + ) + self._pty = Pty( + self.envd_api_url, + self.connection_config, + self._transport.pool, + self._envd_version, + ) + + def is_running(self, request_timeout: Optional[float] = None) -> bool: + """ + Check if the sandbox is running. + + :param request_timeout: Timeout for the request in **seconds** + + :return: `True` if the sandbox is running, `False` otherwise + + Example + ```python + sandbox = Sandbox.create() + sandbox.is_running() # Returns True + + sandbox.kill() + sandbox.is_running() # Returns False + ``` + """ + try: + r = self._envd_api.get( + ENVD_API_HEALTH_ROUTE, + timeout=self.connection_config.get_request_timeout(request_timeout), + ) + + if r.status_code == 502: + return False + + err = handle_envd_api_exception(r) + + if err: + raise err + + except httpx.TimeoutException: + raise format_request_timeout_error() + + return True + + @classmethod + def create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: bool = True, + allow_internet_access: bool = True, + network: Optional[SandboxNetworkOpts] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Create a new sandbox. + + By default, the sandbox is created from the default `base` sandbox template. + + :param template: Sandbox template name or ID + :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param metadata: Custom metadata for the sandbox + :param envs: Custom environment variables for the sandbox + :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. + :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`. + :param network: Sandbox network configuration + + :return: A Sandbox instance for the new sandbox + + Use this method instead of using the constructor to create a new sandbox. + """ + if not template: + template = cls.default_template + + sandbox = cls._create( + template=template, + auto_pause=False, + timeout=timeout, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + network=network, + **opts, + ) + + return sandbox + + @overload + def connect( + self, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param timeout: Timeout for the sandbox in **seconds** + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = Sandbox.create() + sandbox.beta_pause() + + # Another code block + same_sandbox = sandbox.connect() + + :return: A running sandbox instance + """ + ... + + @overload + @classmethod + def connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the sandbox in **seconds**. + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = Sandbox.create() + Sandbox.beta_pause(sandbox.sandbox_id) + + # Another code block + same_sandbox = Sandbox.connect(sandbox.sandbox_id) + ``` + """ + ... + + @class_method_variant("_cls_connect") + def connect( + self, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + """ + Connect to a sandbox. If the sandbox is paused, it will be automatically resumed. + Sandbox must be either running or be paused. + + With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc). + + :param timeout: Timeout for the sandbox in **seconds**. + For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :return: A running sandbox instance + + @example + ```python + sandbox = Sandbox.create() + sandbox.beta_pause() + + # Another code block + same_sandbox = sandbox.connect() + ``` + """ + SandboxApi._cls_connect( + sandbox_id=self.sandbox_id, + timeout=timeout, + **opts, + ) + + return self + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.kill() + + @overload + def kill( + self, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox. + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + ... + + @overload + @staticmethod + def kill( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + ... + + @class_method_variant("_cls_kill") + def kill( + self, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Kill the sandbox specified by sandbox ID. + + :return: `True` if the sandbox was killed, `False` if the sandbox was not found + """ + return SandboxApi._cls_kill( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def set_timeout( + self, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the sandbox. + After the timeout expires, the sandbox will be automatically killed. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param timeout: Timeout for the sandbox in **seconds** + """ + ... + + @overload + @staticmethod + def set_timeout( + sandbox_id: str, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the sandbox specified by sandbox ID. + After the timeout expires, the sandbox will be automatically killed. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param sandbox_id: Sandbox ID + :param timeout: Timeout for the sandbox in **seconds** + """ + ... + + @class_method_variant("_cls_set_timeout") + def set_timeout( + self, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + """ + Set the timeout of the sandbox. + After the timeout expires, the sandbox will be automatically killed. + This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`. + + The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + + :param timeout: Timeout for the sandbox in **seconds** + + """ + + SandboxApi._cls_set_timeout( + sandbox_id=self.sandbox_id, + timeout=timeout, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def get_info( + self, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :return: Sandbox info + """ + ... + + @overload + @staticmethod + def get_info( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :param sandbox_id: Sandbox ID + + :return: Sandbox info + """ + ... + + @class_method_variant("_cls_get_info") + def get_info( + self, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get sandbox information like sandbox ID, template, metadata, started at/end at date. + + :return: Sandbox info + """ + return SandboxApi._cls_get_info( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def get_metrics( + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @overload + @staticmethod + def get_metrics( + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @class_method_variant("_cls_get_metrics") + def get_metrics( + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + """ + Get the metrics of the sandbox specified by sandbox ID. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to the current time + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + if self._envd_version < Version("0.1.5"): + raise SandboxException( + "Metrics are not supported in this version of the sandbox, please rebuild your template." + ) + + if self._envd_version < Version("0.2.4"): + logger.warning( + "Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics." + ) + + return SandboxApi._cls_get_metrics( + sandbox_id=self.sandbox_id, + start=start, + end=end, + **self.connection_config.get_api_params(**opts), + ) + + @classmethod + def beta_create( + cls, + template: Optional[str] = None, + timeout: Optional[int] = None, + auto_pause: bool = False, + metadata: Optional[Dict[str, str]] = None, + envs: Optional[Dict[str, str]] = None, + secure: bool = True, + allow_internet_access: bool = True, + **opts: Unpack[ApiParams], + ) -> Self: + """ + [BETA] This feature is in beta and may change in the future. + + Create a new sandbox. + + By default, the sandbox is created from the default `base` sandbox template. + + :param template: Sandbox template name or ID + :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users. + :param auto_pause: Automatically pause the sandbox after the timeout expires. Defaults to `False`. + :param metadata: Custom metadata for the sandbox + :param envs: Custom environment variables for the sandbox + :param secure: Envd is secured with access token and cannot be used without it, defaults to `True`. + :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. + + :return: A Sandbox instance for the new sandbox + + Use this method instead of using the constructor to create a new sandbox. + """ + + if not template: + template = cls.default_template + + sandbox = cls._create( + template=template, + auto_pause=auto_pause, + timeout=timeout, + metadata=metadata, + envs=envs, + secure=secure, + allow_internet_access=allow_internet_access, + **opts, + ) + + return sandbox + + @overload + def beta_pause( + self, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox. + """ + ... + + @overload + @classmethod + def beta_pause( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + """ + ... + + @class_method_variant("_cls_pause") + def beta_pause( + self, + **opts: Unpack[ApiParams], + ) -> None: + """ + [BETA] This feature is in beta and may change in the future. + + Pause the sandbox. + + :return: Sandbox ID that can be used to resume the sandbox + """ + + SandboxApi._cls_pause( + sandbox_id=self.sandbox_id, + **opts, + ) + + + + @classmethod + def _cls_connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Self: + sandbox = SandboxApi._cls_connect(sandbox_id, timeout, **opts) + + sandbox_headers = {} + envd_access_token = sandbox.envd_access_token + if envd_access_token is not None and not isinstance(envd_access_token, Unset): + sandbox_headers["X-Access-Token"] = envd_access_token + + connection_config = ConnectionConfig( + extra_sandbox_headers=sandbox_headers, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=sandbox.domain, + connection_config=connection_config, + envd_version=Version(sandbox.envd_version), + envd_access_token=envd_access_token, + traffic_access_token=sandbox.traffic_access_token, + ) + + @classmethod + def _create( + cls, + template: Optional[str], + timeout: Optional[int], + auto_pause: bool, + metadata: Optional[Dict[str, str]], + envs: Optional[Dict[str, str]], + secure: bool, + allow_internet_access: bool, + network: Optional[SandboxNetworkOpts] = None, + **opts: Unpack[ApiParams], + ) -> Self: + extra_sandbox_headers = {} + + debug = opts.get("debug") + if debug: + sandbox_id = "debug_sandbox_id" + sandbox_domain = None + envd_version = ENVD_DEBUG_FALLBACK + envd_access_token = None + traffic_access_token = None + else: + response = SandboxApi._create_sandbox( + template=template or cls.default_template, + timeout=timeout or cls.default_sandbox_timeout, + auto_pause=auto_pause, + metadata=metadata, + env_vars=envs, + secure=secure, + allow_internet_access=allow_internet_access, + network=network, + **opts, + ) + + sandbox_id = response.sandbox_id + sandbox_domain = response.sandbox_domain + envd_version = Version(response.envd_version) + envd_access_token = response.envd_access_token + traffic_access_token = response.traffic_access_token + + if envd_access_token is not None and not isinstance( + envd_access_token, Unset + ): + extra_sandbox_headers["X-Access-Token"] = envd_access_token + + extra_sandbox_headers["E2b-Sandbox-Id"] = sandbox_id + extra_sandbox_headers["E2b-Sandbox-Port"] = str(ConnectionConfig.envd_port) + + connection_config = ConnectionConfig( + extra_sandbox_headers=extra_sandbox_headers, + **opts, + ) + + return cls( + sandbox_id=sandbox_id, + sandbox_domain=sandbox_domain, + envd_version=envd_version, + envd_access_token=envd_access_token, + traffic_access_token=traffic_access_token, + connection_config=connection_config, + ) diff --git a/ucloud_sandbox/sandbox_sync/paginator.py b/ucloud_sandbox/sandbox_sync/paginator.py new file mode 100644 index 0000000..6888f16 --- /dev/null +++ b/ucloud_sandbox/sandbox_sync/paginator.py @@ -0,0 +1,69 @@ +import urllib.parse +from typing import Optional, List + +from ucloud_agentbox.api import handle_api_exception +from ucloud_agentbox.api.client.api.sandboxes import get_v2_sandboxes +from ucloud_agentbox.api.client.models.error import Error +from ucloud_agentbox.api.client.types import UNSET +from ucloud_agentbox.exceptions import SandboxException +from ucloud_agentbox.sandbox.sandbox_api import SandboxPaginatorBase, SandboxInfo +from ucloud_agentbox.api.client_sync import get_api_client + + +class SandboxPaginator(SandboxPaginatorBase): + """ + Paginator for listing sandboxes. + + Example: + ```python + paginator = Sandbox.list() + + while paginator.has_next: + sandboxes = paginator.next_items() + print(sandboxes) + ``` + """ + + def next_items(self) -> List[SandboxInfo]: + """ + Returns the next page of sandboxes. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :returns: List of sandboxes + """ + if not self.has_next: + raise Exception("No more items to fetch") + + # Convert filters to the format expected by the API + metadata: Optional[str] = None + if self.query and self.query.metadata: + quoted_metadata = { + urllib.parse.quote(k): urllib.parse.quote(v) + for k, v in self.query.metadata.items() + } + metadata = urllib.parse.urlencode(quoted_metadata) + + api_client = get_api_client(self._config) + res = get_v2_sandboxes.sync_detailed( + client=api_client, + metadata=metadata if metadata else UNSET, + state=self.query.state if self.query and self.query.state else UNSET, + limit=self.limit if self.limit else UNSET, + next_token=self._next_token if self._next_token else UNSET, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + self._next_token = res.headers.get("x-next-token") + self._has_next = bool(self._next_token) + + if res.parsed is None: + return [] + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed] diff --git a/ucloud_sandbox/sandbox_sync/sandbox_api.py b/ucloud_sandbox/sandbox_sync/sandbox_api.py new file mode 100644 index 0000000..eadec03 --- /dev/null +++ b/ucloud_sandbox/sandbox_sync/sandbox_api.py @@ -0,0 +1,305 @@ +import datetime +from typing import Dict, List, Optional + +from packaging.version import Version +from typing_extensions import Unpack + +from ucloud_agentbox.api import SandboxCreateResponse, handle_api_exception +from ucloud_agentbox.api.client.api.sandboxes import ( + delete_sandboxes_sandbox_id, + get_sandboxes_sandbox_id, + get_sandboxes_sandbox_id_metrics, + post_sandboxes, + post_sandboxes_sandbox_id_connect, + post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_timeout, +) +from ucloud_agentbox.api.client.models import ( + ConnectSandbox, + Error, + NewSandbox, + PostSandboxesSandboxIDTimeoutBody, + Sandbox, + SandboxNetworkConfig, +) +from ucloud_agentbox.api.client.types import UNSET +from ucloud_agentbox.connection_config import ApiParams, ConnectionConfig +from ucloud_agentbox.exceptions import NotFoundException, SandboxException, TemplateException +from ucloud_agentbox.sandbox.main import SandboxBase +from ucloud_agentbox.sandbox.sandbox_api import ( + SandboxInfo, + SandboxMetrics, + SandboxNetworkOpts, + SandboxQuery, +) +from ucloud_agentbox.sandbox_sync.paginator import SandboxPaginator, get_api_client + + +class SandboxApi(SandboxBase): + @staticmethod + def list( + query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SandboxPaginator: + """ + List all running sandboxes. + + :param query: Filter the list of sandboxes by metadata or state, e.g. `SandboxListQuery(metadata={"key": "value"})` or `SandboxListQuery(state=[SandboxState.RUNNING])` + :param limit: Maximum number of sandboxes to return per page + :param next_token: Token for pagination + + :return: List of running sandboxes + """ + return SandboxPaginator( + query=query, + limit=limit, + next_token=next_token, + **opts, + ) + + @classmethod + def _cls_get_info( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SandboxInfo: + """ + Get the sandbox info. + :param sandbox_id: Sandbox ID + + :return: Sandbox info + """ + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = get_sandboxes_sandbox_id.sync_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise SandboxException("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return SandboxInfo._from_sandbox_detail(res.parsed) + + @classmethod + def _cls_kill( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip killing the sandbox in debug mode + return True + + api_client = get_api_client(config) + res = delete_sandboxes_sandbox_id.sync_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + + @classmethod + def _cls_set_timeout( + cls, + sandbox_id: str, + timeout: int, + **opts: Unpack[ApiParams], + ) -> None: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip setting timeout in debug mode + return + + api_client = get_api_client(config) + res = post_sandboxes_sandbox_id_timeout.sync_detailed( + sandbox_id, + client=api_client, + body=PostSandboxesSandboxIDTimeoutBody(timeout=timeout), + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + @classmethod + def _create_sandbox( + cls, + template: str, + timeout: int, + auto_pause: bool, + allow_internet_access: bool, + metadata: Optional[Dict[str, str]], + env_vars: Optional[Dict[str, str]], + secure: bool, + network: Optional[SandboxNetworkOpts] = None, + **opts: Unpack[ApiParams], + ) -> SandboxCreateResponse: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = post_sandboxes.sync_detailed( + body=NewSandbox( + template_id=template, + auto_pause=auto_pause, + metadata=metadata or {}, + timeout=timeout, + env_vars=env_vars or {}, + secure=secure, + allow_internet_access=allow_internet_access, + network=SandboxNetworkConfig(**network) if network else UNSET, + ), + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise Exception("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + if Version(res.parsed.envd_version) < Version("0.1.0"): + SandboxApi._cls_kill(res.parsed.sandbox_id) + raise TemplateException( + "You need to update the template to use the new SDK. " + "You can do this by running `e2b template build` in the directory with the template." + ) + + return SandboxCreateResponse( + sandbox_id=res.parsed.sandbox_id, + sandbox_domain=res.parsed.domain, + envd_version=res.parsed.envd_version, + envd_access_token=res.parsed.envd_access_token, + traffic_access_token=res.parsed.traffic_access_token, + ) + + @classmethod + def _cls_get_metrics( + cls, + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + **opts: Unpack[ApiParams], + ) -> List[SandboxMetrics]: + config = ConnectionConfig(**opts) + + if config.debug: + # Skip getting the metrics in debug mode + return [] + + api_client = get_api_client(config) + res = get_sandboxes_sandbox_id_metrics.sync_detailed( + sandbox_id, + start=int(start.timestamp() * 1000) if start else None, + end=int(end.timestamp() * 1000) if end else None, + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + # Convert to typed SandboxMetrics objects + return [ + SandboxMetrics( + cpu_count=metric.cpu_count, + cpu_used_pct=metric.cpu_used_pct, + disk_total=metric.disk_total, + disk_used=metric.disk_used, + mem_total=metric.mem_total, + mem_used=metric.mem_used, + timestamp=metric.timestamp, + ) + for metric in res.parsed + ] + + @classmethod + def _cls_connect( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + **opts: Unpack[ApiParams], + ) -> Sandbox: + timeout = timeout or SandboxBase.default_sandbox_timeout + + config = ConnectionConfig(**opts) + + api_client = get_api_client( + config, + headers={ + "E2b-Sandbox-Id": sandbox_id, + "E2b-Sandbox-Port": str(config.envd_port), + }, + ) + res = post_sandboxes_sandbox_id_connect.sync_detailed( + sandbox_id, + client=api_client, + body=ConnectSandbox(timeout=timeout), + ) + + if res.status_code == 404: + raise NotFoundException(f"Paused sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return res.parsed + + @classmethod + def _cls_pause( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> str: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = post_sandboxes_sandbox_id_pause.sync_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code == 409: + return sandbox_id + + if res.status_code >= 300: + raise handle_api_exception(res) + + return sandbox_id diff --git a/ucloud_sandbox/template/consts.py b/ucloud_sandbox/template/consts.py new file mode 100644 index 0000000..41d6cb8 --- /dev/null +++ b/ucloud_sandbox/template/consts.py @@ -0,0 +1,30 @@ +""" +Special step name for the finalization phase of template building. +This is the last step that runs after all user-defined instructions. +""" + +FINALIZE_STEP_NAME = "finalize" + +""" +Special step name for the base image phase of template building. +This is the first step that sets up the base image. +""" +BASE_STEP_NAME = "base" + +""" +Stack trace depth for capturing caller information. + +Depth levels: +1. TemplateClass +2. Caller method (e.g., copy(), from_image(), etc.) + +This depth is used to determine the original caller's location +for stack traces. +""" +STACK_TRACE_DEPTH = 2 + +""" +Default setting for whether to resolve symbolic links when copying files. +When False, symlinks are copied as symlinks rather than following them. +""" +RESOLVE_SYMLINKS = False diff --git a/ucloud_sandbox/template/dockerfile_parser.py b/ucloud_sandbox/template/dockerfile_parser.py new file mode 100644 index 0000000..f04244a --- /dev/null +++ b/ucloud_sandbox/template/dockerfile_parser.py @@ -0,0 +1,275 @@ +import json +import os +import re +import tempfile +from typing import Dict, List, Optional, Protocol, Union, Literal + +from dockerfile_parse import DockerfileParser +from ucloud_agentbox.template.types import CopyItem + + +class DockerfFileFinalParserInterface(Protocol): + """Protocol defining the final interface for Dockerfile parsing callbacks.""" + + +class DockerfileParserInterface(Protocol): + """Protocol defining the interface for Dockerfile parsing callbacks.""" + + def run_cmd( + self, command: Union[str, List[str]], user: Optional[str] = None + ) -> "DockerfileParserInterface": + """Handle RUN instruction.""" + ... + + def copy( + self, + src: Union[str, List[CopyItem]], + dest: Optional[str] = None, + force_upload: Optional[Literal[True]] = None, + resolve_symlinks: Optional[bool] = None, + user: Optional[str] = None, + mode: Optional[int] = None, + ) -> "DockerfileParserInterface": + """Handle COPY instruction.""" + ... + + def set_workdir(self, workdir: str) -> "DockerfileParserInterface": + """Handle WORKDIR instruction.""" + ... + + def set_user(self, user: str) -> "DockerfileParserInterface": + """Handle USER instruction.""" + ... + + def set_envs(self, envs: Dict[str, str]) -> "DockerfileParserInterface": + """Handle ENV instruction.""" + ... + + def set_start_cmd( + self, start_cmd: str, ready_cmd: str + ) -> "DockerfFileFinalParserInterface": + """Handle CMD/ENTRYPOINT instruction.""" + ... + + +def parse_dockerfile( + dockerfile_content_or_path: str, template_builder: DockerfileParserInterface +) -> str: + """ + Parse a Dockerfile and convert it to Template SDK format. + + :param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file + :param template_builder: Interface providing template builder methods + + :return: The base image from the Dockerfile + + :raises ValueError: If the Dockerfile is invalid or unsupported + """ + # Check if input is a file path that exists + if os.path.isfile(dockerfile_content_or_path): + # Read the file content + with open(dockerfile_content_or_path, "r", encoding="utf-8") as f: + dockerfile_content = f.read() + else: + # Treat as content directly + dockerfile_content = dockerfile_content_or_path + + # Use a temporary directory to avoid creating files in the current directory + with tempfile.TemporaryDirectory() as temp_dir: + # Create a temporary Dockerfile + dockerfile_path = os.path.join(temp_dir, "Dockerfile") + with open(dockerfile_path, "w") as f: + f.write(dockerfile_content) + + dfp = DockerfileParser(path=temp_dir) + + # Check for multi-stage builds + from_instructions = [ + instruction + for instruction in dfp.structure + if instruction["instruction"] == "FROM" + ] + + if len(from_instructions) > 1: + raise ValueError("Multi-stage Dockerfiles are not supported") + + if len(from_instructions) == 0: + raise ValueError("Dockerfile must contain a FROM instruction") + + # Set the base image from the first FROM instruction + base_image = from_instructions[0]["value"] + # Remove AS alias if present (e.g., "node:18 AS builder" -> "node:18") + if " as " in base_image.lower(): + base_image = base_image.split(" as ")[0].strip() + + user_changed = False + workdir_changed = False + + # Set the user and workdir to the Docker defaults + template_builder.set_user("root") + template_builder.set_workdir("/") + + # Process all other instructions + for instruction_data in dfp.structure: + instruction = instruction_data["instruction"] + value = instruction_data["value"] + + if instruction == "FROM": + # Already handled above + continue + elif instruction == "RUN": + _handle_run_instruction(value, template_builder) + elif instruction in ["COPY", "ADD"]: + _handle_copy_instruction(value, template_builder) + elif instruction == "WORKDIR": + _handle_workdir_instruction(value, template_builder) + workdir_changed = True + elif instruction == "USER": + _handle_user_instruction(value, template_builder) + user_changed = True + elif instruction in ["ENV", "ARG"]: + _handle_env_instruction(value, instruction, template_builder) + elif instruction in ["CMD", "ENTRYPOINT"]: + _handle_cmd_entrypoint_instruction(value, template_builder) + else: + print(f"Unsupported instruction: {instruction}") + continue + + # Set the user and workdir to the E2B defaults + if not user_changed: + template_builder.set_user("user") + if not workdir_changed: + template_builder.set_workdir("/home/user") + + return base_image + + +def _handle_run_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle RUN instruction""" + if not value.strip(): + return + # Remove line continuations and normalize whitespace + command = re.sub(r"\\\s*\n\s*", " ", value).strip() + template_builder.run_cmd(command) + + +def _handle_copy_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle COPY/ADD instruction""" + if not value.strip(): + return + # Parse source and destination from COPY/ADD command + # Handle both quoted and unquoted paths + parts = [] + current_part = "" + in_quotes = False + quote_char = None + + i = 0 + while i < len(value): + char = value[i] + if char in ['"', "'"] and (i == 0 or value[i - 1] != "\\"): + if not in_quotes: + in_quotes = True + quote_char = char + elif char == quote_char: + in_quotes = False + quote_char = None + else: + current_part += char + elif char == " " and not in_quotes: + if current_part: + parts.append(current_part) + current_part = "" + else: + current_part += char + i += 1 + + if current_part: + parts.append(current_part) + + if len(parts) >= 2: + src = parts[0] + dest = parts[-1] # Last part is destination + template_builder.copy(src, dest) + + +def _handle_workdir_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle WORKDIR instruction""" + if not value.strip(): + return + workdir = value.strip() + template_builder.set_workdir(workdir) + + +def _handle_user_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle USER instruction""" + if not value.strip(): + return + user = value.strip() + template_builder.set_user(user) + + +def _handle_env_instruction( + value: str, instruction_type: str, template_builder: DockerfileParserInterface +) -> None: + """Handle ENV/ARG instruction""" + if not value.strip(): + return + + # Parse environment variables from the value + # Handle both "KEY=value" and "KEY value" formats + env_vars = {} + + # First try to split on = for KEY=value format + if "=" in value: + # Handle multiple KEY=value pairs on one line + pairs = re.findall(r"(\w+)=([^\s]*(?:\s+(?!\w+=)[^\s]*)*)", value) + for key, val in pairs: + env_vars[key] = val.strip("\"'") + else: + # Handle "KEY value" format + parts = value.split(None, 1) + if len(parts) == 2: + key, val = parts + env_vars[key] = val.strip("\"'") + elif len(parts) == 1 and instruction_type == "ARG": + # ARG without default value + key = parts[0] + env_vars[key] = "" + + # Add each environment variable + if env_vars: + template_builder.set_envs(env_vars) + + +def _handle_cmd_entrypoint_instruction( + value: str, template_builder: DockerfileParserInterface +) -> None: + """Handle CMD/ENTRYPOINT instruction - convert to set_start_cmd with 20s timeout""" + if not value.strip(): + return + command = value.strip() + + # Try to parse as JSON (for array format like CMD ["sleep", "infinity"]) + try: + parsed_command = json.loads(command) + if isinstance(parsed_command, list): + command = " ".join(str(item) for item in parsed_command) + except Exception: + pass + + # Import wait_for_timeout locally to avoid circular dependency + def wait_for_timeout(timeout: int) -> str: + # convert to seconds, but ensure minimum of 1 second + seconds = max(1, timeout // 1000) + return f"sleep {seconds}" + + template_builder.set_start_cmd(command, wait_for_timeout(20_000)) diff --git a/ucloud_sandbox/template/logger.py b/ucloud_sandbox/template/logger.py new file mode 100644 index 0000000..d69893d --- /dev/null +++ b/ucloud_sandbox/template/logger.py @@ -0,0 +1,232 @@ +import sys +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, TypedDict, Callable, Dict, Literal + +from rich.console import Console +from rich.style import Style +from rich.text import Text + +from ucloud_agentbox.template.utils import strip_ansi_escape_codes + +"""Log entry severity levels.""" +LogEntryLevel = Literal["debug", "info", "warn", "error"] + + +@dataclass +class LogEntry: + """ + Represents a single log entry from the template build process. + """ + + timestamp: datetime + level: LogEntryLevel + message: str + + def __post_init__(self): + self.message = strip_ansi_escape_codes(self.message) + + def __str__(self) -> str: + return f"[{self.timestamp.isoformat()}] [{self.level}] {self.message}" + + +@dataclass +class LogEntryStart(LogEntry): + """ + Special log entry indicating the start of a build process. + """ + + level: LogEntryLevel = field(default="debug", init=False) + + +@dataclass +class LogEntryEnd(LogEntry): + """ + Special log entry indicating the end of a build process. + """ + + level: LogEntryLevel = field(default="debug", init=False) + + +""" +Interval in milliseconds for updating the build timer display. +""" +TIMER_UPDATE_INTERVAL_MS = 150 + +""" +Default minimum log level to display. +""" +DEFAULT_LEVEL: LogEntryLevel = "info" + +""" +Colored labels for each log level. +""" +levels: Dict[LogEntryLevel, tuple[str, Style]] = { + "error": ("ERROR", Style(color="red")), + "warn": ("WARN ", Style(color="#FF4400")), + "info": ("INFO ", Style(color="#FF8800")), + "debug": ("DEBUG", Style(color="bright_black")), +} + +""" +Numeric ordering of log levels for comparison (lower = less severe). +""" +level_order = { + "debug": 0, + "info": 1, + "warn": 2, + "error": 3, +} + + +def set_interval(func, interval): + """ + Returns a stop function that can be called to cancel the interval. + + Similar to JavaScript's setInterval. + + :param func: Function to execute at each interval + :param interval: Interval duration in **seconds** + + :return: Stop function that can be called to cancel the interval + """ + stopped = threading.Event() + + def loop(): + while not stopped.is_set(): + if stopped.wait(interval): # wait returns True if stopped + break + if not stopped.is_set(): # Double-check before executing + func() + + threading.Thread(target=loop, daemon=True).start() + return stopped.set # Return the stop function + + +class DefaultBuildLoggerInitialState(TypedDict): + start_time: float + animation_frame: int + timer: Optional[Callable[[], None]] + + +class DefaultBuildLogger: + __console = Console() + + __min_level: LogEntryLevel + __state: DefaultBuildLoggerInitialState + + def __init__(self, min_level: Optional[LogEntryLevel] = None): + self.__min_level = min_level if min_level is not None else DEFAULT_LEVEL + self.__reset_initial_state() + + def logger(self, log): + if isinstance(log, LogEntryStart): + self.__start_timer() + return + + if isinstance(log, LogEntryEnd): + if self.__state["timer"] is not None: + self.__state["timer"]() + return + + # Filter by minimum level + if level_order[log.level] < level_order[self.__min_level]: + return + + formatted_line = self.__format_log_line(log) + self.__console.print(formatted_line) + + # Redraw the timer line + self.__update_timer() + + def __reset_initial_state(self, timer: Optional[Callable[[], None]] = None): + self.__state = { + "start_time": time.time(), + "animation_frame": 0, + "timer": timer, + } + + def __format_timer_line(self) -> str: + elapsed_seconds = time.time() - self.__state["start_time"] + return f"{elapsed_seconds:.1f}s" + + def __animate_status(self) -> str: + frames = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"] + idx = self.__state["animation_frame"] % len(frames) + return frames[idx] + + def __format_log_line(self, line: LogEntry) -> Text: + timer = self.__format_timer_line().ljust(5) + timestamp = line.timestamp.strftime("%H:%M:%S") + level_text, level_style = levels.get(line.level, levels[DEFAULT_LEVEL]) + + # Build a rich Text object + text = Text.assemble( + timer, + " | ", + (timestamp, "dim"), + " ", + (level_text, level_style), + " ", + line.message, + ) + + return text + + def __start_timer(self): + if not sys.stdout.isatty(): + return + + # Start the timer interval + stop_timer = set_interval( + self.__update_timer, TIMER_UPDATE_INTERVAL_MS / 1000.0 + ) + + self.__reset_initial_state(stop_timer) + + # Initial timer display + self.__update_timer() + + def __update_timer(self): + if not sys.stdout.isatty(): + return + + self.__state["animation_frame"] += 1 + jumping_squares = self.__animate_status() + + timer_text = Text.assemble( + jumping_squares, " Building ", self.__format_timer_line() + ) + + # Print with carriage return + self.__console.print(timer_text, end="\r") + + +def default_build_logger( + min_level: Optional[LogEntryLevel] = None, +) -> Callable[[LogEntry], None]: + """ + Create a default build logger with animated timer display. + + :param min_level: Minimum log level to display (default: 'info') + + :return: Logger function that accepts LogEntry instances + + Example + ```python + from e2b import Template, default_build_logger + + template = Template().from_python_image() + + # Use with build - implementation would be in build_async module + # await Template.build(template, + # alias='my-template', + # on_build_logs=default_build_logger(min_level='debug') + # ) + ``` + """ + build_logger = DefaultBuildLogger(min_level) + + return build_logger.logger diff --git a/ucloud_sandbox/template/main.py b/ucloud_sandbox/template/main.py new file mode 100644 index 0000000..2e65de9 --- /dev/null +++ b/ucloud_sandbox/template/main.py @@ -0,0 +1,1323 @@ +import json +from typing import Dict, List, Optional, Union, Literal +from pathlib import Path + + +from ucloud_agentbox.exceptions import BuildException +from ucloud_agentbox.template.consts import STACK_TRACE_DEPTH, RESOLVE_SYMLINKS +from ucloud_agentbox.template.dockerfile_parser import parse_dockerfile +from ucloud_agentbox.template.readycmd import ReadyCmd, wait_for_file +from ucloud_agentbox.template.types import ( + CopyItem, + Instruction, + TemplateType, + RegistryConfig, + InstructionType, +) +from ucloud_agentbox.template.utils import ( + calculate_files_hash, + get_caller_directory, + pad_octal, + read_dockerignore, + read_gcp_service_account_json, + get_caller_frame, +) +from types import TracebackType + + +class TemplateBuilder: + """ + Builder class for adding instructions to an AgentBox template. + + All methods return self to allow method chaining. + """ + + def __init__(self, template: "TemplateBase"): + self._template = template + + def copy( + self, + src: Union[Union[str, Path], List[Union[str, Path]]], + dest: Union[str, Path], + force_upload: Optional[Literal[True]] = None, + user: Optional[str] = None, + mode: Optional[int] = None, + resolve_symlinks: Optional[bool] = None, + ) -> "TemplateBuilder": + """ + Copy files or directories from the local filesystem into the template. + + :param src: Source file(s) or directory path(s) to copy + :param dest: Destination path in the template + :param force_upload: Force upload even if files are cached + :param user: User and optionally group (user:group) to own the files + :param mode: File permissions in octal format (e.g., 0o755) + :param resolve_symlinks: Whether to resolve symlinks + + :return: `TemplateBuilder` class + + Example + ```python + template.copy('requirements.txt', '/home/user/') + template.copy(['app.py', 'config.py'], '/app/', mode=0o755) + ``` + """ + srcs = [src] if isinstance(src, (str, Path)) else src + + for src_item in srcs: + args = [ + str(src_item), + str(dest), + user or "", + pad_octal(mode) if mode else "", + ] + + instruction: Instruction = { + "type": InstructionType.COPY, + "args": args, + "force": force_upload or self._template._force_next_layer, + "forceUpload": force_upload, + "resolveSymlinks": resolve_symlinks, + } + + self._template._instructions.append(instruction) + + self._template._collect_stack_trace() + return self + + def copy_items(self, items: List[CopyItem]) -> "TemplateBuilder": + """ + Copy multiple files or directories using a list of copy items. + + :param items: List of CopyItem dictionaries with src, dest, and optional parameters + + :return: `TemplateBuilder` class + + Example + ```python + template.copy_items([ + {'src': 'app.py', 'dest': '/app/'}, + {'src': 'config.py', 'dest': '/app/', 'mode': 0o644} + ]) + ``` + """ + self._template._run_in_new_stack_trace_context( + lambda: [ + self.copy( + item["src"], + item["dest"], + item.get("forceUpload"), + item.get("user"), + item.get("mode"), + item.get("resolveSymlinks"), + ) + for item in items + ] + ) + return self + + def remove( + self, + path: Union[Union[str, Path], List[Union[str, Path]]], + force: bool = False, + recursive: bool = False, + user: Optional[str] = None, + ) -> "TemplateBuilder": + """ + Remove files or directories in the template. + + :param path: File(s) or directory path(s) to remove + :param force: Force removal without prompting + :param recursive: Remove directories recursively + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.remove('/tmp/cache', recursive=True, force=True) + template.remove('/tmp/cache', recursive=True, force=True, user='root') + ``` + """ + paths = [path] if isinstance(path, (str, Path)) else path + args = ["rm"] + if recursive: + args.append("-r") + if force: + args.append("-f") + args.extend([str(p) for p in paths]) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def rename( + self, + src: Union[str, Path], + dest: Union[str, Path], + force: bool = False, + user: Optional[str] = None, + ) -> "TemplateBuilder": + """ + Rename or move a file or directory in the template. + + :param src: Source path + :param dest: Destination path + :param force: Force rename without prompting + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.rename('/tmp/old.txt', '/tmp/new.txt') + template.rename('/tmp/old.txt', '/tmp/new.txt', user='root') + ``` + """ + args = ["mv", str(src), str(dest)] + if force: + args.append("-f") + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def make_dir( + self, + path: Union[Union[str, Path], List[Union[str, Path]]], + mode: Optional[int] = None, + user: Optional[str] = None, + ) -> "TemplateBuilder": + """ + Create directory(ies) in the template. + + :param path: Directory path(s) to create + :param mode: Directory permissions in octal format (e.g., 0o755) + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.make_dir('/app/data', mode=0o755) + template.make_dir(['/app/logs', '/app/cache']) + template.make_dir('/app/data', mode=0o755, user='root') + ``` + """ + path_list = [path] if isinstance(path, (str, Path)) else path + args = ["mkdir", "-p"] + if mode: + args.append(f"-m {pad_octal(mode)}") + args.extend([str(p) for p in path_list]) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def make_symlink( + self, + src: Union[str, Path], + dest: Union[str, Path], + user: Optional[str] = None, + force: bool = False, + ) -> "TemplateBuilder": + """ + Create a symbolic link in the template. + + :param src: Source path (target of the symlink) + :param dest: Destination path (location of the symlink) + :param user: User to run the command as + :param force: Force symlink without prompting + + :return: `TemplateBuilder` class + + Example + ```python + template.make_symlink('/usr/bin/python3', '/usr/bin/python') + template.make_symlink('/usr/bin/python3', '/usr/bin/python', user='root') + template.make_symlink('/usr/bin/python3', '/usr/bin/python', force=True) + ``` + """ + args = ["ln", "-s"] + if force: + args.append("-f") + args.extend([str(src), str(dest)]) + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def run_cmd( + self, command: Union[str, List[str]], user: Optional[str] = None + ) -> "TemplateBuilder": + """ + Run a shell command during template build. + + :param command: Command string or list of commands to run (joined with &&) + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.run_cmd('apt-get update') + template.run_cmd(['pip install numpy', 'pip install pandas']) + template.run_cmd('apt-get install vim', user='root') + ``` + """ + commands = [command] if isinstance(command, str) else command + args = [" && ".join(commands)] + + if user: + args.append(user) + + instruction: Instruction = { + "type": InstructionType.RUN, + "args": args, + "force": self._template._force_next_layer, + "forceUpload": None, + } + self._template._instructions.append(instruction) + self._template._collect_stack_trace() + return self + + def set_workdir(self, workdir: Union[str, Path]) -> "TemplateBuilder": + """ + Set the working directory for subsequent commands in the template. + + :param workdir: Path to set as the working directory + + :return: `TemplateBuilder` class + + Example + ```python + template.set_workdir('/app') + ``` + """ + instruction: Instruction = { + "type": InstructionType.WORKDIR, + "args": [str(workdir)], + "force": self._template._force_next_layer, + "forceUpload": None, + } + self._template._instructions.append(instruction) + self._template._collect_stack_trace() + return self + + def set_user(self, user: str) -> "TemplateBuilder": + """ + Set the user for subsequent commands in the template. + + :param user: Username to set + + :return: `TemplateBuilder` class + + Example + ```python + template.set_user('root') + ``` + """ + instruction: Instruction = { + "type": InstructionType.USER, + "args": [user], + "force": self._template._force_next_layer, + "forceUpload": None, + } + self._template._instructions.append(instruction) + self._template._collect_stack_trace() + return self + + def pip_install( + self, packages: Optional[Union[str, List[str]]] = None, g: bool = True + ) -> "TemplateBuilder": + """ + Install Python packages using pip. + + :param packages: Package name(s) to install. If None, runs 'pip install .' in the current directory + :param g: Install packages globally (default: True). If False, installs for user only + + :return: `TemplateBuilder` class + + Example + ```python + template.pip_install('numpy') + template.pip_install(['pandas', 'scikit-learn']) + template.pip_install('numpy', g=False) # Install for user only + template.pip_install() # Installs from current directory + ``` + """ + if isinstance(packages, str): + packages = [packages] + + args = ["pip", "install"] + if not g: + args.append("--user") + if packages: + args.extend(packages) + else: + args.append(".") + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user="root" if g else None) + ) + + def npm_install( + self, + packages: Optional[Union[str, List[str]]] = None, + g: Optional[bool] = False, + dev: Optional[bool] = False, + ) -> "TemplateBuilder": + """ + Install Node.js packages using npm. + + :param packages: Package name(s) to install. If None, installs from package.json + :param g: Install packages globally + :param dev: Install packages as dev dependencies + + :return: `TemplateBuilder` class + + Example + ```python + template.npm_install('express') + template.npm_install(['lodash', 'axios']) + template.npm_install('typescript', g=True) + template.npm_install() # Installs from package.json + ``` + """ + if isinstance(packages, str): + packages = [packages] + + args = ["npm", "install"] + if g: + args.append("-g") + if dev: + args.append("--save-dev") + if packages: + args.extend(packages) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user="root" if g else None) + ) + + def bun_install( + self, + packages: Optional[Union[str, List[str]]] = None, + g: Optional[bool] = False, + dev: Optional[bool] = False, + ) -> "TemplateBuilder": + """ + Install Bun packages using bun. + + :param packages: Package name(s) to install. If None, installs from package.json + :param g: Install packages globally + :param dev: Install packages as dev dependencies + + :return: `TemplateBuilder` class + + Example + ```python + template.bun_install('express') + template.bun_install(['lodash', 'axios']) + template.bun_install('tsx', g=True) + template.bun_install('typescript', dev=True) + template.bun_install() // Installs from package.json + ``` + """ + if isinstance(packages, str): + packages = [packages] + + args = ["bun", "install"] + if g: + args.append("-g") + if dev: + args.append("--dev") + if packages: + args.extend(packages) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user="root" if g else None) + ) + + def apt_install( + self, packages: Union[str, List[str]], no_install_recommends: bool = False + ) -> "TemplateBuilder": + """ + Install system packages using apt-get. + + :param packages: Package name(s) to install + :param no_install_recommends: Whether to install recommended packages + + :return: `TemplateBuilder` class + + Example + ```python + template.apt_install('vim') + template.apt_install(['git', 'curl', 'wget']) + ``` + """ + if isinstance(packages, str): + packages = [packages] + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd( + [ + "apt-get update", + f"DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y {'--no-install-recommends ' if no_install_recommends else ''}{' '.join(packages)}", + ], + user="root", + ) + ) + + + def git_clone( + self, + url: str, + path: Optional[Union[str, Path]] = None, + branch: Optional[str] = None, + depth: Optional[int] = None, + user: Optional[str] = None, + ) -> "TemplateBuilder": + """ + Clone a git repository into the template. + + :param url: Git repository URL + :param path: Destination path for the clone + :param branch: Branch to clone + :param depth: Clone depth for shallow clones + :param user: User to run the command as + + :return: `TemplateBuilder` class + + Example + ```python + template.git_clone('https://github.com/user/repo.git', '/app/repo') + template.git_clone('https://github.com/user/repo.git', branch='main', depth=1) + template.git_clone('https://github.com/user/repo.git', '/app/repo', user='root') + ``` + """ + args = ["git", "clone", url] + if branch: + args.append(f"--branch {branch}") + args.append("--single-branch") + if depth: + args.append(f"--depth {depth}") + if path: + args.append(str(path)) + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd(" ".join(args), user=user) + ) + + def beta_dev_container_prebuild( + self, + devcontainer_directory: Union[str, Path], + ) -> "TemplateBuilder": + """ + Prebuild a devcontainer from the specified directory during the build process. + + :param devcontainer_directory: Path to the devcontainer directory + + :return: `TemplateBuilder` class + + Example + ```python + template.git_clone('https://myrepo.com/project.git', '/my-devcontainer') + template.beta_dev_container_prebuild('/my-devcontainer') + ``` + """ + if self._template._base_template != "devcontainer": + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = None + if caller_frame is not None: + stack_trace = TracebackType( + tb_next=None, + tb_frame=caller_frame, + tb_lasti=caller_frame.f_lasti, + tb_lineno=caller_frame.f_lineno, + ) + raise BuildException( + "Devcontainers can only used in the devcontainer template" + ).with_traceback(stack_trace) + + return self._template._run_in_new_stack_trace_context( + lambda: self.run_cmd( + f"devcontainer build --workspace-folder {devcontainer_directory}", + user="root", + ) + ) + + def beta_set_dev_container_start( + self, + devcontainer_directory: Union[str, Path], + ) -> "TemplateFinal": + """ + Start a devcontainer from the specified directory and set it as the start command. + + This method returns `TemplateFinal`, which means it must be the last method in the chain. + + :param devcontainer_directory: Path to the devcontainer directory + + :return: `TemplateFinal` class + + Example + ```python + # Simple start + template.git_clone('https://myrepo.com/project.git', '/my-devcontainer') + template.beta_set_devcontainer_start('/my-devcontainer') + + # With prebuild + template.git_clone('https://myrepo.com/project.git', '/my-devcontainer') + template.beta_dev_container_prebuild('/my-devcontainer') + template.beta_set_dev_container_start('/my-devcontainer') + ``` + """ + if self._template._base_template != "devcontainer": + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = None + if caller_frame is not None: + stack_trace = TracebackType( + tb_next=None, + tb_frame=caller_frame, + tb_lasti=caller_frame.f_lasti, + tb_lineno=caller_frame.f_lineno, + ) + raise BuildException( + "Devcontainers can only used in the devcontainer template" + ).with_traceback(stack_trace) + + def _set_start(): + return self.set_start_cmd( + "sudo devcontainer up --workspace-folder " + + str(devcontainer_directory) + + " && sudo /prepare-exec.sh " + + str(devcontainer_directory) + + " | sudo tee /devcontainer.sh > /dev/null && sudo chmod +x /devcontainer.sh && sudo touch /devcontainer.up", + wait_for_file("/devcontainer.up"), + ) + + return self._template._run_in_new_stack_trace_context(_set_start) + + def set_envs(self, envs: Dict[str, str]) -> "TemplateBuilder": + """ + Set environment variables in the template. + + :param envs: Dictionary of environment variable names and values + + :return: `TemplateBuilder` class + + Example + ```python + template.set_envs({'NODE_ENV': 'production', 'PORT': '8080'}) + ``` + """ + if len(envs) == 0: + return self + + instruction: Instruction = { + "type": InstructionType.ENV, + "args": [item for key, value in envs.items() for item in [key, value]], + "force": self._template._force_next_layer, + "forceUpload": None, + } + self._template._instructions.append(instruction) + self._template._collect_stack_trace() + return self + + def skip_cache(self) -> "TemplateBuilder": + """ + Skip cache for all subsequent build instructions from this point. + + Call this before any instruction to force it and all following layers + to be rebuilt, ignoring any cached layers. + + :return: `TemplateBuilder` class + + Example + ```python + template.skip_cache().run_cmd('apt-get update') + ``` + """ + self._template._force_next_layer = True + return self + + def set_start_cmd( + self, start_cmd: str, ready_cmd: Union[str, ReadyCmd] + ) -> "TemplateFinal": + """ + Set the command to start when the sandbox launches and the ready check command. + + :param start_cmd: Command to run when the sandbox starts + :param ready_cmd: Command or ReadyCmd to check if the sandbox is ready + + :return: `TemplateFinal` class + + Example + ```python + # Using a string command + template.set_start_cmd( + 'python app.py', + 'curl http://localhost:8000/health' + ) + + # Using ReadyCmd helpers + from ucloud_agentbox import wait_for_port, wait_for_url + + template.set_start_cmd( + 'python -m http.server 8000', + wait_for_port(8000) + ) + + template.set_start_cmd( + 'npm start', + wait_for_url('http://localhost:3000/health', 200) + ) + ``` + """ + self._template._start_cmd = start_cmd + + if isinstance(ready_cmd, ReadyCmd): + ready_cmd = ready_cmd.get_cmd() + + self._template._ready_cmd = ready_cmd + self._template._collect_stack_trace() + return TemplateFinal(self._template) + + def set_ready_cmd(self, ready_cmd: Union[str, ReadyCmd]) -> "TemplateFinal": + """ + Set the command to check if the sandbox is ready. + + :param ready_cmd: Command or ReadyCmd to check if the sandbox is ready + + :return: `TemplateFinal` class + + Example + ```python + # Using a string command + template.set_ready_cmd('curl http://localhost:8000/health') + + # Using ReadyCmd helpers + from ucloud_agentbox import wait_for_port, wait_for_file, wait_for_process + + template.set_ready_cmd(wait_for_port(3000)) + + template.set_ready_cmd(wait_for_file('/tmp/ready')) + + template.set_ready_cmd(wait_for_process('nginx')) + ``` + """ + if isinstance(ready_cmd, ReadyCmd): + ready_cmd = ready_cmd.get_cmd() + + self._template._ready_cmd = ready_cmd + self._template._collect_stack_trace() + return TemplateFinal(self._template) + + +class TemplateFinal: + """ + Final template state after start/ready commands are set. + """ + + def __init__(self, template: "TemplateBase"): + self._template = template + + +class TemplateBase: + """ + Base class for building AgentBox sandbox templates. + """ + + _logs_refresh_frequency = 0.2 + + def __init__( + self, + file_context_path: Optional[Union[str, Path]] = None, + file_ignore_patterns: Optional[List[str]] = None, + ): + """ + Create a new template builder instance. + + :param file_context_path: Base path for resolving relative file paths in copy operations + :param file_ignore_patterns: List of glob patterns to ignore when copying files + """ + self._default_base_image: str = "ucloud/agentbox-base" + self._base_image: Optional[str] = self._default_base_image + self._base_template: Optional[str] = None + self._registry_config: Optional[RegistryConfig] = None + self._start_cmd: Optional[str] = None + self._ready_cmd: Optional[str] = None + # Force the whole template to be rebuilt + self._force: bool = False + # Force the next layer to be rebuilt + self._force_next_layer: bool = False + self._instructions: List[Instruction] = [] + # If no file_context_path is provided, use the caller's directory + self._file_context_path = ( + file_context_path.as_posix() + if isinstance(file_context_path, Path) + else (file_context_path or get_caller_directory(STACK_TRACE_DEPTH) or ".") + ) + self._file_ignore_patterns: List[str] = file_ignore_patterns or [] + self._stack_traces: List[Union[TracebackType, None]] = [] + self._stack_traces_enabled: bool = True + self._stack_traces_override: Optional[Union[TracebackType, None]] = None + + def skip_cache(self) -> "TemplateBase": + """ + Skip cache for all subsequent build instructions from this point. + + :return: `TemplateBase` class + + Example + ```python + template.skip_cache().from_python_image('3.11') + ``` + """ + self._force_next_layer = True + return self + + def _collect_stack_trace( + self, stack_traces_depth: int = STACK_TRACE_DEPTH + ) -> "TemplateBase": + """ + Collect the current stack trace for debugging purposes. + + :param stack_traces_depth: Depth to traverse in the call stack + + :return: `TemplateBase` class + """ + if not self._stack_traces_enabled: + return self + + # Use the override if set, otherwise get the caller frame + if self._stack_traces_override is not None: + self._stack_traces.append(self._stack_traces_override) + return self + + stack = get_caller_frame(stack_traces_depth) + if stack is None: + self._stack_traces.append(None) + return self + + # Create a traceback object from the caller frame + capture_stack_trace = TracebackType( + tb_next=None, + tb_frame=stack, + tb_lasti=stack.f_lasti, + tb_lineno=stack.f_lineno, + ) + + self._stack_traces.append(capture_stack_trace) + return self + + def _disable_stack_trace(self) -> "TemplateBase": + """ + Temporarily disable stack trace collection. + + :return: `TemplateBase` class + """ + self._stack_traces_enabled = False + return self + + def _enable_stack_trace(self) -> "TemplateBase": + """ + Re-enable stack trace collection. + + :return: `TemplateBase` class + """ + self._stack_traces_enabled = True + return self + + def _run_in_new_stack_trace_context(self, fn): + """ + Execute a function in a clean stack trace context. + + :param fn: Function to execute + + :return: The result of the function + """ + self._disable_stack_trace() + result = fn() + self._enable_stack_trace() + self._collect_stack_trace(STACK_TRACE_DEPTH + 1) + return result + + def _run_in_stack_trace_override_context( + self, fn, stack_trace_override: Optional[Union[TracebackType, None]] + ): + """ + Execute a function with a manual stack trace override. + + :param fn: Function to execute + :param stack_trace_override: Stack trace to use instead of auto-collecting + + :return: The result of the function + """ + self._stack_traces_override = stack_trace_override + result = fn() + self._stack_traces_override = None + return result + + # Built-in image mixins + def from_debian_image(self, variant: str = "stable") -> TemplateBuilder: + """ + Start template from a Debian base image. + + :param variant: Debian image variant + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_debian_image('bookworm') + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"debian:{variant}") + ) + + def from_ubuntu_image(self, variant: str = "latest") -> TemplateBuilder: + """ + Start template from an Ubuntu base image. + + :param variant: Ubuntu image variant (default: 'latest') + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_ubuntu_image('24.04') + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"ubuntu:{variant}") + ) + + def from_python_image(self, version: str = "3") -> TemplateBuilder: + """ + Start template from a Python base image. + + :param version: Python version (default: '3') + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_python_image('3') + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"python:{version}") + ) + + def from_node_image(self, variant: str = "lts") -> TemplateBuilder: + """ + Start template from a Node.js base image. + + :param variant: Node.js image variant (default: 'lts') + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_node_image('24') + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"node:{variant}") + ) + + def from_bun_image(self, variant: str = "latest") -> TemplateBuilder: + """ + Start template from a Bun base image. + + :param variant: Bun image variant (default: 'latest') + + :return: `TemplateBuilder` class + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(f"oven/bun:{variant}") + ) + + def from_base_image(self) -> TemplateBuilder: + """ + Start template from the AgentBox base image (ucloud/agentbox-base:latest). + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_base_image() + ``` + """ + return self._run_in_new_stack_trace_context( + lambda: self.from_image(self._default_base_image) + ) + + def from_image( + self, + image: str, + username: Optional[str] = None, + password: Optional[str] = None, + ) -> TemplateBuilder: + """ + Start template from a Docker image. + + :param image: Docker image name (e.g., 'ubuntu:24.04') + :param username: Username for private registry authentication + :param password: Password for private registry authentication + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_image('python:3') + + # With credentials (optional) + Template().from_image('myregistry.com/myimage:latest', username='user', password='pass') + ``` + """ + self._base_image = image + self._base_template = None + + # Set the registry config if provided + if username and password: + self._registry_config = { + "type": "registry", + "username": username, + "password": password, + } + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return TemplateBuilder(self) + + def from_template(self, template: str) -> TemplateBuilder: + """ + Start template from an existing AgentBox template. + + :param template: AgentBox template ID or alias + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_template('my-base-template') + ``` + """ + self._base_template = template + self._base_image = None + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return TemplateBuilder(self) + + def from_dockerfile(self, dockerfile_content_or_path: str) -> TemplateBuilder: + """ + Parse a Dockerfile and convert it to Template SDK format. + + :param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_dockerfile('Dockerfile') + Template().from_dockerfile('FROM python:3\\nRUN pip install numpy') + ``` + """ + # Create a TemplateBuilder first to use its methods + builder = TemplateBuilder(self) + + # Get the caller frame to use for stack trace override + # -1 as we're going up the call stack from the parse_dockerfile function + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace_override = None + if caller_frame is not None: + stack_trace_override = TracebackType( + tb_next=None, + tb_frame=caller_frame, + tb_lasti=caller_frame.f_lasti, + tb_lineno=caller_frame.f_lineno, + ) + + # Parse the dockerfile using the builder as the interface + base_image = self._run_in_stack_trace_override_context( + lambda: parse_dockerfile(dockerfile_content_or_path, builder), + stack_trace_override, + ) + self._base_image = base_image + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return builder + + def from_aws_registry( + self, + image: str, + access_key_id: str, + secret_access_key: str, + region: str, + ) -> TemplateBuilder: + """ + Start template from an AWS ECR registry image. + + :param image: Docker image name from AWS ECR + :param access_key_id: AWS access key ID + :param secret_access_key: AWS secret access key + :param region: AWS region + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_aws_registry( + '123456789.dkr.ecr.us-west-2.amazonaws.com/myimage:latest', + access_key_id='AKIA...', + secret_access_key='...', + region='us-west-2' + ) + ``` + """ + self._base_image = image + self._base_template = None + + # Set the registry config if provided + self._registry_config = { + "type": "aws", + "awsAccessKeyId": access_key_id, + "awsSecretAccessKey": secret_access_key, + "awsRegion": region, + } + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return TemplateBuilder(self) + + def from_gcp_registry( + self, image: str, service_account_json: Union[str, dict] + ) -> TemplateBuilder: + """ + Start template from a GCP Artifact Registry or Container Registry image. + + :param image: Docker image name from GCP registry + :param service_account_json: Service account JSON string, dict, or path to JSON file + + :return: `TemplateBuilder` class + + Example + ```python + Template().from_gcp_registry( + 'gcr.io/myproject/myimage:latest', + service_account_json='path/to/service-account.json' + ) + ``` + """ + self._base_image = image + self._base_template = None + + # Set the registry config if provided + self._registry_config = { + "type": "gcp", + "serviceAccountJson": read_gcp_service_account_json( + self._file_context_path, service_account_json + ), + } + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + self._collect_stack_trace() + return TemplateBuilder(self) + + @staticmethod + def to_json(template: "TemplateClass") -> str: + """ + Convert a template to JSON representation. + + :param template: The template to convert (TemplateBuilder or TemplateFinal instance) + + :return: JSON string representation of the template + + Example + ```python + template = Template().from_python_image('3').copy('app.py', '/app/') + json_str = TemplateBase.to_json(template) + ``` + """ + return json.dumps( + template._template._serialize( + template._template._instructions_with_hashes() + ), + indent=2, + ) + + @staticmethod + def to_dockerfile(template: "TemplateClass") -> str: + """ + Convert a template to Dockerfile format. + + Note: Templates based on other AgentBox templates cannot be converted to Dockerfile. + + :param template: The template to convert (TemplateBuilder or TemplateFinal instance) + + :return: Dockerfile string representation + + :raises ValueError: If the template is based on another AgentBox template or has no base image + + Example + ```python + template = Template().from_python_image('3').copy('app.py', '/app/') + dockerfile = TemplateBase.to_dockerfile(template) + ``` + """ + if template._template._base_template is not None: + raise ValueError( + "Cannot convert template built from another template to Dockerfile. " + "Templates based on other templates can only be built using the AgentBox API." + ) + + if template._template._base_image is None: + raise ValueError("No base image specified for template") + + dockerfile = f"FROM {template._template._base_image}\n" + + for instruction in template._template._instructions: + if instruction["type"] == InstructionType.RUN: + dockerfile += f"RUN {instruction['args'][0]}\n" + continue + + if instruction["type"] == InstructionType.COPY: + dockerfile += ( + f"COPY {instruction['args'][0]} {instruction['args'][1]}\n" + ) + continue + + if instruction["type"] == InstructionType.ENV: + args = instruction["args"] + values = [] + for i in range(0, len(args), 2): + values.append(f"{args[i]}={args[i + 1]}") + dockerfile += f"ENV {' '.join(values)}\n" + continue + + dockerfile += ( + f"{instruction['type'].value} {' '.join(instruction['args'])}\n" + ) + + if template._template._start_cmd: + dockerfile += f"ENTRYPOINT {template._template._start_cmd}\n" + + return dockerfile + + def _instructions_with_hashes( + self, + ) -> List[Instruction]: + """ + Add file hashes to COPY instructions for cache invalidation. + + :return: Copy of instructions list with filesHash added to COPY instructions + """ + steps: List[Instruction] = [] + + for index, instruction in enumerate(self._instructions): + step: Instruction = { + "type": instruction["type"], + "args": instruction["args"], + "force": instruction["force"], + "forceUpload": instruction.get("forceUpload"), + "resolveSymlinks": instruction.get("resolveSymlinks"), + } + + if instruction["type"] == InstructionType.COPY: + stack_trace = None + if index + 1 < len(self._stack_traces): + stack_trace = self._stack_traces[index + 1] + + args = instruction.get("args", []) + src = args[0] if len(args) > 0 else None + dest = args[1] if len(args) > 1 else None + if src is None or dest is None: + raise ValueError("Source path and destination path are required") + + resolve_symlinks = instruction.get("resolveSymlinks") + step["filesHash"] = calculate_files_hash( + src, + dest, + self._file_context_path, + [ + *self._file_ignore_patterns, + *read_dockerignore(self._file_context_path), + ], + resolve_symlinks + if resolve_symlinks is not None + else RESOLVE_SYMLINKS, + stack_trace, + ) + + steps.append(step) + + return steps + + def _serialize(self, steps: List[Instruction]) -> TemplateType: + """ + Serialize the template to the API request format. + + :param steps: List of build instructions with file hashes + + :return: Template data formatted for the API + """ + _steps: List[Instruction] = [] + + for _, instruction in enumerate(steps): + step: Instruction = { + "type": instruction.get("type"), + "args": instruction.get("args"), + "force": instruction.get("force"), + } + + files_hash = instruction.get("filesHash") + if files_hash is not None: + step["filesHash"] = files_hash + + force_upload = instruction.get("forceUpload") + if force_upload is not None: + step["forceUpload"] = force_upload + + _steps.append(step) + + template_data: TemplateType = { + "steps": _steps, + "force": self._force, + } + + if self._base_image is not None: + template_data["fromImage"] = self._base_image + + if self._base_template is not None: + template_data["fromTemplate"] = self._base_template + + if self._registry_config is not None: + template_data["fromImageRegistry"] = self._registry_config + + if self._start_cmd is not None: + template_data["startCmd"] = self._start_cmd + + if self._ready_cmd is not None: + template_data["readyCmd"] = self._ready_cmd + + return template_data + + +TemplateClass = Union[TemplateFinal, TemplateBuilder] diff --git a/ucloud_sandbox/template/readycmd.py b/ucloud_sandbox/template/readycmd.py new file mode 100644 index 0000000..4523e98 --- /dev/null +++ b/ucloud_sandbox/template/readycmd.py @@ -0,0 +1,138 @@ +class ReadyCmd: + """ + Wrapper class for ready check commands. + """ + + def __init__(self, cmd: str): + self.__cmd = cmd + + def get_cmd(self): + return self.__cmd + + +def wait_for_port(port: int): + """ + Wait for a port to be listening. + + Uses `ss` command to check if a port is open and listening. + + :param port: Port number to wait for + + :return: ReadyCmd that checks for the port + + Example + ```python + from ucloud_agentbox import Template, wait_for_port + + template = ( + Template() + .from_python_image() + .set_start_cmd('python -m http.server 8000', wait_for_port(8000)) + ) + ``` + """ + cmd = f"ss -tuln | grep :{port}" + return ReadyCmd(cmd) + + +def wait_for_url(url: str, status_code: int = 200): + """ + Wait for a URL to return a specific HTTP status code. + + Uses `curl` to make HTTP requests and check the response status. + + :param url: URL to check (e.g., 'http://localhost:3000/health') + :param status_code: Expected HTTP status code (default: 200) + + :return: ReadyCmd that checks the URL + + Example + ```python + from ucloud_agentbox import Template, wait_for_url + + template = ( + Template() + .from_node_image() + .set_start_cmd('npm start', wait_for_url('http://localhost:3000/health')) + ) + ``` + """ + cmd = f'curl -s -o /dev/null -w "%{{http_code}}" {url} | grep -q "{status_code}"' + return ReadyCmd(cmd) + + +def wait_for_process(process_name: str): + """ + Wait for a process with a specific name to be running. + + Uses `pgrep` to check if a process exists. + + :param process_name: Name of the process to wait for + + :return: ReadyCmd that checks for the process + + Example + ```python + from ucloud_agentbox import Template, wait_for_process + + template = ( + Template() + .from_base_image() + .set_start_cmd('./my-daemon', wait_for_process('my-daemon')) + ) + ``` + """ + cmd = f"pgrep {process_name} > /dev/null" + return ReadyCmd(cmd) + + +def wait_for_file(filename: str): + """ + Wait for a file to exist. + + Uses shell test command to check file existence. + + :param filename: Path to the file to wait for + + :return: ReadyCmd that checks for the file + + Example + ```python + from ucloud_agentbox import Template, wait_for_file + + template = ( + Template() + .from_base_image() + .set_start_cmd('./init.sh', wait_for_file('/tmp/ready')) + ) + ``` + """ + cmd = f"[ -f {filename} ]" + return ReadyCmd(cmd) + + +def wait_for_timeout(timeout: int): + """ + Wait for a specified timeout before considering the sandbox ready. + + Uses `sleep` command to wait for a fixed duration. + + :param timeout: Time to wait in **milliseconds** (minimum: 1000ms / 1 second) + + :return: ReadyCmd that waits for the specified duration + + Example + ```python + from ucloud_agentbox import Template, wait_for_timeout + + template = ( + Template() + .from_node_image() + .set_start_cmd('npm start', wait_for_timeout(5000)) # Wait 5 seconds + ) + ``` + """ + # convert to seconds, but ensure minimum of 1 second + seconds = max(1, timeout // 1000) + cmd = f"sleep {seconds}" + return ReadyCmd(cmd) diff --git a/ucloud_sandbox/template/types.py b/ucloud_sandbox/template/types.py new file mode 100644 index 0000000..a1e8406 --- /dev/null +++ b/ucloud_sandbox/template/types.py @@ -0,0 +1,105 @@ +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import List, Literal, Optional, TypedDict, Union + +from typing_extensions import NotRequired + + +class InstructionType(str, Enum): + """ + Types of instructions that can be used in a template. + """ + + COPY = "COPY" + ENV = "ENV" + RUN = "RUN" + WORKDIR = "WORKDIR" + USER = "USER" + + +class CopyItem(TypedDict): + """ + Configuration for a single file/directory copy operation. + """ + + src: Union[Union[str, Path], List[Union[str, Path]]] + dest: Union[str, Path] + forceUpload: NotRequired[Optional[Literal[True]]] + user: NotRequired[Optional[str]] + mode: NotRequired[Optional[int]] + resolveSymlinks: NotRequired[Optional[bool]] + + +class Instruction(TypedDict): + """ + Represents a single instruction in the template build process. + """ + + type: InstructionType + args: List[str] + force: bool + forceUpload: NotRequired[Optional[Literal[True]]] + filesHash: NotRequired[Optional[str]] + resolveSymlinks: NotRequired[Optional[bool]] + + +class GenericDockerRegistry(TypedDict): + """ + Configuration for a generic Docker registry with basic authentication. + """ + + type: Literal["registry"] + username: str + password: str + + +class AWSRegistry(TypedDict): + """ + Configuration for AWS Elastic Container Registry (ECR). + """ + + type: Literal["aws"] + awsAccessKeyId: str + awsSecretAccessKey: str + awsRegion: str + + +class GCPRegistry(TypedDict): + """ + Configuration for Google Container Registry (GCR) or Artifact Registry. + """ + + type: Literal["gcp"] + serviceAccountJson: str + + +""" +Union type for all supported container registry configurations. +""" +RegistryConfig = Union[GenericDockerRegistry, AWSRegistry, GCPRegistry] + + +class TemplateType(TypedDict): + """ + Internal representation of a template for the E2B build API. + """ + + fromImage: NotRequired[str] + fromTemplate: NotRequired[str] + fromImageRegistry: NotRequired[RegistryConfig] + startCmd: NotRequired[str] + readyCmd: NotRequired[str] + steps: List[Instruction] + force: bool + + +@dataclass +class BuildInfo: + """ + Information about a built template. + """ + + alias: str + template_id: str + build_id: str diff --git a/ucloud_sandbox/template/utils.py b/ucloud_sandbox/template/utils.py new file mode 100644 index 0000000..13905d4 --- /dev/null +++ b/ucloud_sandbox/template/utils.py @@ -0,0 +1,320 @@ +import hashlib +import os +import io +import tarfile +import json +import stat +from wcmatch import glob +import re +import inspect +from types import TracebackType, FrameType +from typing import List, Optional, Union + +from ucloud_agentbox.template.consts import BASE_STEP_NAME, FINALIZE_STEP_NAME + + +def read_dockerignore(context_path: str) -> List[str]: + """ + Read and parse a .dockerignore file. + + :param context_path: Directory path containing the .dockerignore file + + :return: Array of ignore patterns (empty lines and comments are filtered out) + """ + dockerignore_path = os.path.join(context_path, ".dockerignore") + if not os.path.exists(dockerignore_path): + return [] + + with open(dockerignore_path, "r", encoding="utf-8") as f: + content = f.read() + + return [ + line.strip() + for line in content.split("\n") + if line.strip() and not line.strip().startswith("#") + ] + + +def normalize_path(path: str) -> str: + """ + Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows). + + :param path: The path to normalize + :return: The normalized path + """ + return path.replace(os.sep, "/") + + +def get_all_files_in_path( + src: str, + context_path: str, + ignore_patterns: List[str], + include_directories: bool = True, +) -> List[str]: + """ + Get all files for a given path and ignore patterns. + + :param src: Path to the source directory + :param context_path: Base directory for resolving relative paths + :param ignore_patterns: Ignore patterns + :param include_directories: Whether to include directories + :return: Array of files + """ + files = set() + + # Use glob to find all files/directories matching the pattern under context_path + abs_context_path = os.path.abspath(context_path) + files_glob = glob.glob( + src, + flags=glob.GLOBSTAR, + root_dir=abs_context_path, + exclude=ignore_patterns, + ) + + for file in files_glob: + # Join it with abs_context_path to get the absolute path + file_path = os.path.join(abs_context_path, file) + + if os.path.isdir(file_path): + # If it's a directory, add the directory and all entries recursively + if include_directories: + files.add(file_path) + dir_files = glob.glob( + normalize_path(file) + "/**/*", + flags=glob.GLOBSTAR, + root_dir=abs_context_path, + exclude=ignore_patterns, + ) + for dir_file in dir_files: + dir_file_path = os.path.join(abs_context_path, dir_file) + files.add(dir_file_path) + else: + files.add(file_path) + + return sorted(list(files)) + + +def calculate_files_hash( + src: str, + dest: str, + context_path: str, + ignore_patterns: List[str], + resolve_symlinks: bool, + stack_trace: Optional[TracebackType], +) -> str: + """ + Calculate a hash of files being copied to detect changes for cache invalidation. + + The hash includes file content, metadata (mode, size), and relative paths. + Note: uid, gid, and mtime are excluded to ensure stable hashes across environments. + + :param src: Source path pattern for files to copy + :param dest: Destination path where files will be copied + :param context_path: Base directory for resolving relative paths + :param ignore_patterns: Glob patterns to ignore + :param resolve_symlinks: Whether to resolve symbolic links when hashing + :param stack_trace: Optional stack trace for error reporting + + :return: Hex string hash of all files + + :raises ValueError: If no files match the source pattern + """ + src_path = os.path.join(context_path, src) + hash_obj = hashlib.sha256() + content = f"COPY {src} {dest}" + + hash_obj.update(content.encode()) + + files = get_all_files_in_path(src, context_path, ignore_patterns, True) + + if len(files) == 0: + raise ValueError(f"No files found in {src_path}").with_traceback(stack_trace) + + def hash_stats(stat_info: os.stat_result) -> None: + # Only include stable metadata (mode, size) + # Exclude uid, gid, and mtime to ensure consistent hashes across environments + hash_obj.update(str(stat_info.st_mode).encode()) + hash_obj.update(str(stat_info.st_size).encode()) + + for file in files: + # Hash the relative path + relative_path = os.path.relpath(file, context_path) + hash_obj.update(relative_path.encode()) + + # Add stat information to hash calculation + if os.path.islink(file): + stats = os.lstat(file) + should_follow = resolve_symlinks and ( + os.path.isfile(file) or os.path.isdir(file) + ) + + if not should_follow: + hash_stats(stats) + + content = os.readlink(file) + hash_obj.update(content.encode()) + continue + + stats = os.stat(file) + hash_stats(stats) + + if stat.S_ISREG(stats.st_mode): + with open(file, "rb") as f: + hash_obj.update(f.read()) + + return hash_obj.hexdigest() + + +def tar_file_stream( + file_name: str, + file_context_path: str, + ignore_patterns: List[str], + resolve_symlinks: bool, +) -> io.BytesIO: + """ + Create a tar stream of files matching a pattern. + + :param file_name: Glob pattern for files to include + :param file_context_path: Base directory for resolving file paths + :param ignore_patterns: Ignore patterns + :param resolve_symlinks: Whether to resolve symbolic links + + :return: Tar stream + """ + tar_buffer = io.BytesIO() + with tarfile.open( + fileobj=tar_buffer, + mode="w:gz", + dereference=resolve_symlinks, + ) as tar: + files = get_all_files_in_path( + file_name, file_context_path, ignore_patterns, True + ) + for file in files: + tar.add( + file, arcname=os.path.relpath(file, file_context_path), recursive=False + ) + + return tar_buffer + + +def strip_ansi_escape_codes(text: str) -> str: + """ + Strip ANSI escape codes from a string. + + Source: https://github.com/chalk/ansi-regex/blob/main/index.js + + :param text: String with ANSI escape codes + + :return: String without ANSI escape codes + """ + # Valid string terminator sequences are BEL, ESC\, and 0x9c + st = r"(?:\u0007|\u001B\u005C|\u009C)" + pattern = [ + rf"[\u001B\u009B][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?{st})", + r"(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))", + ] + ansi_escape = re.compile("|".join(pattern), re.UNICODE) + return ansi_escape.sub("", text) + + +def get_caller_frame(depth: int) -> Optional[FrameType]: + """ + Get the caller's stack frame at a specific depth. + + This is used to provide better error messages and debugging information + by tracking where template methods were called from in user code. + + :param depth: The depth of the stack trace to retrieve + + :return: The caller frame, or None if not available + """ + stack = inspect.stack()[1:] + if len(stack) < depth + 1: + return None + return stack[depth].frame + + +def get_caller_directory(depth: int) -> Optional[str]: + """ + Get the directory of the caller at a specific stack depth. + + This is used to determine the file_context_path when creating a template, + so file paths are resolved relative to the user's template file location. + + :param depth: The depth of the stack trace + + :return: The caller's directory path, or None if not available + """ + try: + # Get the stack trace + caller_frame = get_caller_frame(depth) + if caller_frame is None: + return None + + caller_file = caller_frame.f_code.co_filename + + # Return the directory of the caller file + return os.path.dirname(os.path.abspath(caller_file)) + except Exception: + return None + + +def pad_octal(mode: int) -> str: + """ + Convert a numeric file mode to a zero-padded octal string. + + :param mode: File mode as a number (e.g., 493 for 0o755) + + :return: Zero-padded 4-digit octal string (e.g., "0755") + + Example + ```python + pad_octal(0o755) # Returns "0755" + pad_octal(0o644) # Returns "0644" + ``` + """ + return f"{mode:04o}" + + +def get_build_step_index(step: str, stack_traces_length: int) -> int: + """ + Get the array index for a build step based on its name. + + Special steps: + - BASE_STEP_NAME: Returns 0 (first step) + - FINALIZE_STEP_NAME: Returns the last index + - Numeric strings: Converted to number + + :param step: Build step name or number as string + :param stack_traces_length: Total number of stack traces (used for FINALIZE_STEP_NAME) + + :return: Index for the build step + """ + if step == BASE_STEP_NAME: + return 0 + + if step == FINALIZE_STEP_NAME: + return stack_traces_length - 1 + + return int(step) + + +def read_gcp_service_account_json( + context_path: str, path_or_content: Union[str, dict] +) -> str: + """ + Read GCP service account JSON from a file or object. + + :param context_path: Base directory for resolving relative file paths + :param path_or_content: Either a path to a JSON file or a service account object + + :return: Service account JSON as a string + """ + if isinstance(path_or_content, str): + with open( + os.path.join(context_path, path_or_content), "r", encoding="utf-8" + ) as f: + return f.read() + else: + return json.dumps(path_or_content) diff --git a/ucloud_sandbox/template_async/build_api.py b/ucloud_sandbox/template_async/build_api.py new file mode 100644 index 0000000..0b1d2b6 --- /dev/null +++ b/ucloud_sandbox/template_async/build_api.py @@ -0,0 +1,202 @@ +import asyncio +from types import TracebackType +from typing import Callable, Literal, Optional, List, Union + +import httpx + +from ucloud_agentbox.api import handle_api_exception +from ucloud_agentbox.api.client.api.templates import ( + post_v3_templates, + get_templates_template_id_files_hash, + post_v_2_templates_template_id_builds_build_id, + get_templates_template_id_builds_build_id_status, +) +from ucloud_agentbox.api.client.client import AuthenticatedClient +from ucloud_agentbox.api.client.models import ( + TemplateBuildRequestV3, + TemplateBuildStartV2, + TemplateBuildFileUpload, + TemplateBuild, + Error, +) +from ucloud_agentbox.exceptions import BuildException, FileUploadException +from ucloud_agentbox.template.logger import LogEntry +from ucloud_agentbox.template.types import TemplateType +from ucloud_agentbox.template.utils import get_build_step_index, tar_file_stream + + +async def request_build( + client: AuthenticatedClient, name: str, cpu_count: int, memory_mb: int +): + res = await post_v3_templates.asyncio_detailed( + client=client, + body=TemplateBuildRequestV3( + alias=name, + cpu_count=cpu_count, + memory_mb=memory_mb, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to request build") + + return res.parsed + + +async def get_file_upload_link( + client: AuthenticatedClient, + template_id: str, + files_hash: str, + stack_trace: Optional[TracebackType] = None, +) -> TemplateBuildFileUpload: + res = await get_templates_template_id_files_hash.asyncio_detailed( + template_id=template_id, + hash_=files_hash, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, FileUploadException, stack_trace) + + if isinstance(res.parsed, Error): + raise FileUploadException(f"API error: {res.parsed.message}").with_traceback( + stack_trace + ) + + if res.parsed is None: + raise FileUploadException("Failed to get file upload link").with_traceback( + stack_trace + ) + + return res.parsed + + +async def upload_file( + api_client: AuthenticatedClient, + file_name: str, + context_path: str, + url: str, + ignore_patterns: List[str], + resolve_symlinks: bool, + stack_trace: Optional[TracebackType], +): + try: + tar_buffer = tar_file_stream( + file_name, context_path, ignore_patterns, resolve_symlinks + ) + + client = api_client.get_async_httpx_client() + response = await client.put(url, content=tar_buffer.getvalue()) + response.raise_for_status() + except httpx.HTTPStatusError as e: + raise FileUploadException(f"Failed to upload file: {e}").with_traceback( + stack_trace + ) + except Exception as e: + raise FileUploadException(f"Failed to upload file: {e}").with_traceback( + stack_trace + ) + + +async def trigger_build( + client: AuthenticatedClient, + template_id: str, + build_id: str, + template: TemplateType, +) -> None: + # Convert template dict to TemplateBuildStartV2 model using from_dict + template_data = TemplateBuildStartV2.from_dict(template) + + res = await post_v_2_templates_template_id_builds_build_id.asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=template_data, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + +async def get_build_status( + client: AuthenticatedClient, template_id: str, build_id: str, logs_offset: int +) -> TemplateBuild: + res = await get_templates_template_id_builds_build_id_status.asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to get build status") + + return res.parsed + + +async def wait_for_build_finish( + client: AuthenticatedClient, + template_id: str, + build_id: str, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + logs_refresh_frequency: float = 0.2, + stack_traces: List[Union[TracebackType, None]] = [], +): + logs_offset = 0 + status: Literal["building", "waiting", "ready", "error"] = "building" + + while status in ["building", "waiting"]: + build_status = await get_build_status( + client, template_id, build_id, logs_offset + ) + + logs_offset += len(build_status.log_entries) + + for log_entry in build_status.log_entries: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=log_entry.timestamp, + level=log_entry.level.value, + message=log_entry.message, + ) + ) + + status = build_status.status.value + + if status == "ready": + return + + elif status == "waiting": + pass + + elif status == "error": + traceback = None + if build_status.reason and build_status.reason.step: + # Find the corresponding stack trace for the failed step + step_index = get_build_step_index( + build_status.reason.step, len(stack_traces) + ) + if step_index < len(stack_traces): + traceback = stack_traces[step_index] + + raise BuildException( + build_status.reason.message if build_status.reason else "Build failed" + ).with_traceback(traceback) + + # Wait for a short period before checking the status again + await asyncio.sleep(logs_refresh_frequency) + + raise BuildException("Unknown build error occurred.") diff --git a/ucloud_sandbox/template_async/main.py b/ucloud_sandbox/template_async/main.py new file mode 100644 index 0000000..73f8087 --- /dev/null +++ b/ucloud_sandbox/template_async/main.py @@ -0,0 +1,366 @@ +import os +from datetime import datetime +from typing import Callable, Optional + +from ucloud_agentbox.api.client.client import AuthenticatedClient +from ucloud_agentbox.connection_config import ConnectionConfig +from ucloud_agentbox.template.consts import RESOLVE_SYMLINKS +from ucloud_agentbox.template.logger import LogEntry, LogEntryEnd, LogEntryStart +from ucloud_agentbox.template.main import TemplateBase, TemplateClass +from ucloud_agentbox.template.types import BuildInfo, InstructionType +from ucloud_agentbox.template.utils import read_dockerignore + +from .build_api import ( + get_build_status, + get_file_upload_link, + request_build, + trigger_build, + upload_file, + wait_for_build_finish, +) +from ucloud_agentbox.api.client_async import get_api_client + + +class AsyncTemplate(TemplateBase): + """ + Asynchronous template builder for AgentBox sandboxes. + """ + + @staticmethod + async def _build( + template: TemplateClass, + api_client: AuthenticatedClient, + alias: str, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + ) -> BuildInfo: + """ + Internal implementation of the template build process + + :param template: The template to build + :param api_client: Authenticated API client + :param alias: Alias name for the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param on_build_logs: Callback function to receive build logs during the build process + """ + if skip_cache: + template._template._force = True + + # Create template + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Requesting build for template: {alias}", + ) + ) + + response = await request_build( + api_client, + name=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + ) + + template_id = response.template_id + build_id = response.build_id + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Template created with ID: {template_id}, Build ID: {build_id}", + ) + ) + + instructions_with_hashes = template._template._instructions_with_hashes() + + # Upload files + for index, file_upload in enumerate(instructions_with_hashes): + if file_upload["type"] != InstructionType.COPY: + continue + + args = file_upload.get("args", []) + src = args[0] if len(args) > 0 else None + force_upload = file_upload.get("forceUpload") + files_hash = file_upload.get("filesHash", None) + resolve_symlinks = file_upload.get("resolveSymlinks", RESOLVE_SYMLINKS) + + if src is None or files_hash is None: + raise ValueError("Source path and files hash are required") + + stack_trace = None + if index + 1 < len(template._template._stack_traces): + stack_trace = template._template._stack_traces[index + 1] + + file_info = await get_file_upload_link( + api_client, template_id, files_hash, stack_trace + ) + + if (force_upload and file_info.url) or ( + file_info.present is False and file_info.url + ): + await upload_file( + api_client, + src, + template._template._file_context_path, + file_info.url, + [ + *template._template._file_ignore_patterns, + *read_dockerignore(template._template._file_context_path), + ], + resolve_symlinks, + stack_trace, + ) + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Uploaded '{src}'", + ) + ) + else: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Skipping upload of '{src}', already cached", + ) + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="All file uploads completed", + ) + ) + + # Start build + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Starting building...", + ) + ) + + await trigger_build( + api_client, + template_id, + build_id, + template._template._serialize(instructions_with_hashes), + ) + + return BuildInfo( + alias=alias, + template_id=template_id, + build_id=build_id, + ) + + @staticmethod + async def build( + template: TemplateClass, + alias: str, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + ) -> BuildInfo: + """ + Build and deploy a template to AgentBox infrastructure. + + :param template: The template to build + :param alias: Alias name for the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param on_build_logs: Callback function to receive build logs during the build process + :param api_key: AgentBox API key for authentication + :param domain: Domain of the AgentBox API + + Example + ```python + from ucloud_agentbox import AsyncTemplate + + template = ( + AsyncTemplate() + .from_python_image('3') + .copy('requirements.txt', '/home/user/') + .run_cmd('pip install -r /home/user/requirements.txt') + ) + + await AsyncTemplate.build( + template, + alias='my-python-env', + cpu_count=2, + memory_mb=1024 + ) + ``` + """ + try: + if on_build_logs: + on_build_logs( + LogEntryStart( + timestamp=datetime.now(), + message="Build started", + ) + ) + + domain = domain or os.environ.get("AGENTBOX_DOMAIN", "sandbox.ucloudai.com") + config = ConnectionConfig( + domain=domain, api_key=api_key or os.environ.get("AGENTBOX_API_KEY") + ) + api_client = get_api_client( + config, + require_api_key=True, + require_access_token=False, + ) + + data = await AsyncTemplate._build( + template, + api_client, + alias, + cpu_count, + memory_mb, + skip_cache, + on_build_logs, + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Waiting for logs...", + ) + ) + + await wait_for_build_finish( + api_client, + data.template_id, + data.build_id, + on_build_logs, + logs_refresh_frequency=TemplateBase._logs_refresh_frequency, + stack_traces=template._template._stack_traces, + ) + + return data + finally: + if on_build_logs: + on_build_logs( + LogEntryEnd( + timestamp=datetime.now(), + message="Build finished", + ) + ) + + @staticmethod + async def build_in_background( + template: TemplateClass, + alias: str, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + ) -> BuildInfo: + """ + Build and deploy a template to AgentBox infrastructure without waiting for completion. + + :param template: The template to build + :param alias: Alias name for the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param api_key: AgentBox API key for authentication + :param domain: Domain of the AgentBox API + :return: BuildInfo containing the template ID and build ID + + Example + ```python + from ucloud_agentbox import AsyncTemplate + + template = ( + AsyncTemplate() + .from_python_image('3') + .run_cmd('echo "test"') + .set_start_cmd('echo "Hello"', 'sleep 1') + ) + + build_info = await AsyncTemplate.build_in_background( + template, + alias='my-python-env', + cpu_count=2, + memory_mb=1024 + ) + ``` + """ + domain = domain or os.environ.get("AGENTBOX_DOMAIN", "sandbox.ucloudai.com") + config = ConnectionConfig( + domain=domain, api_key=api_key or os.environ.get("AGENTBOX_API_KEY") + ) + api_client = get_api_client( + config, + require_api_key=True, + require_access_token=False, + ) + + return await AsyncTemplate._build( + template, + api_client, + alias, + cpu_count, + memory_mb, + skip_cache, + on_build_logs, + ) + + @staticmethod + async def get_build_status( + build_info: BuildInfo, + logs_offset: int = 0, + api_key: Optional[str] = None, + domain: Optional[str] = None, + ): + """ + Get the status of a build. + + :param build_info: Build identifiers returned from build_in_background + :param logs_offset: Offset for fetching logs + :param api_key: AgentBox API key for authentication + :param domain: Domain of the AgentBox API + :return: TemplateBuild containing the build status and logs + + Example + ```python + from ucloud_agentbox import AsyncTemplate + + build_info = await AsyncTemplate.build_in_background(template, alias='my-template') + status = await AsyncTemplate.get_build_status(build_info, logs_offset=0) + ``` + """ + domain = domain or os.environ.get("AGENTBOX_DOMAIN", "sandbox.ucloudai.com") + config = ConnectionConfig( + domain=domain, api_key=api_key or os.environ.get("AGENTBOX_API_KEY") + ) + api_client = get_api_client(config) + return await get_build_status( + api_client, + build_info.template_id, + build_info.build_id, + logs_offset, + ) diff --git a/ucloud_sandbox/template_sync/build_api.py b/ucloud_sandbox/template_sync/build_api.py new file mode 100644 index 0000000..8e45acf --- /dev/null +++ b/ucloud_sandbox/template_sync/build_api.py @@ -0,0 +1,199 @@ +import time +from types import TracebackType +from typing import Callable, Literal, Optional, List, Union + +import httpx + +from ucloud_agentbox.api import handle_api_exception +from ucloud_agentbox.api.client.api.templates import ( + post_v3_templates, + get_templates_template_id_files_hash, + post_v_2_templates_template_id_builds_build_id, + get_templates_template_id_builds_build_id_status, +) +from ucloud_agentbox.api.client.client import AuthenticatedClient +from ucloud_agentbox.api.client.models import ( + TemplateBuildRequestV3, + TemplateBuildStartV2, + TemplateBuildFileUpload, + TemplateBuild, + Error, +) +from ucloud_agentbox.exceptions import BuildException, FileUploadException +from ucloud_agentbox.template.logger import LogEntry +from ucloud_agentbox.template.types import TemplateType +from ucloud_agentbox.template.utils import get_build_step_index, tar_file_stream + + +def request_build( + client: AuthenticatedClient, name: str, cpu_count: int, memory_mb: int +): + res = post_v3_templates.sync_detailed( + client=client, + body=TemplateBuildRequestV3( + alias=name, + cpu_count=cpu_count, + memory_mb=memory_mb, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to request build") + + return res.parsed + + +def get_file_upload_link( + client: AuthenticatedClient, + template_id: str, + files_hash: str, + stack_trace: Optional[TracebackType] = None, +) -> TemplateBuildFileUpload: + res = get_templates_template_id_files_hash.sync_detailed( + template_id=template_id, + hash_=files_hash, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, FileUploadException, stack_trace) + + if isinstance(res.parsed, Error): + raise FileUploadException(f"API error: {res.parsed.message}").with_traceback( + stack_trace + ) + + if res.parsed is None: + raise FileUploadException("Failed to get file upload link").with_traceback( + stack_trace + ) + + return res.parsed + + +def upload_file( + api_client: AuthenticatedClient, + file_name: str, + context_path: str, + url: str, + ignore_patterns: List[str], + resolve_symlinks: bool, + stack_trace: Optional[TracebackType], +): + try: + tar_buffer = tar_file_stream( + file_name, context_path, ignore_patterns, resolve_symlinks + ) + client = api_client.get_httpx_client() + response = client.put(url, content=tar_buffer.getvalue()) + response.raise_for_status() + except httpx.HTTPStatusError as e: + raise FileUploadException(f"Failed to upload file: {e}").with_traceback( + stack_trace + ) + except Exception as e: + raise FileUploadException(f"Failed to upload file: {e}").with_traceback( + stack_trace + ) + + +def trigger_build( + client: AuthenticatedClient, + template_id: str, + build_id: str, + template: TemplateType, +) -> None: + # Convert template dict to TemplateBuildStartV2 model using from_dict + template_data = TemplateBuildStartV2.from_dict(template) + + res = post_v_2_templates_template_id_builds_build_id.sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=template_data, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + +def get_build_status( + client: AuthenticatedClient, template_id: str, build_id: str, logs_offset: int +) -> TemplateBuild: + res = get_templates_template_id_builds_build_id_status.sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to get build status") + + return res.parsed + + +def wait_for_build_finish( + client: AuthenticatedClient, + template_id: str, + build_id: str, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + logs_refresh_frequency: float = 0.2, + stack_traces: List[Union[TracebackType, None]] = [], +): + logs_offset = 0 + status: Literal["building", "waiting", "ready", "error"] = "building" + + while status in ["building", "waiting"]: + build_status = get_build_status(client, template_id, build_id, logs_offset) + + logs_offset += len(build_status.log_entries) + + for log_entry in build_status.log_entries: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=log_entry.timestamp, + level=log_entry.level.value, + message=log_entry.message, + ) + ) + + status = build_status.status.value + + if status == "ready": + return + + elif status == "waiting": + pass + + elif status == "error": + traceback = None + if build_status.reason and build_status.reason.step: + # Find the corresponding stack trace for the failed step + step_index = get_build_step_index( + build_status.reason.step, len(stack_traces) + ) + if step_index < len(stack_traces): + traceback = stack_traces[step_index] + + raise BuildException( + build_status.reason.message if build_status.reason else "Build failed" + ).with_traceback(traceback) + + # Wait for a short period before checking the status again + time.sleep(logs_refresh_frequency) + + raise BuildException("Unknown build error occurred.") diff --git a/ucloud_sandbox/template_sync/main.py b/ucloud_sandbox/template_sync/main.py new file mode 100644 index 0000000..086c3f1 --- /dev/null +++ b/ucloud_sandbox/template_sync/main.py @@ -0,0 +1,371 @@ +import os +from datetime import datetime +from typing import Callable, Optional + +from ucloud_agentbox.api.client.client import AuthenticatedClient +from ucloud_agentbox.connection_config import ConnectionConfig + +from ucloud_agentbox.api.client_sync import get_api_client +from ucloud_agentbox.template.consts import RESOLVE_SYMLINKS +from ucloud_agentbox.template.logger import LogEntry, LogEntryEnd, LogEntryStart +from ucloud_agentbox.template.main import TemplateBase, TemplateClass +from ucloud_agentbox.template.types import BuildInfo, InstructionType +from ucloud_agentbox.template_sync.build_api import ( + get_build_status, + get_file_upload_link, + request_build, + trigger_build, + upload_file, + wait_for_build_finish, +) +from ucloud_agentbox.template.utils import read_dockerignore + + +class Template(TemplateBase): + """ + Synchronous template builder for AgentBox sandboxes. + """ + + @staticmethod + def _build( + template: TemplateClass, + api_client: AuthenticatedClient, + alias: str, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + ) -> BuildInfo: + """ + Internal implementation of the template build process + + :param template: The template to build + :param api_client: Authenticated API client + :param alias: Alias name for the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param on_build_logs: Callback function to receive build logs during the build process + """ + if skip_cache: + template._template._force = True + + # Create template + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Requesting build for template: {alias}", + ) + ) + + response = request_build( + api_client, + name=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + ) + + template_id = response.template_id + build_id = response.build_id + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Template created with ID: {template_id}, Build ID: {build_id}", + ) + ) + + instructions_with_hashes = template._template._instructions_with_hashes() + + # Upload files + for index, file_upload in enumerate(instructions_with_hashes): + if file_upload["type"] != InstructionType.COPY: + continue + + args = file_upload.get("args", []) + src = args[0] if len(args) > 0 else None + force_upload = file_upload.get("forceUpload") + files_hash = file_upload.get("filesHash", None) + resolve_symlinks = file_upload.get("resolveSymlinks", RESOLVE_SYMLINKS) + + if src is None or files_hash is None: + raise ValueError("Source path and files hash are required") + + stack_trace = None + if index + 1 < len(template._template._stack_traces): + stack_trace = template._template._stack_traces[index + 1] + + file_info = get_file_upload_link( + api_client, template_id, files_hash, stack_trace + ) + + if (force_upload and file_info.url) or ( + file_info.present is False and file_info.url + ): + upload_file( + api_client, + src, + template._template._file_context_path, + file_info.url, + [ + *template._template._file_ignore_patterns, + *read_dockerignore(template._template._file_context_path), + ], + resolve_symlinks, + stack_trace, + ) + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Uploaded '{src}'", + ) + ) + else: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Skipping upload of '{src}', already cached", + ) + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="All file uploads completed", + ) + ) + + # Start build + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Starting building...", + ) + ) + + trigger_build( + api_client, + template_id, + build_id, + template._template._serialize(instructions_with_hashes), + ) + + return BuildInfo( + alias=alias, + template_id=template_id, + build_id=build_id, + ) + + @staticmethod + def build( + template: TemplateClass, + alias: str, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + ) -> BuildInfo: + """ + Build and deploy a template to AgentBox infrastructure. + + :param template: The template to build + :param alias: Alias name for the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param on_build_logs: Callback function to receive build logs during the build process + :param api_key: AgentBox API key for authentication + :param domain: Domain of the AgentBox API + + Example + ```python + from ucloud_agentbox import Template + + template = ( + Template() + .from_python_image('3') + .copy('requirements.txt', '/home/user/') + .run_cmd('pip install -r /home/user/requirements.txt') + ) + + Template.build( + template, + alias='my-python-env', + cpu_count=2, + memory_mb=1024 + ) + ``` + """ + try: + if on_build_logs: + on_build_logs( + LogEntryStart( + timestamp=datetime.now(), + message="Build started", + ) + ) + + domain = domain or os.environ.get("AGENTBOX_DOMAIN", "sandbox.ucloudai.com") + config = ConnectionConfig( + domain=domain, api_key=api_key or os.environ.get("AGENTBOX_API_KEY") + ) + api_client = get_api_client( + config, + require_api_key=True, + require_access_token=False, + ) + + data = Template._build( + template, + api_client, + alias, + cpu_count, + memory_mb, + skip_cache, + on_build_logs, + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Waiting for logs...", + ) + ) + + wait_for_build_finish( + api_client, + data.template_id, + data.build_id, + on_build_logs, + logs_refresh_frequency=TemplateBase._logs_refresh_frequency, + stack_traces=template._template._stack_traces, + ) + + return data + finally: + if on_build_logs: + on_build_logs( + LogEntryEnd( + timestamp=datetime.now(), + message="Build finished", + ) + ) + + @staticmethod + def build_in_background( + template: TemplateClass, + alias: str, + cpu_count: int = 2, + memory_mb: int = 1024, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + ) -> BuildInfo: + """ + Build and deploy a template to AgentBox infrastructure without waiting for completion. + + :param template: The template to build + :param alias: Alias name for the template + :param cpu_count: Number of CPUs allocated to the sandbox + :param memory_mb: Amount of memory in MB allocated to the sandbox + :param skip_cache: If True, forces a complete rebuild ignoring cache + :param api_key: AgentBox API key for authentication + :param domain: Domain of the AgentBox API + :return: BuildInfo containing the template ID and build ID + + Example + ```python + from ucloud_agentbox import Template + + template = ( + Template() + .from_python_image('3') + .run_cmd('echo "test"') + .set_start_cmd('echo "Hello"', 'sleep 1') + ) + + build_info = Template.build_in_background( + template, + alias='my-python-env', + cpu_count=2, + memory_mb=1024 + ) + ``` + """ + domain = domain or os.environ.get("AGENTBOX_DOMAIN", "sandbox.ucloudai.com") + config = ConnectionConfig( + domain=domain, api_key=api_key or os.environ.get("AGENTBOX_API_KEY") + ) + api_client = get_api_client( + config, + require_api_key=True, + require_access_token=False, + ) + + return Template._build( + template, + api_client, + alias, + cpu_count, + memory_mb, + skip_cache, + on_build_logs, + ) + + @staticmethod + def get_build_status( + build_info: BuildInfo, + logs_offset: int = 0, + api_key: Optional[str] = None, + domain: Optional[str] = None, + ): + """ + Get the status of a build. + + :param build_info: Build identifiers returned from build_in_background + :param logs_offset: Offset for fetching logs + :param api_key: AgentBox API key for authentication + :param domain: Domain of the AgentBox API + :return: TemplateBuild containing the build status and logs + + Example + ```python + from ucloud_agentbox import Template + + build_info = Template.build_in_background(template, alias='my-template') + status = Template.get_build_status(build_info, logs_offset=0) + ``` + """ + domain = domain or os.environ.get("AGENTBOX_DOMAIN", "sandbox.ucloudai.com") + config = ConnectionConfig( + domain=domain, api_key=api_key or os.environ.get("AGENTBOX_API_KEY") + ) + api_client = get_api_client( + config, + require_api_key=True, + require_access_token=False, + ) + + return get_build_status( + api_client, + build_info.template_id, + build_info.build_id, + logs_offset, + )