From fa380eeb0c00bfc6e9d527ef58bfedb6626327e8 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 00:54:56 +0800 Subject: [PATCH 01/20] feat(sdk): scaffold the Python package with uv, ruff, pyright gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uv-managed project (uv_build backend, committed lockfile), ruff as linter+formatter (E/F/W/I/UP/B/SIM/RUF), pyright strict, pytest. Package name arcbox (PyPI simple index answers 404 — the name is free), Python >= 3.10. Runtime deps: httpx (UDS transport), protobuf (wire types), msgspec (typed Connect error-body parsing). --- sdk/python/.gitignore | 6 + sdk/python/.python-version | 1 + sdk/python/README.md | 3 + sdk/python/pyproject.toml | 51 +++ sdk/python/src/arcbox/__init__.py | 6 + sdk/python/src/arcbox/py.typed | 0 sdk/python/uv.lock | 568 ++++++++++++++++++++++++++++++ 7 files changed, 635 insertions(+) create mode 100644 sdk/python/.gitignore create mode 100644 sdk/python/.python-version create mode 100644 sdk/python/README.md create mode 100644 sdk/python/pyproject.toml create mode 100644 sdk/python/src/arcbox/__init__.py create mode 100644 sdk/python/src/arcbox/py.typed create mode 100644 sdk/python/uv.lock diff --git a/sdk/python/.gitignore b/sdk/python/.gitignore new file mode 100644 index 000000000..775ece17e --- /dev/null +++ b/sdk/python/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +dist/ diff --git a/sdk/python/.python-version b/sdk/python/.python-version new file mode 100644 index 000000000..6324d401a --- /dev/null +++ b/sdk/python/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 000000000..b1557701f --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,3 @@ +# arcbox + +ArcBox Sandbox SDK for Python (scaffold — full README lands with the surface). diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 000000000..3b6523d78 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "arcbox" +version = "0.1.0" +description = "ArcBox Sandbox SDK for Python — run isolated microVM sandboxes on your Mac" +readme = "README.md" +authors = [{ name = "ArcBox, Inc." }] +license = "MIT OR Apache-2.0" +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.28.1", + "msgspec>=0.21.1", + "protobuf>=7.35.1", +] + +[project.urls] +Repository = "https://github.com/arcboxlabs/arcbox" + +[build-system] +requires = ["uv_build>=0.12.1,<0.13.0"] +build-backend = "uv_build" + +[tool.ruff] +target-version = "py310" +extend-exclude = ["src/arcbox/_gen"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"] + +[tool.pyright] +include = ["src", "tests", "scripts"] +exclude = ["src/arcbox/_gen", ".venv"] +typeCheckingMode = "strict" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ty.src] +include = ["src", "tests", "scripts"] +exclude = ["src/arcbox/_gen"] + +[dependency-groups] +dev = [ + "anyio>=4.14.2", + "grpcio-tools>=1.83.0", + "pyrefly>=1.2.0", + "pyright>=1.1.411", + "pytest>=9.1.1", + "ruff>=0.16.1", + "ty>=0.0.65", + "unasync>=0.6.0", +] diff --git a/sdk/python/src/arcbox/__init__.py b/sdk/python/src/arcbox/__init__.py new file mode 100644 index 000000000..f95675814 --- /dev/null +++ b/sdk/python/src/arcbox/__init__.py @@ -0,0 +1,6 @@ +"""arcbox — run isolated microVM sandboxes on the local ArcBox daemon. + +Public surface only: everything under `arcbox._gen` is generated wire +code and is deliberately NOT exported — public shapes are hand-written +and mapped at the transport boundary. +""" diff --git a/sdk/python/src/arcbox/py.typed b/sdk/python/src/arcbox/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock new file mode 100644 index 000000000..5cddfce09 --- /dev/null +++ b/sdk/python/uv.lock @@ -0,0 +1,568 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "arcbox" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "msgspec" }, + { name = "protobuf" }, +] + +[package.dev-dependencies] +dev = [ + { name = "anyio" }, + { name = "grpcio-tools" }, + { name = "pyrefly" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, + { name = "unasync" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "msgspec", specifier = ">=0.21.1" }, + { name = "protobuf", specifier = ">=7.35.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "anyio", specifier = ">=4.14.2" }, + { name = "grpcio-tools", specifier = ">=1.83.0" }, + { name = "pyrefly", specifier = ">=1.2.0" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.1" }, + { name = "ty", specifier = ">=0.0.65" }, + { name = "unasync", specifier = ">=0.6.0" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/fd/655c8a773d728bc3c93fb4713ae4bf79ffc75996f86fb78b2974c8e1dfbd/grpcio-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:fba099b716e73512d61b97f71ea3c31a72abb36904036e316bf4dd148ca8dcc8", size = 6334247, upload-time = "2026-07-23T15:18:53.099Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9a/1ce5760d35a04a992006dd2f79afff2db548f93ee7426fa95c9f1fc90c61/grpcio-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6755ed67cc3e454d51ae9f6e1915b80d3942fa4de956ef48dacd45ab7f40b727", size = 12168650, upload-time = "2026-07-23T15:18:56.348Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ab/bbcb5be0a1a6cb21f036e2afdd4f7a70147cfb7a7b42648a310d7c43acfc/grpcio-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5882c1a721b50ce0123ee5e839e1ab059ad72a7ade76cdf2d5bd833b56791acf", size = 6916899, upload-time = "2026-07-23T15:18:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/4c27977ecb3b3f9f363b93f570e001cb24ef264a9a907d7fd0f949ed59f0/grpcio-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e3eedfc92b6b9f2960115e7e620cf0cbf80bb7849a51ce3820dc54dfd88b6b9", size = 7648761, upload-time = "2026-07-23T15:19:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/49/0c823a7627ff2e69a61e4a53c4edf215272892fc2c47c6431f033d46f4cc/grpcio-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fcaa7c45c45b4a89e2867d1f1785d9481a788399d915e341ed2eb49aeef9dd4", size = 7074920, upload-time = "2026-07-23T15:19:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ce/963f01ff7c789a76909c9691b704112e02ca1e11c10405cd99c2bd7c40f1/grpcio-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6b6c666a1d5613ff360c9e90f44665e3a88b25a815209ddbc0917eec281931cb", size = 7598046, upload-time = "2026-07-23T15:19:03.921Z" }, + { url = "https://files.pythonhosted.org/packages/eb/de/1ce6bdefc847a7973040d10cebc8996c653a2a687c0a4da8d05dcab4e397/grpcio-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6be5c807b717be3dd649446f021301fd7907e376318675d2147823071034112a", size = 8634792, upload-time = "2026-07-23T15:19:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8b/7fe6a73895e3bdd788101d1276e48e0d262ebb165afacec1ec4efebcd785/grpcio-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c834e86d8fd2f03d7e4db49a027f7c5b89c5b88eed305543a5295bd6fee61e40", size = 8000286, upload-time = "2026-07-23T15:19:07.739Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/9a779de2bcda8722501a056fad1bec3d1117977af0c080ab1fc0655fdf35/grpcio-1.83.0-cp310-cp310-win32.whl", hash = "sha256:35a5b1c192496b6c25956eebfa963468935612206fd2543ac3ce981e6a5e0f03", size = 4404616, upload-time = "2026-07-23T15:19:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8e/ce9a23590cac33a6c24e6386cc0ffc55821cc13212acc822e98f00a67161/grpcio-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:8f6c395e493d20c39b29392ca200e9aaeb78d0bc2f04db0c0a7da7ddc939aa57", size = 5162304, upload-time = "2026-07-23T15:19:11.467Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720, upload-time = "2026-07-23T15:19:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773, upload-time = "2026-07-23T15:19:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203, upload-time = "2026-07-23T15:19:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508, upload-time = "2026-07-23T15:19:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466, upload-time = "2026-07-23T15:19:21.478Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583, upload-time = "2026-07-23T15:19:23.74Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810, upload-time = "2026-07-23T15:19:25.536Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021, upload-time = "2026-07-23T15:19:27.863Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376, upload-time = "2026-07-23T15:19:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469, upload-time = "2026-07-23T15:19:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/b1/50b17b3a2ba8970dbb00b2035a4df218bc6ec88d2d77fc7da4e42e1c7b19/grpcio_tools-1.83.0.tar.gz", hash = "sha256:515907265d14fa9975d0c7723f95a9da01463d7ac607546a03f8741f86a1bb07", size = 6400437, upload-time = "2026-07-23T15:22:18.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/00/4a0b426262c8488a02d7fcabe72a070c4691ea79ebf050d543d5cf054234/grpcio_tools-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:f281cb706999676bb841bcd57129a69091b9286236c89d6114c752ebf6cd5a1b", size = 2652630, upload-time = "2026-07-23T15:20:49.826Z" }, + { url = "https://files.pythonhosted.org/packages/10/22/c5ebf22b6975b9846d54b0e5328eea1780ba5906419e43d305415e110611/grpcio_tools-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3647c6adae9528dd56183061371151e3a96f71c299dc69c540318c3af2233a88", size = 5967265, upload-time = "2026-07-23T15:20:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/3d/06/c0a9bad5cc5b1eef47647c480fc9aac82ae67e723db8f5e90e56bdb5adf9/grpcio_tools-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6346c688d25bcf264e55f0c5f48ae825f7a2906ab969ed3b4a93df53e48bf07", size = 2704488, upload-time = "2026-07-23T15:20:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8e/e54c7475e0fb528861f66a2e2a0b892a86b5a636905336337d29e091c2c1/grpcio_tools-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:281d5d056d7ba839f4fff9b63f9ad239fe3353fe10e28e782251bdae6ba68306", size = 3032303, upload-time = "2026-07-23T15:20:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4f/2221bc79d7c2315d5e3604f353c470b380386fe5d7f8d4740a7d00630394/grpcio_tools-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35af1be2fe409abf9817ec43c34aab8a99189b15530eb78f3a94a6b1266d8b12", size = 2773919, upload-time = "2026-07-23T15:20:57.187Z" }, + { url = "https://files.pythonhosted.org/packages/93/94/50b1e7b1526e11e8e7cc6796cf71707ce6bc8714d9bacdd7290a2039b140/grpcio_tools-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7f5f5b6e1a91422069601fbf94f7fc970a7647fa69d2bc9f59e38913523117af", size = 3226535, upload-time = "2026-07-23T15:20:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/fa/df/3de83d76f30a76ba85464d72a579199a1191ad027f5c89786db767b22f83/grpcio_tools-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cad9333c0d5afcc2ffb26bebc5e8f097e3218b964758c3c65609dbcb77ec2aa7", size = 3798914, upload-time = "2026-07-23T15:21:00.05Z" }, + { url = "https://files.pythonhosted.org/packages/f4/51/957d040037a142b7232b5f1bfc0abfdac0acbdaec74f2ba3f39cc570ccb2/grpcio_tools-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2106b29b9dae5068acab7ee2f6b104d3054b4f31289f105b4afe1fcfbf1966c4", size = 3457747, upload-time = "2026-07-23T15:21:01.511Z" }, + { url = "https://files.pythonhosted.org/packages/49/27/7199a053df071d5c9924aee050d6aea391892f05cb6eb266365a72c8b05c/grpcio_tools-1.83.0-cp310-cp310-win32.whl", hash = "sha256:a47e674e6afac5d73ee3a87d57ed53f7b79cc01f44d602fe6ee90919aa171583", size = 1022559, upload-time = "2026-07-23T15:21:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/32/d4/6201e4948618a60b5fdfecd3b193da9b86c76e1aa8555573da78649900a5/grpcio_tools-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:88fc53ee3ce28d3ea2fe8fe1d3ed57854d0d25d6dac18e74c1f24e0a377bb509", size = 1192122, upload-time = "2026-07-23T15:21:04.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f0/d55c59b3af39d2ca975a2bafc5be1a60e0460a1b507bdbcf7bb8a17567db/grpcio_tools-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:72471a4a46909f1d798836c0a0aa2f568e10f6404585d7b22ac7330dd6a7bc74", size = 2652835, upload-time = "2026-07-23T15:21:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/82/97/59bdf27c5f99848087b3b268c90b3fcc557d400c8d0224cf49c7ac573e60/grpcio_tools-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:7da547dd1e0b1fe3d6d5677e9c1848969ecdbd51a92c342cf82d885c5935de7d", size = 5967887, upload-time = "2026-07-23T15:21:08.19Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c4/117d6688c4cb40240ce48da1ffac005e8da8bf63785634bdbb9143ca367e/grpcio_tools-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33350bd94c4913f8eaf6ca79ce69cc673bb46ca5f800e6474b1d6899ed321dad", size = 2704869, upload-time = "2026-07-23T15:21:09.857Z" }, + { url = "https://files.pythonhosted.org/packages/1a/51/e4d1a89f69072bb93b975548c40fe9524219ab5777cce113c1e2183b1f2d/grpcio_tools-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0f27da6dc58c910f31dcee4fcbfd05659c10c14b9e132f4f17da48d867e75eb4", size = 3032318, upload-time = "2026-07-23T15:21:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/ae/26/f9a082b79ae7f7dac7050047614000721dcf3d1c6d14bffa881b1f7a9774/grpcio_tools-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3a5000e8540efb80f74d9c760f36ed9408294d76edb0fa4b87fd287b0a8a258", size = 2774113, upload-time = "2026-07-23T15:21:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/de/59/b715d431218f0e8382490db7b1d01b3d32392c359c1886eee7e2551edaa8/grpcio_tools-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f710032264ed114c9d3b52f4c5cf71d68ccf70f25c4cb5776fe60388374bc01b", size = 3226715, upload-time = "2026-07-23T15:21:14.88Z" }, + { url = "https://files.pythonhosted.org/packages/5f/99/c4e91a2116062f7b42ab99459eeda15ec59a07daad01f4f38753fb1584ac/grpcio_tools-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ad00de87334154901e9af50a7f3f95261a0133502c6c0cea1f4e6107245154c3", size = 3799000, upload-time = "2026-07-23T15:21:16.352Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f7/0bb5e45a1f51822c0bcf5a355a06132c43b9dbe5c283edc8d0b63bec7626/grpcio_tools-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:de2b0c645363f7c4b005f145e06e790870dfb21bb7e162dec6fee2f054de9291", size = 3457774, upload-time = "2026-07-23T15:21:17.953Z" }, + { url = "https://files.pythonhosted.org/packages/e7/51/52aec42a3059bad4263a7a82c715a0fc08220f7533be41f0b9177b05468a/grpcio_tools-1.83.0-cp311-cp311-win32.whl", hash = "sha256:6d1a1c9e62689d04b63b227558b759a55dce8fb81a3934d3b5f95d1ee26a2b45", size = 1022798, upload-time = "2026-07-23T15:21:19.854Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/034d6daa5b2fae4e4c1718111c1b791589c3743d7d8fb07984e0b59c7f2c/grpcio_tools-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:a6ac3cc2c2d77f869a96dfaf2b1315852878babddfe2dc49b9fd47afbf502865", size = 1192474, upload-time = "2026-07-23T15:21:21.467Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/02e4d809d880785a613697e4c0f8134a436ec0142dc3c11989ad1a1c787a/grpcio_tools-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:bd93bbe18c4424805fd2e39854f75d76f80655175621254dc43cb45ec8e91e85", size = 2653283, upload-time = "2026-07-23T15:21:22.973Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/23e8423ac54c3858a5cfbca8a954fa292cab7b8a9a1ee9dc3259b906b763/grpcio_tools-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:dc2d370563ee1ee6c1769e49df35ef3f6e75cea8f25acf4ac6e54b335e6f788f", size = 5965938, upload-time = "2026-07-23T15:21:24.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a1/6eb17cf322bfbb76a9b9a8a5ca4a6b27f0af84821145969fc464feedaa0e/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8350e236470700b02bc4ba7f27a8796559630170e6527dda41c0344fbe988e56", size = 2705429, upload-time = "2026-07-23T15:21:26.519Z" }, + { url = "https://files.pythonhosted.org/packages/d4/84/f3c7e5e91e5d40ee792f112260bd329db5381de6f83b21387dd6163ebe51/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b1649f47c4675c1540ad2a77005f4d08392c06202686a0b1bb6b894f96cb75fe", size = 3033412, upload-time = "2026-07-23T15:21:28.183Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ec/acd6c1800925b0d60f8a670b68cf5bc3566aee60e7cb90178b34253bf53a/grpcio_tools-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d4edc6ba9fdca70bbf585ff6ab5971b8cd6140b4b316df66fd91d168bc1b617", size = 2774500, upload-time = "2026-07-23T15:21:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/36/e6/c7c6f4e25b7344f1fbc5df69606bfd4b9692d4e32d17781c665d3ed45e70/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:762a9f8a4a4a39bda02feebed94efb8d778e0e5a82d0c8f786dce5ddcb950c7f", size = 3229875, upload-time = "2026-07-23T15:21:31.617Z" }, + { url = "https://files.pythonhosted.org/packages/77/fc/9cbdc4606f378a9c2b569c0b6b57f181f97787006be4131a5820d469b70a/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c6c469c928a183f1a99ab26e263fe307347ee7023fa623b55bc778846b2f51b9", size = 3803163, upload-time = "2026-07-23T15:21:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0b/3b754886a02ead1487a967fd11ff13e920218fcf6c8e174f6de0c26dd819/grpcio_tools-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7980b3ca9dd31c42468c5af8cec97037f83715ea6efbe1b936ecb9c6832ac0f5", size = 3461815, upload-time = "2026-07-23T15:21:34.982Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/4629c154853f6f677299cde014e615084c15ad1d4fedd6845d2e7d354c7e/grpcio_tools-1.83.0-cp312-cp312-win32.whl", hash = "sha256:fd2ff46917f566b3b63dae191d1b05ef2188fe51e756ef321cbdd707ab29dbfb", size = 1022490, upload-time = "2026-07-23T15:21:36.696Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/696b9671e32a693c67299724a1f70f81dfb78aca6a3283ea6a65d54e92b8/grpcio_tools-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:92d2343806b5c21162a57fbaad24fcd8d935ef530f95a0f706a4e2546fdc0662", size = 1192286, upload-time = "2026-07-23T15:21:38.446Z" }, + { url = "https://files.pythonhosted.org/packages/63/2f/a7a4465b2a5b74b479373bf44d86da5840d7d20871764a39fb300e55e093/grpcio_tools-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:3277cfbb7cbbd2d72921fbcd7aba6c8ab1c91a9ab27e8045ac0a0f2e0517cec9", size = 2652845, upload-time = "2026-07-23T15:21:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/ed8ebaae3bd0ecd2387b0fdb3a696bd4b4d4565d18589ee3ba7c6affcbed/grpcio_tools-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1aa9617ce9c2bcbfb8f2fa08e6259e1b3cadaab0316e41f71496847af2f0a664", size = 5963575, upload-time = "2026-07-23T15:21:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/13/23/75bddae583077f1374c64e2a87b5924bdbffe162b3855af30c30a0fc8c5c/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ad37c786ea92825534466052f5f22f1f29983b1d00ca71ad43e256715a86bba3", size = 2705094, upload-time = "2026-07-23T15:21:43.675Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ac/7e1b12b1c5afff4f7d578f3c4eafbb1849f8004ddcc236cd3ff95c8be607/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c82216864d435ecf6f535798d03e9f6b9025a672a2e815715d629aba4ba70349", size = 3033061, upload-time = "2026-07-23T15:21:45.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6d/113291a7aad0c47a1e2ba2375595dfc3c2ab648e0ea05ff3d6f0f89055b3/grpcio_tools-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:04284627655629387b63278591e5efd0aded28d3a08432fe8a8765e4daf2d5b2", size = 2773649, upload-time = "2026-07-23T15:21:47.404Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c2/ce1c6c2475ed5cb0f7c6689bde57e3c105714676227da11b423ff37620a9/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fe627a5248d8e712f5ec3e019420050e61b10600ed241264aefb379a0f1b338", size = 3229788, upload-time = "2026-07-23T15:21:49.441Z" }, + { url = "https://files.pythonhosted.org/packages/c5/53/823fd52c29630398706de400ce7003b03e17ce91df024fc53cde810d2758/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1d5a9a9664d2f4bdda000652e7febf9ab4545a6391c8f05babd52ecb27d7e03e", size = 3802531, upload-time = "2026-07-23T15:21:51.37Z" }, + { url = "https://files.pythonhosted.org/packages/30/85/942ee07caf97b75ead416c6ad5f2fb12b16b8bc92fa3d60bbbea4c06e076/grpcio_tools-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4bf0e421e1ab5f2cd638de44fc903aed3ba4a2fcb19b93c6f528ff0ec63e3a6a", size = 3461032, upload-time = "2026-07-23T15:21:53.343Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/54cb428ea18912fa9541509fbc2e10f9807d96e88cf435f86e63c540ac25/grpcio_tools-1.83.0-cp313-cp313-win32.whl", hash = "sha256:7b1bd6db403b38addded54866187eba6f9ab9afadf72bb8d0515ed13f0b16c5c", size = 1022116, upload-time = "2026-07-23T15:21:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/77/80/2369320766091f6daedb924d133037a9f8b84bfb3e4d02d6ccffcd57b0cd/grpcio_tools-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:d654c645af7cf608a30644bffb8d1ef6b14e8846482c3d0131d0dda91f6fb590", size = 1191934, upload-time = "2026-07-23T15:21:56.862Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bd/3abb9c200f90110805553ca0e8f7908a0b89485ea99bb271daa37eaafb72/grpcio_tools-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:1ea047ff4bd2bb32fe5268042cb9c9e7bb054e932b52ad756642622f032ef656", size = 2652841, upload-time = "2026-07-23T15:21:58.726Z" }, + { url = "https://files.pythonhosted.org/packages/a2/46/d5beb04f0ffe552e55eaddd3413786e17a1fa68edb2fd8398969c38bc7e8/grpcio_tools-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7a8ac9cb3fbf7a5e4fe59f211e77b4fa4d51279c9f480e6ff98037cf56da1ad8", size = 5963493, upload-time = "2026-07-23T15:22:00.547Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/d8e01ca3281cb59722372c415024a7e70e8a653e70e2075e875394e4f761/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a5fa95fb33a600d2491867a1048f47baa27a830eac01f475043b8ccf63a471eb", size = 2705303, upload-time = "2026-07-23T15:22:02.448Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/75b382d8f116e274ce639ec55a6908dc792627b01d3c47b4f8991701203b/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0ca67941524662e01adea91571bb79df1ac9b4b2641812ef8636e21945119bee", size = 3033047, upload-time = "2026-07-23T15:22:04.445Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9f/200fb55729b735192fded061f53aa37c88cf9f58933cb108e16f5c1fd967/grpcio_tools-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e2a5816a5b6b06b42a6989f02944841c2a8b3daaa7f033dac9267f70078028ef", size = 2773830, upload-time = "2026-07-23T15:22:06.519Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/e585c4e255599ec45f2e07e0f570352158354285afd65ef30ceda97b445e/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f4a83f002895b11c4a862c366d77165825e0064aff14bf2c7453f59f66599b0e", size = 3229907, upload-time = "2026-07-23T15:22:08.358Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/0c098b4ff64d948666b79d0036ce104b8c2209e6f4d1594046044dcfa25d/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1cfee967ae073bc064862971871229248965422f38556096aec76db19d8a8c79", size = 3802600, upload-time = "2026-07-23T15:22:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e1/249047aab0da8c6b3b2e0156e6868aa0b598973ddf53f59186c43664ff96/grpcio_tools-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f7e82ee718ae09f879cb832e4517a56691de24383a2da673be184ad8b18e452f", size = 3461307, upload-time = "2026-07-23T15:22:12.33Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/11a228c47acecde2e05715bda4480e5a695690e00776c1db391e5a02c1f7/grpcio_tools-1.83.0-cp314-cp314-win32.whl", hash = "sha256:846fd211ebb72f50d39d3874cc0d616c2b9bcb71db51121ca86af29eec013c74", size = 1045047, upload-time = "2026-07-23T15:22:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/30/b1/d1f150b2ab3b4ae9932c05104fe1edbcb7fbf505587ea8db99e49341a05f/grpcio_tools-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8c9686b0c19f70b63d8d6cfeff5ad3480bdedecd60f14711fe43950f5397253", size = 1224199, upload-time = "2026-07-23T15:22:16.064Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/38/d591d9f66d43d897ecbd249f2833665823d19c8b043f16619bc8343e23df/msgspec-0.21.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064", size = 195172, upload-time = "2026-04-12T21:43:45.062Z" }, + { url = "https://files.pythonhosted.org/packages/69/1a/6899188b5982ec1324e0c629b7801eed2db987f6634fab58abd9fc82d317/msgspec-0.21.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238", size = 188316, upload-time = "2026-04-12T21:43:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/9e/95/7e591b4fa11fdbbf9891164473c23420a8c781ef553295abe416bf335f42/msgspec-0.21.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce", size = 216565, upload-time = "2026-04-12T21:43:48.081Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/714feeaf3b84cf2027235681725593840153dedd2868578f9f2715e296bb/msgspec-0.21.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027", size = 222689, upload-time = "2026-04-12T21:43:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b9/4384243e814f2579e5205e17d170b9c1a30121afd1393298d904817a7fa7/msgspec-0.21.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06", size = 222343, upload-time = "2026-04-12T21:43:50.612Z" }, + { url = "https://files.pythonhosted.org/packages/04/01/4b227d9c4057346271043632bad41979cf8c3dca372e41bb1f7d546395b2/msgspec-0.21.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae", size = 225607, upload-time = "2026-04-12T21:43:52.129Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/27021d1c3e5da837743092a7b7a5e8818397e1f4c05ee8b068bd7d1fd78a/msgspec-0.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449", size = 188392, upload-time = "2026-04-12T21:43:53.384Z" }, + { url = "https://files.pythonhosted.org/packages/80/2b/daf7a8d6d7cf00e0dcd0439178b284ade701234abdcadf3385601da04fbd/msgspec-0.21.1-cp310-cp310-win_arm64.whl", hash = "sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e", size = 174191, upload-time = "2026-04-12T21:43:54.6Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" }, + { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyrefly" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" }, + { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" }, + { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" }, + { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" }, + { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" }, + { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "tokenize-rt" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/ed/8f07e893132d5051d86a553e749d5c89b2a4776eb3a579b72ed61f8559ca/tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6", size = 5476, upload-time = "2025-05-23T23:48:00.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44", size = 6004, upload-time = "2025-05-23T23:47:58.812Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "ty" +version = "0.0.65" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cf561927e8e9ab5c1892a833b664aa9cd6f051a75f6280c66d8047246bda/ty-0.0.65.tar.gz", hash = "sha256:b7134bffcc00b715fa8291e84d845782ced810a998dc1f7f11d71c85c4046325", size = 6460098, upload-time = "2026-07-29T18:31:03.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/4e/71e2d325d2b53a1afad81624ad076b2ede413213fc4a18cb05b78c568571/ty-0.0.65-py3-none-linux_armv6l.whl", hash = "sha256:dc556c9f05408bef4c4ef02b2cc382e4e5f797b4b20d64410289848f0d76705f", size = 12298466, upload-time = "2026-07-29T18:30:12.744Z" }, + { url = "https://files.pythonhosted.org/packages/57/77/fec8f29647c55794efa430a7f365e44f5ce7ffb6459d9445a87fac569bec/ty-0.0.65-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29d2e0d34cc0a28a17ef0cf81135c5ebabc3562131f9079138ba5e7bae0f56bd", size = 11942421, upload-time = "2026-07-29T18:30:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/13/09/7f3766aef9dc627e2698cf4e3e59cf53389dcae3812040d33c1aa931230f/ty-0.0.65-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685f49a9312bbf69d5b65bbb66384fed1f927403ea030c217b9289092d7e46c4", size = 11451922, upload-time = "2026-07-29T18:30:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/1a77cd50e0befb50f55b8bf9bd3ed3eddf184bf28c61b56727039e0774fc/ty-0.0.65-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f564b5ebe78e2f3a8e7b8eacb1292eb88b7c0f3c8630671cfca31abc0709cd9", size = 11994999, upload-time = "2026-07-29T18:30:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/7b/feda16f3a4a0a99be27431e0c9598eeeec0db1eb2fec9a15976698209418/ty-0.0.65-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c983e156fe9e113fb56389e13d327b6b8549fe866de9b269684723a88e9b732d", size = 12090662, upload-time = "2026-07-29T18:30:24.93Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3e/3f69bf9c9307dbdc0771719f65ce5b556e7bdeeaccbdd599d4f57866d801/ty-0.0.65-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3663b7396e8b1a9954e20e732de7ccb0192bf4118473069b4945920d6923921", size = 12822094, upload-time = "2026-07-29T18:30:28.012Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/8fa791b3bb503ee2b46ad81690cd1bdd54519582df6d805cee57fe143e85/ty-0.0.65-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306ed01f29d6e108e98feb233dbbf5878a027603b71bd3743b343977933a9f16", size = 13357833, upload-time = "2026-07-29T18:30:31.122Z" }, + { url = "https://files.pythonhosted.org/packages/c1/73/4dda396a201e1dd0ed3594a9b48e559cb41c4bc048c6cd4c4d1b39eb4313/ty-0.0.65-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28bcfc8898c94f079a9100e684bcf312b6a64ad3a7d4ebb35a4591546030a2cd", size = 12977303, upload-time = "2026-07-29T18:30:33.944Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/c250c2c569adc53a8591716641388397bcb2a442e4a30b952ae81b50c0e0/ty-0.0.65-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a75bd0c245c38802a8f488378e74f92feb7dd33db7d63fbdd6fdf82791ba730", size = 12579338, upload-time = "2026-07-29T18:30:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/d3/94/4a5647d44753ca218fc930d7e4d9bf468d0ed4a0ad4b3d57588bc1bbacf7/ty-0.0.65-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9e5e1bdea9662d2b5312b4e99f319f4e6e2ea427511b5fbc546141b79ec53f76", size = 12957731, upload-time = "2026-07-29T18:30:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/1e22fa11a1e0dfb20b1c7f3cbfd8170273aada2a82f9ecd3055275370c44/ty-0.0.65-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:03a88493d4842889f65280ae241e06b399d57eb3c63571054cad21a4c33b3b69", size = 11938625, upload-time = "2026-07-29T18:30:42.603Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/fe5f22ef62b193201bc5566762e22049762cd485bfafb5095a7050760054/ty-0.0.65-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:600b8bf6f4940cf7ffb2f43d3716faaf38dcb97cd8617c55771451bc0276408f", size = 12105592, upload-time = "2026-07-29T18:30:45.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/fd/922b3a6e9d697452cdbb4b7e3f636868add5ec652154518a736e4364f3b7/ty-0.0.65-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c28007bc79d648c1ddaf1e65885d07baec48eb87240da442f608e4107c1b7d8", size = 12387335, upload-time = "2026-07-29T18:30:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/77/22/a1a08ebc84c083db2fb55e3b5cd186db0c067692f4921146f601360231e2/ty-0.0.65-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c852da96091ad22361e6586b7c7ba98e1334dcd4d8ffb67e47f4fb673de33f77", size = 12682710, upload-time = "2026-07-29T18:30:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/eaaa410a25bbdea19722109b5422380a0e211b3afcf3071d15953ddbd5db/ty-0.0.65-py3-none-win32.whl", hash = "sha256:cf529d538f1403b14b0511e6ec3cdb95d3d974adabf24cc76cedc533368c3edc", size = 11692341, upload-time = "2026-07-29T18:30:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/6d48f206dce9d7e53fe3b5ea0f0ab5800dd9d2365b2b48f736783436c43f/ty-0.0.65-py3-none-win_amd64.whl", hash = "sha256:234a321e33c7cbbfbd67bfa0b01b685dd9c21f1841781a21e5ca1fa0b25f1d5d", size = 12729355, upload-time = "2026-07-29T18:30:57.275Z" }, + { url = "https://files.pythonhosted.org/packages/96/aa/7446f7725e303cf78e058c893af1f0552b9451895454908706f4c6c3494b/ty-0.0.65-py3-none-win_arm64.whl", hash = "sha256:b9424be1ec56d93ff18609fb1c0a0a2283fe1282cd6d1c7604f97d73b94d61f2", size = 12051375, upload-time = "2026-07-29T18:31:00.579Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "unasync" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, + { name = "tokenize-rt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/4e/735dbc0885ca197bcd80a2479ca24035627e2e768c784261fc7f1b8d7600/unasync-0.6.0.tar.gz", hash = "sha256:a9d01ace3e1068b20550ab15b7f9723b15b8bcde728bc1770bcb578374c7ee58", size = 18755, upload-time = "2024-05-03T11:14:58.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/b5/d2842541718ffa12060854735587543120a31ebc339435e0bd0faf368541/unasync-0.6.0-py3-none-any.whl", hash = "sha256:9cf7aaaea9737e417d8949bf9be55dc25fdb4ef1f4edc21b58f76ff0d2b9d73f", size = 9959, upload-time = "2024-05-03T11:14:56.17Z" }, +] From 7ffc0462198ef8c1725a1f338a60c1374ad2f16e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 00:59:30 +0800 Subject: [PATCH 02/20] feat(sdk): generate the sandbox wire types into arcbox/_gen scripts/gen_proto.py drives the protoc bundled with grpcio-tools over rpc/arcbox-protocol/proto/arcbox/sandbox/v1, flattens the package, and rewrites intra-package imports to relative ones so the modules are location-independent under arcbox._gen. Output (py + pyi) is committed and never exported; bundled protoc 7.35.1 matches the pinned protobuf runtime. --- sdk/python/scripts/gen_proto.py | 83 ++++ sdk/python/src/arcbox/_gen/__init__.py | 1 + sdk/python/src/arcbox/_gen/errors_pb2.py | 42 ++ sdk/python/src/arcbox/_gen/errors_pb2.pyi | 66 +++ sdk/python/src/arcbox/_gen/filesystem_pb2.py | 71 +++ sdk/python/src/arcbox/_gen/filesystem_pb2.pyi | 173 +++++++ sdk/python/src/arcbox/_gen/process_pb2.py | 83 ++++ sdk/python/src/arcbox/_gen/process_pb2.pyi | 245 ++++++++++ sdk/python/src/arcbox/_gen/sandbox_pb2.py | 126 ++++++ sdk/python/src/arcbox/_gen/sandbox_pb2.pyi | 427 ++++++++++++++++++ sdk/python/src/arcbox/_gen/snapshot_pb2.py | 70 +++ sdk/python/src/arcbox/_gen/snapshot_pb2.pyi | 119 +++++ sdk/python/src/arcbox/_gen/template_pb2.py | 75 +++ sdk/python/src/arcbox/_gen/template_pb2.pyi | 148 ++++++ 14 files changed, 1729 insertions(+) create mode 100644 sdk/python/scripts/gen_proto.py create mode 100644 sdk/python/src/arcbox/_gen/__init__.py create mode 100644 sdk/python/src/arcbox/_gen/errors_pb2.py create mode 100644 sdk/python/src/arcbox/_gen/errors_pb2.pyi create mode 100644 sdk/python/src/arcbox/_gen/filesystem_pb2.py create mode 100644 sdk/python/src/arcbox/_gen/filesystem_pb2.pyi create mode 100644 sdk/python/src/arcbox/_gen/process_pb2.py create mode 100644 sdk/python/src/arcbox/_gen/process_pb2.pyi create mode 100644 sdk/python/src/arcbox/_gen/sandbox_pb2.py create mode 100644 sdk/python/src/arcbox/_gen/sandbox_pb2.pyi create mode 100644 sdk/python/src/arcbox/_gen/snapshot_pb2.py create mode 100644 sdk/python/src/arcbox/_gen/snapshot_pb2.pyi create mode 100644 sdk/python/src/arcbox/_gen/template_pb2.py create mode 100644 sdk/python/src/arcbox/_gen/template_pb2.pyi diff --git a/sdk/python/scripts/gen_proto.py b/sdk/python/scripts/gen_proto.py new file mode 100644 index 000000000..1cad9df21 --- /dev/null +++ b/sdk/python/scripts/gen_proto.py @@ -0,0 +1,83 @@ +"""Regenerate `arcbox/_gen` from the repo's sandbox protos. + +Runs the protoc bundled with grpcio-tools over +`rpc/arcbox-protocol/proto/arcbox/sandbox/v1`, then flattens the output +package and rewrites the intra-package imports to relative ones so the +generated modules are location-independent under `arcbox._gen`. + +Usage: uv run python scripts/gen_proto.py +""" + +from __future__ import annotations + +import re +import shutil +import sys +import tempfile +from pathlib import Path + +from grpc_tools import protoc + +SDK_ROOT = Path(__file__).resolve().parent.parent +REPO_PROTO_DIR = SDK_ROOT.parent.parent / "rpc" / "arcbox-protocol" / "proto" +PROTO_PACKAGE_DIR = "arcbox/sandbox/v1" +GEN_DIR = SDK_ROOT / "src" / "arcbox" / "_gen" + +GEN_INIT = '"""Generated wire types (protoc). Private — never exported."""\n' + +# protoc emits absolute imports following the proto package path; the +# flattened modules live side by side, so both styles become relative. +IMPORT_REWRITES = [ + (re.compile(r"^from arcbox\.sandbox\.v1 import "), "from . import "), + (re.compile(r"^import arcbox\.sandbox\.v1\."), "from . import "), +] + + +def well_known_include() -> str: + """grpcio-tools bundles the well-known protos next to its package.""" + return str(Path(protoc.__file__).parent / "_proto") + + +def rewrite_imports(text: str) -> str: + lines = [] + for line in text.splitlines(keepends=True): + for pattern, replacement in IMPORT_REWRITES: + line = pattern.sub(replacement, line) + lines.append(line) + return "".join(lines) + + +def main() -> int: + protos = sorted((REPO_PROTO_DIR / PROTO_PACKAGE_DIR).glob("*.proto")) + if not protos: + print(f"no protos found under {REPO_PROTO_DIR / PROTO_PACKAGE_DIR}") + return 1 + + with tempfile.TemporaryDirectory() as tmp: + args = [ + "protoc", + f"--proto_path={REPO_PROTO_DIR}", + f"--proto_path={well_known_include()}", + f"--python_out={tmp}", + f"--pyi_out={tmp}", + *(str(p) for p in protos), + ] + if (code := protoc.main(args)) != 0: + return code + + if GEN_DIR.exists(): + shutil.rmtree(GEN_DIR) + GEN_DIR.mkdir(parents=True) + (GEN_DIR / "__init__.py").write_text(GEN_INIT) + + for generated in sorted((Path(tmp) / PROTO_PACKAGE_DIR).iterdir()): + (GEN_DIR / generated.name).write_text( + rewrite_imports(generated.read_text()) + ) + + print(f"regenerated {GEN_DIR}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sdk/python/src/arcbox/_gen/__init__.py b/sdk/python/src/arcbox/_gen/__init__.py new file mode 100644 index 000000000..7b775c79b --- /dev/null +++ b/sdk/python/src/arcbox/_gen/__init__.py @@ -0,0 +1 @@ +"""Generated wire types (protoc). Private — never exported.""" diff --git a/sdk/python/src/arcbox/_gen/errors_pb2.py b/sdk/python/src/arcbox/_gen/errors_pb2.py new file mode 100644 index 000000000..1d7a05930 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/errors_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/errors.proto +# Protobuf Python Version: 7.35.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 runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/errors.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x61rcbox/sandbox/v1/errors.proto\x12\x11\x61rcbox.sandbox.v1\"\xb7\x01\n\tErrorInfo\x12*\n\x04\x63ode\x18\x01 \x01(\x0e\x32\x1c.arcbox.sandbox.v1.ErrorCode\x12\x12\n\nsuggestion\x18\x02 \x01(\t\x12:\n\x07\x63ontext\x18\x03 \x03(\x0b\x32).arcbox.sandbox.v1.ErrorInfo.ContextEntry\x1a.\n\x0c\x43ontextEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x04\n\tErrorCode\x12\x1a\n\x16\x45RROR_CODE_UNSPECIFIED\x10\x00\x12 \n\x1c\x45RROR_CODE_SANDBOX_NOT_FOUND\x10\x01\x12!\n\x1d\x45RROR_CODE_TEMPLATE_NOT_FOUND\x10\x02\x12\"\n\x1e\x45RROR_CODE_EXECUTION_NOT_FOUND\x10\x03\x12\x1d\n\x19\x45RROR_CODE_FILE_NOT_FOUND\x10\x04\x12\x1d\n\x19\x45RROR_CODE_SANDBOX_PAUSED\x10\x05\x12 \n\x1c\x45RROR_CODE_SANDBOX_NOT_READY\x10\x06\x12\x1d\n\x19\x45RROR_CODE_SANDBOX_FAILED\x10\x07\x12\x1a\n\x16\x45RROR_CODE_TTL_EXPIRED\x10\x08\x12\x1e\n\x1a\x45RROR_CODE_COMMAND_TIMEOUT\x10\t\x12&\n\"ERROR_CODE_NESTED_VIRT_UNSUPPORTED\x10\n\x12\x1f\n\x1b\x45RROR_CODE_TEMPLATE_INVALID\x10\x0b\x12\x1d\n\x19\x45RROR_CODE_FILE_TOO_LARGE\x10\x0c\x12\x1b\n\x17\x45RROR_CODE_STDIN_CLOSED\x10\r\x12\x1b\n\x17\x45RROR_CODE_TTY_REQUIRED\x10\x0e\x12\x1a\n\x16\x45RROR_CODE_PORT_IN_USE\x10\x0f\x12\x1c\n\x18\x45RROR_CODE_AUTH_REQUIRED\x10\x10\x12 \n\x1c\x45RROR_CODE_PROTOCOL_MISMATCH\x10\x11\x12&\n\"ERROR_CODE_RESOURCE_EXHAUSTED_HOST\x10\x12\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.errors_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_ERRORINFO_CONTEXTENTRY']._loaded_options = None + _globals['_ERRORINFO_CONTEXTENTRY']._serialized_options = b'8\001' + _globals['_ERRORCODE']._serialized_start=240 + _globals['_ERRORCODE']._serialized_end=865 + _globals['_ERRORINFO']._serialized_start=54 + _globals['_ERRORINFO']._serialized_end=237 + _globals['_ERRORINFO_CONTEXTENTRY']._serialized_start=191 + _globals['_ERRORINFO_CONTEXTENTRY']._serialized_end=237 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/errors_pb2.pyi b/sdk/python/src/arcbox/_gen/errors_pb2.pyi new file mode 100644 index 000000000..b645088a7 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/errors_pb2.pyi @@ -0,0 +1,66 @@ +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 collections.abc import Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class ErrorCode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + ERROR_CODE_UNSPECIFIED: _ClassVar[ErrorCode] + ERROR_CODE_SANDBOX_NOT_FOUND: _ClassVar[ErrorCode] + ERROR_CODE_TEMPLATE_NOT_FOUND: _ClassVar[ErrorCode] + ERROR_CODE_EXECUTION_NOT_FOUND: _ClassVar[ErrorCode] + ERROR_CODE_FILE_NOT_FOUND: _ClassVar[ErrorCode] + ERROR_CODE_SANDBOX_PAUSED: _ClassVar[ErrorCode] + ERROR_CODE_SANDBOX_NOT_READY: _ClassVar[ErrorCode] + ERROR_CODE_SANDBOX_FAILED: _ClassVar[ErrorCode] + ERROR_CODE_TTL_EXPIRED: _ClassVar[ErrorCode] + ERROR_CODE_COMMAND_TIMEOUT: _ClassVar[ErrorCode] + ERROR_CODE_NESTED_VIRT_UNSUPPORTED: _ClassVar[ErrorCode] + ERROR_CODE_TEMPLATE_INVALID: _ClassVar[ErrorCode] + ERROR_CODE_FILE_TOO_LARGE: _ClassVar[ErrorCode] + ERROR_CODE_STDIN_CLOSED: _ClassVar[ErrorCode] + ERROR_CODE_TTY_REQUIRED: _ClassVar[ErrorCode] + ERROR_CODE_PORT_IN_USE: _ClassVar[ErrorCode] + ERROR_CODE_AUTH_REQUIRED: _ClassVar[ErrorCode] + ERROR_CODE_PROTOCOL_MISMATCH: _ClassVar[ErrorCode] + ERROR_CODE_RESOURCE_EXHAUSTED_HOST: _ClassVar[ErrorCode] +ERROR_CODE_UNSPECIFIED: ErrorCode +ERROR_CODE_SANDBOX_NOT_FOUND: ErrorCode +ERROR_CODE_TEMPLATE_NOT_FOUND: ErrorCode +ERROR_CODE_EXECUTION_NOT_FOUND: ErrorCode +ERROR_CODE_FILE_NOT_FOUND: ErrorCode +ERROR_CODE_SANDBOX_PAUSED: ErrorCode +ERROR_CODE_SANDBOX_NOT_READY: ErrorCode +ERROR_CODE_SANDBOX_FAILED: ErrorCode +ERROR_CODE_TTL_EXPIRED: ErrorCode +ERROR_CODE_COMMAND_TIMEOUT: ErrorCode +ERROR_CODE_NESTED_VIRT_UNSUPPORTED: ErrorCode +ERROR_CODE_TEMPLATE_INVALID: ErrorCode +ERROR_CODE_FILE_TOO_LARGE: ErrorCode +ERROR_CODE_STDIN_CLOSED: ErrorCode +ERROR_CODE_TTY_REQUIRED: ErrorCode +ERROR_CODE_PORT_IN_USE: ErrorCode +ERROR_CODE_AUTH_REQUIRED: ErrorCode +ERROR_CODE_PROTOCOL_MISMATCH: ErrorCode +ERROR_CODE_RESOURCE_EXHAUSTED_HOST: ErrorCode + +class ErrorInfo(_message.Message): + __slots__ = ("code", "suggestion", "context") + class ContextEntry(_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: ... + CODE_FIELD_NUMBER: _ClassVar[int] + SUGGESTION_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + code: ErrorCode + suggestion: str + context: _containers.ScalarMap[str, str] + def __init__(self, code: _Optional[_Union[ErrorCode, str]] = ..., suggestion: _Optional[str] = ..., context: _Optional[_Mapping[str, str]] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/filesystem_pb2.py b/sdk/python/src/arcbox/_gen/filesystem_pb2.py new file mode 100644 index 000000000..814ef1ba3 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/filesystem_pb2.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/filesystem.proto +# Protobuf Python Version: 7.35.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 runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/filesystem.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import sandbox_pb2 as arcbox_dot_sandbox_dot_v1_dot_sandbox__pb2 +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"arcbox/sandbox/v1/filesystem.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1f\x61rcbox/sandbox/v1/sandbox.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"+\n\x0fReadFileRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\"\'\n\tFileChunk\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64one\x18\x02 \x01(\x08\"7\n\rWriteFileOpen\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x0c\n\x04mode\x18\x03 \x01(\r\"~\n\x10WriteFileRequest\x12\x30\n\x04open\x18\x01 \x01(\x0b\x32 .arcbox.sandbox.v1.WriteFileOpenH\x00\x12-\n\x05\x63hunk\x18\x02 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.FileChunkH\x00\x42\t\n\x07payload\"\xc2\x01\n\x08\x46ileStat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12)\n\x04kind\x18\x02 \x01(\x0e\x32\x1b.arcbox.sandbox.v1.FileKind\x12\x0c\n\x04size\x18\x03 \x01(\x04\x12\x0c\n\x04mode\x18\x04 \x01(\r\x12/\n\x0bmodified_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0b\n\x03uid\x18\x06 \x01(\r\x12\x0b\n\x03gid\x18\x07 \x01(\r\x12\x16\n\x0esymlink_target\x18\x08 \x01(\t\"+\n\x0fStatFileRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\"*\n\x0eListDirRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\"?\n\x0fListDirResponse\x12,\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x1b.arcbox.sandbox.v1.FileStat\"8\n\x0eMakeDirRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x0c\n\x04mode\x18\x03 \x01(\r\"A\n\x12RemoveEntryRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x11\n\trecursive\x18\x03 \x01(\x08\"B\n\x10MoveEntryRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x11\n\tfrom_path\x18\x02 \x01(\t\x12\x0f\n\x07to_path\x18\x03 \x01(\t\">\n\x0fWatchDirRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x11\n\trecursive\x18\x03 \x01(\x08\"Y\n\x07\x46sEvent\x12,\n\x04kind\x18\x01 \x01(\x0e\x32\x1e.arcbox.sandbox.v1.FsEventKind\x12\x0c\n\x04path\x18\x02 \x01(\t\x12\x12\n\nrenamed_to\x18\x03 \x01(\t\"~\n\x10WatchDirResponse\x12+\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x1a.arcbox.sandbox.v1.FsEventH\x00\x12\x32\n\nkeep_alive\x18\x02 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.KeepAliveH\x00\x42\t\n\x07payload*~\n\x08\x46ileKind\x12\x19\n\x15\x46ILE_KIND_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x46ILE_KIND_FILE\x10\x01\x12\x17\n\x13\x46ILE_KIND_DIRECTORY\x10\x02\x12\x15\n\x11\x46ILE_KIND_SYMLINK\x10\x03\x12\x13\n\x0f\x46ILE_KIND_OTHER\x10\x04*\x99\x01\n\x0b\x46sEventKind\x12\x1d\n\x19\x46S_EVENT_KIND_UNSPECIFIED\x10\x00\x12\x19\n\x15\x46S_EVENT_KIND_CREATED\x10\x01\x12\x1a\n\x16\x46S_EVENT_KIND_MODIFIED\x10\x02\x12\x19\n\x15\x46S_EVENT_KIND_REMOVED\x10\x03\x12\x19\n\x15\x46S_EVENT_KIND_RENAMED\x10\x04\x32\xfc\x04\n\x18SandboxFilesystemService\x12N\n\x08ReadFile\x12\".arcbox.sandbox.v1.ReadFileRequest\x1a\x1c.arcbox.sandbox.v1.FileChunk0\x01\x12J\n\tWriteFile\x12#.arcbox.sandbox.v1.WriteFileRequest\x1a\x16.google.protobuf.Empty(\x01\x12G\n\x04Stat\x12\".arcbox.sandbox.v1.StatFileRequest\x1a\x1b.arcbox.sandbox.v1.FileStat\x12P\n\x07ListDir\x12!.arcbox.sandbox.v1.ListDirRequest\x1a\".arcbox.sandbox.v1.ListDirResponse\x12\x44\n\x07MakeDir\x12!.arcbox.sandbox.v1.MakeDirRequest\x1a\x16.google.protobuf.Empty\x12G\n\x06Remove\x12%.arcbox.sandbox.v1.RemoveEntryRequest\x1a\x16.google.protobuf.Empty\x12\x43\n\x04Move\x12#.arcbox.sandbox.v1.MoveEntryRequest\x1a\x16.google.protobuf.Empty\x12U\n\x08WatchDir\x12\".arcbox.sandbox.v1.WatchDirRequest\x1a#.arcbox.sandbox.v1.WatchDirResponse0\x01\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.filesystem_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_FILEKIND']._serialized_start=1250 + _globals['_FILEKIND']._serialized_end=1376 + _globals['_FSEVENTKIND']._serialized_start=1379 + _globals['_FSEVENTKIND']._serialized_end=1532 + _globals['_READFILEREQUEST']._serialized_start=152 + _globals['_READFILEREQUEST']._serialized_end=195 + _globals['_FILECHUNK']._serialized_start=197 + _globals['_FILECHUNK']._serialized_end=236 + _globals['_WRITEFILEOPEN']._serialized_start=238 + _globals['_WRITEFILEOPEN']._serialized_end=293 + _globals['_WRITEFILEREQUEST']._serialized_start=295 + _globals['_WRITEFILEREQUEST']._serialized_end=421 + _globals['_FILESTAT']._serialized_start=424 + _globals['_FILESTAT']._serialized_end=618 + _globals['_STATFILEREQUEST']._serialized_start=620 + _globals['_STATFILEREQUEST']._serialized_end=663 + _globals['_LISTDIRREQUEST']._serialized_start=665 + _globals['_LISTDIRREQUEST']._serialized_end=707 + _globals['_LISTDIRRESPONSE']._serialized_start=709 + _globals['_LISTDIRRESPONSE']._serialized_end=772 + _globals['_MAKEDIRREQUEST']._serialized_start=774 + _globals['_MAKEDIRREQUEST']._serialized_end=830 + _globals['_REMOVEENTRYREQUEST']._serialized_start=832 + _globals['_REMOVEENTRYREQUEST']._serialized_end=897 + _globals['_MOVEENTRYREQUEST']._serialized_start=899 + _globals['_MOVEENTRYREQUEST']._serialized_end=965 + _globals['_WATCHDIRREQUEST']._serialized_start=967 + _globals['_WATCHDIRREQUEST']._serialized_end=1029 + _globals['_FSEVENT']._serialized_start=1031 + _globals['_FSEVENT']._serialized_end=1120 + _globals['_WATCHDIRRESPONSE']._serialized_start=1122 + _globals['_WATCHDIRRESPONSE']._serialized_end=1248 + _globals['_SANDBOXFILESYSTEMSERVICE']._serialized_start=1535 + _globals['_SANDBOXFILESYSTEMSERVICE']._serialized_end=2171 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/filesystem_pb2.pyi b/sdk/python/src/arcbox/_gen/filesystem_pb2.pyi new file mode 100644 index 000000000..eb958f809 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/filesystem_pb2.pyi @@ -0,0 +1,173 @@ +import datetime + +from . import sandbox_pb2 as _sandbox_pb2 +from google.protobuf import empty_pb2 as _empty_pb2 +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 collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class FileKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + FILE_KIND_UNSPECIFIED: _ClassVar[FileKind] + FILE_KIND_FILE: _ClassVar[FileKind] + FILE_KIND_DIRECTORY: _ClassVar[FileKind] + FILE_KIND_SYMLINK: _ClassVar[FileKind] + FILE_KIND_OTHER: _ClassVar[FileKind] + +class FsEventKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + FS_EVENT_KIND_UNSPECIFIED: _ClassVar[FsEventKind] + FS_EVENT_KIND_CREATED: _ClassVar[FsEventKind] + FS_EVENT_KIND_MODIFIED: _ClassVar[FsEventKind] + FS_EVENT_KIND_REMOVED: _ClassVar[FsEventKind] + FS_EVENT_KIND_RENAMED: _ClassVar[FsEventKind] +FILE_KIND_UNSPECIFIED: FileKind +FILE_KIND_FILE: FileKind +FILE_KIND_DIRECTORY: FileKind +FILE_KIND_SYMLINK: FileKind +FILE_KIND_OTHER: FileKind +FS_EVENT_KIND_UNSPECIFIED: FsEventKind +FS_EVENT_KIND_CREATED: FsEventKind +FS_EVENT_KIND_MODIFIED: FsEventKind +FS_EVENT_KIND_REMOVED: FsEventKind +FS_EVENT_KIND_RENAMED: FsEventKind + +class ReadFileRequest(_message.Message): + __slots__ = ("id", "path") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ...) -> None: ... + +class FileChunk(_message.Message): + __slots__ = ("data", "done") + DATA_FIELD_NUMBER: _ClassVar[int] + DONE_FIELD_NUMBER: _ClassVar[int] + data: bytes + done: bool + def __init__(self, data: _Optional[bytes] = ..., done: _Optional[bool] = ...) -> None: ... + +class WriteFileOpen(_message.Message): + __slots__ = ("id", "path", "mode") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + mode: int + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ..., mode: _Optional[int] = ...) -> None: ... + +class WriteFileRequest(_message.Message): + __slots__ = ("open", "chunk") + OPEN_FIELD_NUMBER: _ClassVar[int] + CHUNK_FIELD_NUMBER: _ClassVar[int] + open: WriteFileOpen + chunk: FileChunk + def __init__(self, open: _Optional[_Union[WriteFileOpen, _Mapping]] = ..., chunk: _Optional[_Union[FileChunk, _Mapping]] = ...) -> None: ... + +class FileStat(_message.Message): + __slots__ = ("name", "kind", "size", "mode", "modified_at", "uid", "gid", "symlink_target") + NAME_FIELD_NUMBER: _ClassVar[int] + KIND_FIELD_NUMBER: _ClassVar[int] + SIZE_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] + MODIFIED_AT_FIELD_NUMBER: _ClassVar[int] + UID_FIELD_NUMBER: _ClassVar[int] + GID_FIELD_NUMBER: _ClassVar[int] + SYMLINK_TARGET_FIELD_NUMBER: _ClassVar[int] + name: str + kind: FileKind + size: int + mode: int + modified_at: _timestamp_pb2.Timestamp + uid: int + gid: int + symlink_target: str + def __init__(self, name: _Optional[str] = ..., kind: _Optional[_Union[FileKind, str]] = ..., size: _Optional[int] = ..., mode: _Optional[int] = ..., modified_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., uid: _Optional[int] = ..., gid: _Optional[int] = ..., symlink_target: _Optional[str] = ...) -> None: ... + +class StatFileRequest(_message.Message): + __slots__ = ("id", "path") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ...) -> None: ... + +class ListDirRequest(_message.Message): + __slots__ = ("id", "path") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ...) -> None: ... + +class ListDirResponse(_message.Message): + __slots__ = ("entries",) + ENTRIES_FIELD_NUMBER: _ClassVar[int] + entries: _containers.RepeatedCompositeFieldContainer[FileStat] + def __init__(self, entries: _Optional[_Iterable[_Union[FileStat, _Mapping]]] = ...) -> None: ... + +class MakeDirRequest(_message.Message): + __slots__ = ("id", "path", "mode") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + mode: int + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ..., mode: _Optional[int] = ...) -> None: ... + +class RemoveEntryRequest(_message.Message): + __slots__ = ("id", "path", "recursive") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + RECURSIVE_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + recursive: bool + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ..., recursive: _Optional[bool] = ...) -> None: ... + +class MoveEntryRequest(_message.Message): + __slots__ = ("id", "from_path", "to_path") + ID_FIELD_NUMBER: _ClassVar[int] + FROM_PATH_FIELD_NUMBER: _ClassVar[int] + TO_PATH_FIELD_NUMBER: _ClassVar[int] + id: str + from_path: str + to_path: str + def __init__(self, id: _Optional[str] = ..., from_path: _Optional[str] = ..., to_path: _Optional[str] = ...) -> None: ... + +class WatchDirRequest(_message.Message): + __slots__ = ("id", "path", "recursive") + ID_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + RECURSIVE_FIELD_NUMBER: _ClassVar[int] + id: str + path: str + recursive: bool + def __init__(self, id: _Optional[str] = ..., path: _Optional[str] = ..., recursive: _Optional[bool] = ...) -> None: ... + +class FsEvent(_message.Message): + __slots__ = ("kind", "path", "renamed_to") + KIND_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + RENAMED_TO_FIELD_NUMBER: _ClassVar[int] + kind: FsEventKind + path: str + renamed_to: str + def __init__(self, kind: _Optional[_Union[FsEventKind, str]] = ..., path: _Optional[str] = ..., renamed_to: _Optional[str] = ...) -> None: ... + +class WatchDirResponse(_message.Message): + __slots__ = ("event", "keep_alive") + EVENT_FIELD_NUMBER: _ClassVar[int] + KEEP_ALIVE_FIELD_NUMBER: _ClassVar[int] + event: FsEvent + keep_alive: _sandbox_pb2.KeepAlive + def __init__(self, event: _Optional[_Union[FsEvent, _Mapping]] = ..., keep_alive: _Optional[_Union[_sandbox_pb2.KeepAlive, _Mapping]] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/process_pb2.py b/sdk/python/src/arcbox/_gen/process_pb2.py new file mode 100644 index 000000000..fa4840986 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/process_pb2.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/process.proto +# Protobuf Python Version: 7.35.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 runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/process.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import sandbox_pb2 as arcbox_dot_sandbox_dot_v1_dot_sandbox__pb2 +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1f\x61rcbox/sandbox/v1/process.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1f\x61rcbox/sandbox/v1/sandbox.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"-\n\x0cTerminalSize\x12\r\n\x05width\x18\x01 \x01(\r\x12\x0e\n\x06height\x18\x02 \x01(\r\"\xe3\x02\n\tExecution\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12\x30\n\x05state\x18\x03 \x01(\x0e\x32!.arcbox.sandbox.v1.ExecutionState\x12\x0b\n\x03tty\x18\x04 \x01(\x08\x12.\n\nstarted_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\texited_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x32\n\x0b\x65xit_status\x18\x07 \x01(\x0b\x32\x1d.arcbox.sandbox.v1.ExitStatus\x12\r\n\x05\x65rror\x18\x08 \x01(\t\x12\x12\n\nstdout_len\x18\t \x01(\x04\x12\x12\n\nstderr_len\x18\n \x01(\x04\x12-\n\x05stdin\x18\x0b \x01(\x0b\x32\x1e.arcbox.sandbox.v1.StdinStatus\"\xc5\x02\n\x15StartExecutionRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12\x0b\n\x03\x63md\x18\x03 \x03(\t\x12>\n\x03\x65nv\x18\x04 \x03(\x0b\x32\x31.arcbox.sandbox.v1.StartExecutionRequest.EnvEntry\x12\x13\n\x0bworking_dir\x18\x05 \x01(\t\x12\x0c\n\x04user\x18\x06 \x01(\t\x12\x0b\n\x03tty\x18\x07 \x01(\x08\x12\x31\n\x08tty_size\x18\x08 \x01(\x0b\x32\x1f.arcbox.sandbox.v1.TerminalSize\x12\x17\n\x0ftimeout_seconds\x18\t \x01(\r\x12\r\n\x05stdin\x18\n \x01(\x08\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"p\n\x16\x41ttachExecutionRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12\x15\n\rstdout_offset\x18\x03 \x01(\x04\x12\x15\n\rstderr_offset\x18\x04 \x01(\x04\"\xf1\x01\n\x0e\x45xecutionEvent\x12\x36\n\x07started\x18\x01 \x01(\x0b\x32#.arcbox.sandbox.v1.ExecutionStartedH\x00\x12\x34\n\x06output\x18\x02 \x01(\x0b\x32\".arcbox.sandbox.v1.ExecutionOutputH\x00\x12\x34\n\x06\x65xited\x18\x03 \x01(\x0b\x32\".arcbox.sandbox.v1.ExecutionExitedH\x00\x12\x32\n\nkeep_alive\x18\x04 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.KeepAliveH\x00\x42\x07\n\x05\x65vent\"C\n\x10\x45xecutionStarted\x12/\n\texecution\x18\x01 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.Execution\"a\n\x0f\x45xecutionOutput\x12\x30\n\x07\x63hannel\x18\x01 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.StdioChannel\x12\x0e\n\x06offset\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"B\n\x0f\x45xecutionExited\x12/\n\texecution\x18\x01 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.Execution\"h\n\x11WriteStdinRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12\x0e\n\x06offset\x18\x03 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x0b\n\x03\x65of\x18\x05 \x01(\x08\"4\n\x0bStdinStatus\x12\x15\n\rbytes_written\x18\x01 \x01(\x04\x12\x0e\n\x06\x63losed\x18\x02 \x01(\x08\"A\n\x15GetStdinStatusRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\"m\n\x16SignalExecutionRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12)\n\x06signal\x18\x03 \x01(\x0e\x32\x19.arcbox.sandbox.v1.Signal\"t\n\x19ResizeExecutionTtyRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12-\n\x04size\x18\x03 \x01(\x0b\x32\x1f.arcbox.sandbox.v1.TerminalSize\"Y\n\x14WaitExecutionRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x02 \x01(\t\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\r\"+\n\x15ListExecutionsRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\"J\n\x16ListExecutionsResponse\x12\x30\n\nexecutions\x18\x01 \x03(\x0b\x32\x1c.arcbox.sandbox.v1.Execution\"O\n\x12WaitForPortRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0c\n\x04port\x18\x02 \x01(\r\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\r*j\n\x0e\x45xecutionState\x12\x1f\n\x1b\x45XECUTION_STATE_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x45XECUTION_STATE_RUNNING\x10\x01\x12\x1a\n\x16\x45XECUTION_STATE_EXITED\x10\x02*x\n\x0cStdioChannel\x12\x1d\n\x19STDIO_CHANNEL_UNSPECIFIED\x10\x00\x12\x18\n\x14STDIO_CHANNEL_STDOUT\x10\x01\x12\x18\n\x14STDIO_CHANNEL_STDERR\x10\x02\x12\x15\n\x11STDIO_CHANNEL_PTY\x10\x03*\xaa\x01\n\x06Signal\x12\x16\n\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x11\n\rSIGNAL_SIGHUP\x10\x01\x12\x11\n\rSIGNAL_SIGINT\x10\x02\x12\x12\n\x0eSIGNAL_SIGQUIT\x10\x03\x12\x12\n\x0eSIGNAL_SIGKILL\x10\t\x12\x12\n\x0eSIGNAL_SIGUSR1\x10\n\x12\x12\n\x0eSIGNAL_SIGUSR2\x10\x0c\x12\x12\n\x0eSIGNAL_SIGTERM\x10\x0f\x32\x9a\x07\n\x15SandboxProcessService\x12X\n\x0eStartExecution\x12(.arcbox.sandbox.v1.StartExecutionRequest\x1a\x1c.arcbox.sandbox.v1.Execution\x12\x61\n\x0f\x41ttachExecution\x12).arcbox.sandbox.v1.AttachExecutionRequest\x1a!.arcbox.sandbox.v1.ExecutionEvent0\x01\x12R\n\nWriteStdin\x12$.arcbox.sandbox.v1.WriteStdinRequest\x1a\x1e.arcbox.sandbox.v1.StdinStatus\x12U\n\x0bStreamStdin\x12$.arcbox.sandbox.v1.WriteStdinRequest\x1a\x1e.arcbox.sandbox.v1.StdinStatus(\x01\x12Z\n\x0eGetStdinStatus\x12(.arcbox.sandbox.v1.GetStdinStatusRequest\x1a\x1e.arcbox.sandbox.v1.StdinStatus\x12T\n\x0fSignalExecution\x12).arcbox.sandbox.v1.SignalExecutionRequest\x1a\x16.google.protobuf.Empty\x12Z\n\x12ResizeExecutionTty\x12,.arcbox.sandbox.v1.ResizeExecutionTtyRequest\x1a\x16.google.protobuf.Empty\x12V\n\rWaitExecution\x12\'.arcbox.sandbox.v1.WaitExecutionRequest\x1a\x1c.arcbox.sandbox.v1.Execution\x12\x65\n\x0eListExecutions\x12(.arcbox.sandbox.v1.ListExecutionsRequest\x1a).arcbox.sandbox.v1.ListExecutionsResponse\x12L\n\x0bWaitForPort\x12%.arcbox.sandbox.v1.WaitForPortRequest\x1a\x16.google.protobuf.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.process_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_STARTEXECUTIONREQUEST_ENVENTRY']._loaded_options = None + _globals['_STARTEXECUTIONREQUEST_ENVENTRY']._serialized_options = b'8\001' + _globals['_EXECUTIONSTATE']._serialized_start=2225 + _globals['_EXECUTIONSTATE']._serialized_end=2331 + _globals['_STDIOCHANNEL']._serialized_start=2333 + _globals['_STDIOCHANNEL']._serialized_end=2453 + _globals['_SIGNAL']._serialized_start=2456 + _globals['_SIGNAL']._serialized_end=2626 + _globals['_TERMINALSIZE']._serialized_start=149 + _globals['_TERMINALSIZE']._serialized_end=194 + _globals['_EXECUTION']._serialized_start=197 + _globals['_EXECUTION']._serialized_end=552 + _globals['_STARTEXECUTIONREQUEST']._serialized_start=555 + _globals['_STARTEXECUTIONREQUEST']._serialized_end=880 + _globals['_STARTEXECUTIONREQUEST_ENVENTRY']._serialized_start=838 + _globals['_STARTEXECUTIONREQUEST_ENVENTRY']._serialized_end=880 + _globals['_ATTACHEXECUTIONREQUEST']._serialized_start=882 + _globals['_ATTACHEXECUTIONREQUEST']._serialized_end=994 + _globals['_EXECUTIONEVENT']._serialized_start=997 + _globals['_EXECUTIONEVENT']._serialized_end=1238 + _globals['_EXECUTIONSTARTED']._serialized_start=1240 + _globals['_EXECUTIONSTARTED']._serialized_end=1307 + _globals['_EXECUTIONOUTPUT']._serialized_start=1309 + _globals['_EXECUTIONOUTPUT']._serialized_end=1406 + _globals['_EXECUTIONEXITED']._serialized_start=1408 + _globals['_EXECUTIONEXITED']._serialized_end=1474 + _globals['_WRITESTDINREQUEST']._serialized_start=1476 + _globals['_WRITESTDINREQUEST']._serialized_end=1580 + _globals['_STDINSTATUS']._serialized_start=1582 + _globals['_STDINSTATUS']._serialized_end=1634 + _globals['_GETSTDINSTATUSREQUEST']._serialized_start=1636 + _globals['_GETSTDINSTATUSREQUEST']._serialized_end=1701 + _globals['_SIGNALEXECUTIONREQUEST']._serialized_start=1703 + _globals['_SIGNALEXECUTIONREQUEST']._serialized_end=1812 + _globals['_RESIZEEXECUTIONTTYREQUEST']._serialized_start=1814 + _globals['_RESIZEEXECUTIONTTYREQUEST']._serialized_end=1930 + _globals['_WAITEXECUTIONREQUEST']._serialized_start=1932 + _globals['_WAITEXECUTIONREQUEST']._serialized_end=2021 + _globals['_LISTEXECUTIONSREQUEST']._serialized_start=2023 + _globals['_LISTEXECUTIONSREQUEST']._serialized_end=2066 + _globals['_LISTEXECUTIONSRESPONSE']._serialized_start=2068 + _globals['_LISTEXECUTIONSRESPONSE']._serialized_end=2142 + _globals['_WAITFORPORTREQUEST']._serialized_start=2144 + _globals['_WAITFORPORTREQUEST']._serialized_end=2223 + _globals['_SANDBOXPROCESSSERVICE']._serialized_start=2629 + _globals['_SANDBOXPROCESSSERVICE']._serialized_end=3551 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/process_pb2.pyi b/sdk/python/src/arcbox/_gen/process_pb2.pyi new file mode 100644 index 000000000..80f2f7667 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/process_pb2.pyi @@ -0,0 +1,245 @@ +import datetime + +from . import sandbox_pb2 as _sandbox_pb2 +from google.protobuf import empty_pb2 as _empty_pb2 +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 collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class ExecutionState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + EXECUTION_STATE_UNSPECIFIED: _ClassVar[ExecutionState] + EXECUTION_STATE_RUNNING: _ClassVar[ExecutionState] + EXECUTION_STATE_EXITED: _ClassVar[ExecutionState] + +class StdioChannel(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + STDIO_CHANNEL_UNSPECIFIED: _ClassVar[StdioChannel] + STDIO_CHANNEL_STDOUT: _ClassVar[StdioChannel] + STDIO_CHANNEL_STDERR: _ClassVar[StdioChannel] + STDIO_CHANNEL_PTY: _ClassVar[StdioChannel] + +class Signal(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SIGNAL_UNSPECIFIED: _ClassVar[Signal] + SIGNAL_SIGHUP: _ClassVar[Signal] + SIGNAL_SIGINT: _ClassVar[Signal] + SIGNAL_SIGQUIT: _ClassVar[Signal] + SIGNAL_SIGKILL: _ClassVar[Signal] + SIGNAL_SIGUSR1: _ClassVar[Signal] + SIGNAL_SIGUSR2: _ClassVar[Signal] + SIGNAL_SIGTERM: _ClassVar[Signal] +EXECUTION_STATE_UNSPECIFIED: ExecutionState +EXECUTION_STATE_RUNNING: ExecutionState +EXECUTION_STATE_EXITED: ExecutionState +STDIO_CHANNEL_UNSPECIFIED: StdioChannel +STDIO_CHANNEL_STDOUT: StdioChannel +STDIO_CHANNEL_STDERR: StdioChannel +STDIO_CHANNEL_PTY: StdioChannel +SIGNAL_UNSPECIFIED: Signal +SIGNAL_SIGHUP: Signal +SIGNAL_SIGINT: Signal +SIGNAL_SIGQUIT: Signal +SIGNAL_SIGKILL: Signal +SIGNAL_SIGUSR1: Signal +SIGNAL_SIGUSR2: Signal +SIGNAL_SIGTERM: Signal + +class TerminalSize(_message.Message): + __slots__ = ("width", "height") + WIDTH_FIELD_NUMBER: _ClassVar[int] + HEIGHT_FIELD_NUMBER: _ClassVar[int] + width: int + height: int + def __init__(self, width: _Optional[int] = ..., height: _Optional[int] = ...) -> None: ... + +class Execution(_message.Message): + __slots__ = ("id", "sandbox_id", "state", "tty", "started_at", "exited_at", "exit_status", "error", "stdout_len", "stderr_len", "stdin") + ID_FIELD_NUMBER: _ClassVar[int] + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + TTY_FIELD_NUMBER: _ClassVar[int] + STARTED_AT_FIELD_NUMBER: _ClassVar[int] + EXITED_AT_FIELD_NUMBER: _ClassVar[int] + EXIT_STATUS_FIELD_NUMBER: _ClassVar[int] + ERROR_FIELD_NUMBER: _ClassVar[int] + STDOUT_LEN_FIELD_NUMBER: _ClassVar[int] + STDERR_LEN_FIELD_NUMBER: _ClassVar[int] + STDIN_FIELD_NUMBER: _ClassVar[int] + id: str + sandbox_id: str + state: ExecutionState + tty: bool + started_at: _timestamp_pb2.Timestamp + exited_at: _timestamp_pb2.Timestamp + exit_status: _sandbox_pb2.ExitStatus + error: str + stdout_len: int + stderr_len: int + stdin: StdinStatus + def __init__(self, id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., state: _Optional[_Union[ExecutionState, str]] = ..., tty: _Optional[bool] = ..., started_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., exited_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., exit_status: _Optional[_Union[_sandbox_pb2.ExitStatus, _Mapping]] = ..., error: _Optional[str] = ..., stdout_len: _Optional[int] = ..., stderr_len: _Optional[int] = ..., stdin: _Optional[_Union[StdinStatus, _Mapping]] = ...) -> None: ... + +class StartExecutionRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "cmd", "env", "working_dir", "user", "tty", "tty_size", "timeout_seconds", "stdin") + class EnvEntry(_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: ... + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + CMD_FIELD_NUMBER: _ClassVar[int] + ENV_FIELD_NUMBER: _ClassVar[int] + WORKING_DIR_FIELD_NUMBER: _ClassVar[int] + USER_FIELD_NUMBER: _ClassVar[int] + TTY_FIELD_NUMBER: _ClassVar[int] + TTY_SIZE_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + STDIN_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + cmd: _containers.RepeatedScalarFieldContainer[str] + env: _containers.ScalarMap[str, str] + working_dir: str + user: str + tty: bool + tty_size: TerminalSize + timeout_seconds: int + stdin: bool + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., cmd: _Optional[_Iterable[str]] = ..., env: _Optional[_Mapping[str, str]] = ..., working_dir: _Optional[str] = ..., user: _Optional[str] = ..., tty: _Optional[bool] = ..., tty_size: _Optional[_Union[TerminalSize, _Mapping]] = ..., timeout_seconds: _Optional[int] = ..., stdin: _Optional[bool] = ...) -> None: ... + +class AttachExecutionRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "stdout_offset", "stderr_offset") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + STDOUT_OFFSET_FIELD_NUMBER: _ClassVar[int] + STDERR_OFFSET_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + stdout_offset: int + stderr_offset: int + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., stdout_offset: _Optional[int] = ..., stderr_offset: _Optional[int] = ...) -> None: ... + +class ExecutionEvent(_message.Message): + __slots__ = ("started", "output", "exited", "keep_alive") + STARTED_FIELD_NUMBER: _ClassVar[int] + OUTPUT_FIELD_NUMBER: _ClassVar[int] + EXITED_FIELD_NUMBER: _ClassVar[int] + KEEP_ALIVE_FIELD_NUMBER: _ClassVar[int] + started: ExecutionStarted + output: ExecutionOutput + exited: ExecutionExited + keep_alive: _sandbox_pb2.KeepAlive + def __init__(self, started: _Optional[_Union[ExecutionStarted, _Mapping]] = ..., output: _Optional[_Union[ExecutionOutput, _Mapping]] = ..., exited: _Optional[_Union[ExecutionExited, _Mapping]] = ..., keep_alive: _Optional[_Union[_sandbox_pb2.KeepAlive, _Mapping]] = ...) -> None: ... + +class ExecutionStarted(_message.Message): + __slots__ = ("execution",) + EXECUTION_FIELD_NUMBER: _ClassVar[int] + execution: Execution + def __init__(self, execution: _Optional[_Union[Execution, _Mapping]] = ...) -> None: ... + +class ExecutionOutput(_message.Message): + __slots__ = ("channel", "offset", "data") + CHANNEL_FIELD_NUMBER: _ClassVar[int] + OFFSET_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + channel: StdioChannel + offset: int + data: bytes + def __init__(self, channel: _Optional[_Union[StdioChannel, str]] = ..., offset: _Optional[int] = ..., data: _Optional[bytes] = ...) -> None: ... + +class ExecutionExited(_message.Message): + __slots__ = ("execution",) + EXECUTION_FIELD_NUMBER: _ClassVar[int] + execution: Execution + def __init__(self, execution: _Optional[_Union[Execution, _Mapping]] = ...) -> None: ... + +class WriteStdinRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "offset", "data", "eof") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + OFFSET_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + EOF_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + offset: int + data: bytes + eof: bool + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., offset: _Optional[int] = ..., data: _Optional[bytes] = ..., eof: _Optional[bool] = ...) -> None: ... + +class StdinStatus(_message.Message): + __slots__ = ("bytes_written", "closed") + BYTES_WRITTEN_FIELD_NUMBER: _ClassVar[int] + CLOSED_FIELD_NUMBER: _ClassVar[int] + bytes_written: int + closed: bool + def __init__(self, bytes_written: _Optional[int] = ..., closed: _Optional[bool] = ...) -> None: ... + +class GetStdinStatusRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ...) -> None: ... + +class SignalExecutionRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "signal") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + SIGNAL_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + signal: Signal + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., signal: _Optional[_Union[Signal, str]] = ...) -> None: ... + +class ResizeExecutionTtyRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "size") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + SIZE_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + size: TerminalSize + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., size: _Optional[_Union[TerminalSize, _Mapping]] = ...) -> None: ... + +class WaitExecutionRequest(_message.Message): + __slots__ = ("sandbox_id", "execution_id", "timeout_seconds") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + EXECUTION_ID_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + execution_id: str + timeout_seconds: int + def __init__(self, sandbox_id: _Optional[str] = ..., execution_id: _Optional[str] = ..., timeout_seconds: _Optional[int] = ...) -> None: ... + +class ListExecutionsRequest(_message.Message): + __slots__ = ("sandbox_id",) + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + def __init__(self, sandbox_id: _Optional[str] = ...) -> None: ... + +class ListExecutionsResponse(_message.Message): + __slots__ = ("executions",) + EXECUTIONS_FIELD_NUMBER: _ClassVar[int] + executions: _containers.RepeatedCompositeFieldContainer[Execution] + def __init__(self, executions: _Optional[_Iterable[_Union[Execution, _Mapping]]] = ...) -> None: ... + +class WaitForPortRequest(_message.Message): + __slots__ = ("sandbox_id", "port", "timeout_seconds") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + PORT_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + port: int + timeout_seconds: int + def __init__(self, sandbox_id: _Optional[str] = ..., port: _Optional[int] = ..., timeout_seconds: _Optional[int] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/sandbox_pb2.py b/sdk/python/src/arcbox/_gen/sandbox_pb2.py new file mode 100644 index 000000000..eb9cf4fcb --- /dev/null +++ b/sdk/python/src/arcbox/_gen/sandbox_pb2.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/sandbox.proto +# Protobuf Python Version: 7.35.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 runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/sandbox.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1f\x61rcbox/sandbox/v1/sandbox.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\";\n\x0bNetworkSpec\x12,\n\x04mode\x18\x01 \x01(\x0e\x32\x1e.arcbox.sandbox.v1.NetworkMode\"9\n\x05Mount\x12\x0e\n\x06source\x18\x01 \x01(\t\x12\x0e\n\x06target\x18\x02 \x01(\t\x12\x10\n\x08readonly\x18\x03 \x01(\x08\"3\n\x0eResourceLimits\x12\r\n\x05vcpus\x18\x01 \x01(\r\x12\x12\n\nmemory_mib\x18\x02 \x01(\x04\"8\n\nExitStatus\x12\x0e\n\x04\x63ode\x18\x01 \x01(\x05H\x00\x12\x10\n\x06signal\x18\x02 \x01(\x05H\x00\x42\x08\n\x06status\"\x0b\n\tKeepAlive\"\xd4\x05\n\x14\x43reateSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32\x33.arcbox.sandbox.v1.CreateSandboxRequest.LabelsEntry\x12\x31\n\x06limits\x18\x06 \x01(\x0b\x32!.arcbox.sandbox.v1.ResourceLimits\x12\x0b\n\x03\x63md\x18\x08 \x03(\t\x12\x16\n\x0eno_default_cmd\x18\x14 \x01(\x08\x12=\n\x03\x65nv\x18\t \x03(\x0b\x32\x30.arcbox.sandbox.v1.CreateSandboxRequest.EnvEntry\x12\x16\n\x0eno_default_env\x18\x15 \x01(\x08\x12\x13\n\x0bworking_dir\x18\n \x01(\t\x12\x0c\n\x04user\x18\x0b \x01(\t\x12(\n\x06mounts\x18\x0c \x03(\x0b\x32\x18.arcbox.sandbox.v1.Mount\x12/\n\x07network\x18\r \x01(\x0b\x32\x1e.arcbox.sandbox.v1.NetworkSpec\x12\x13\n\x0bttl_seconds\x18\x0e \x01(\r\x12\x1b\n\x0essh_public_key\x18\x0f \x01(\tH\x00\x88\x01\x01\x12\x10\n\x08template\x18\x11 \x01(\t\x12\x1c\n\x14idle_timeout_seconds\x18\x12 \x01(\r\x12.\n\x07on_idle\x18\x13 \x01(\x0e\x32\x1d.arcbox.sandbox.v1.IdleAction\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x11\n\x0f_ssh_public_keyJ\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06J\x04\x08\x07\x10\x08J\x04\x08\x10\x10\x11R\x06kernelR\x06rootfsR\tboot_argsR\x05image\"g\n\x15\x43reateSandboxResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nip_address\x18\x02 \x01(\t\x12.\n\x05state\x18\x03 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.SandboxState\"9\n\x12StopSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x17\n\x0ftimeout_seconds\x18\x02 \x01(\r\"1\n\x14RemoveSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05\x66orce\x18\x02 \x01(\x08\"!\n\x13PauseSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\"\"\n\x14ResumeSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\"\xc8\x01\n\x13SetLifecycleRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x18\n\x0bttl_seconds\x18\x02 \x01(\rH\x00\x88\x01\x01\x12!\n\x14idle_timeout_seconds\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\x33\n\x07on_idle\x18\x04 \x01(\x0e\x32\x1d.arcbox.sandbox.v1.IdleActionH\x02\x88\x01\x01\x42\x0e\n\x0c_ttl_secondsB\x17\n\x15_idle_timeout_secondsB\n\n\x08_on_idle\"\x18\n\x16GetCapabilitiesRequest\"\x93\x01\n\x17GetCapabilitiesResponse\x12\x16\n\x0e\x64\x61\x65mon_version\x18\x01 \x01(\t\x12\x10\n\x08protocol\x18\x02 \x01(\r\x12\x10\n\x08\x66\x65\x61tures\x18\x03 \x03(\t\x12<\n\x0bnested_virt\x18\x04 \x01(\x0b\x32\'.arcbox.sandbox.v1.NestedVirtCapability\"9\n\x14NestedVirtCapability\x12\x11\n\tsupported\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"#\n\x15InspectSandboxRequest\x12\n\n\x02id\x18\x01 \x01(\t\"\xfc\x05\n\x0bSandboxInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12.\n\x05state\x18\x02 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.SandboxState\x12:\n\x06labels\x18\x03 \x03(\x0b\x32*.arcbox.sandbox.v1.SandboxInfo.LabelsEntry\x12\x31\n\x06limits\x18\x04 \x01(\x0b\x32!.arcbox.sandbox.v1.ResourceLimits\x12\x32\n\x07network\x18\x05 \x01(\x0b\x32!.arcbox.sandbox.v1.SandboxNetwork\x12.\n\ncreated_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08ready_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x32\n\x0elast_exited_at\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x10last_exit_status\x18\t \x01(\x0b\x32\x1d.arcbox.sandbox.v1.ExitStatus\x12\r\n\x05\x65rror\x18\n \x01(\t\x12\x10\n\x08template\x18\x0b \x01(\t\x12\x30\n\x0cttl_deadline\x18\x0c \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1c\n\x14idle_timeout_seconds\x18\r \x01(\r\x12.\n\x07on_idle\x18\x0e \x01(\x0e\x32\x1d.arcbox.sandbox.v1.IdleAction\x12-\n\tpaused_at\x18\x0f \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\tfailed_at\x18\x10 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rstorage_bytes\x18\x11 \x01(\x04\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"E\n\x0eSandboxNetwork\x12\x12\n\nip_address\x18\x01 \x01(\t\x12\x0f\n\x07gateway\x18\x02 \x01(\tJ\x04\x08\x03\x10\x04R\x08tap_name\"\xe1\x01\n\x14ListSandboxesRequest\x12.\n\x05state\x18\x01 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.SandboxState\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32\x33.arcbox.sandbox.v1.ListSandboxesRequest.LabelsEntry\x12\x11\n\tpage_size\x18\x03 \x01(\r\x12\x12\n\npage_token\x18\x04 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"f\n\x15ListSandboxesResponse\x12\x34\n\tsandboxes\x18\x01 \x03(\x0b\x32!.arcbox.sandbox.v1.SandboxSummary\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\"\xa1\x03\n\x0eSandboxSummary\x12\n\n\x02id\x18\x01 \x01(\t\x12.\n\x05state\x18\x02 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.SandboxState\x12=\n\x06labels\x18\x03 \x03(\x0b\x32-.arcbox.sandbox.v1.SandboxSummary.LabelsEntry\x12\x12\n\nip_address\x18\x04 \x01(\t\x12.\n\ncreated_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08ready_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\tpaused_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12-\n\tfailed_at\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rstorage_bytes\x18\t \x01(\x04\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"]\n\x14SandboxEventsRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x31\n\x04kind\x18\x02 \x01(\x0e\x32#.arcbox.sandbox.v1.SandboxEventKind\"\x86\x01\n\x13WatchEventsResponse\x12\x30\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x1f.arcbox.sandbox.v1.SandboxEventH\x00\x12\x32\n\nkeep_alive\x18\x02 \x01(\x0b\x32\x1c.arcbox.sandbox.v1.KeepAliveH\x00\x42\t\n\x07payload\"\xf7\x01\n\x0cSandboxEvent\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x31\n\x04kind\x18\x02 \x01(\x0e\x32#.arcbox.sandbox.v1.SandboxEventKind\x12(\n\x04time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x43\n\nattributes\x18\x04 \x03(\x0b\x32/.arcbox.sandbox.v1.SandboxEvent.AttributesEntry\x1a\x31\n\x0f\x41ttributesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"{\n\x11\x45xposePortRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0csandbox_port\x18\x02 \x01(\r\x12\x11\n\thost_port\x18\x03 \x01(\r\x12\x31\n\x08protocol\x18\x04 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.PortProtocol\";\n\x12\x45xposePortResponse\x12\x11\n\thost_port\x18\x01 \x01(\r\x12\x12\n\nguest_port\x18\x02 \x01(\r\"j\n\x13UnexposePortRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x14\n\x0csandbox_port\x18\x02 \x01(\r\x12\x31\n\x08protocol\x18\x03 \x01(\x0e\x32\x1f.arcbox.sandbox.v1.PortProtocol*\x83\x02\n\x0cSandboxState\x12\x1d\n\x19SANDBOX_STATE_UNSPECIFIED\x10\x00\x12\x1a\n\x16SANDBOX_STATE_STARTING\x10\x01\x12\x17\n\x13SANDBOX_STATE_READY\x10\x02\x12\x19\n\x15SANDBOX_STATE_RUNNING\x10\x03\x12\x1a\n\x16SANDBOX_STATE_STOPPING\x10\x04\x12\x19\n\x15SANDBOX_STATE_STOPPED\x10\x05\x12\x18\n\x14SANDBOX_STATE_FAILED\x10\x06\x12\x19\n\x15SANDBOX_STATE_PAUSING\x10\x07\x12\x18\n\x14SANDBOX_STATE_PAUSED\x10\x08*\x90\x03\n\x10SandboxEventKind\x12\"\n\x1eSANDBOX_EVENT_KIND_UNSPECIFIED\x10\x00\x12\x1e\n\x1aSANDBOX_EVENT_KIND_CREATED\x10\x01\x12\x1c\n\x18SANDBOX_EVENT_KIND_READY\x10\x02\x12\x1e\n\x1aSANDBOX_EVENT_KIND_RUNNING\x10\x03\x12\x1b\n\x17SANDBOX_EVENT_KIND_IDLE\x10\x04\x12\x1f\n\x1bSANDBOX_EVENT_KIND_STOPPING\x10\x05\x12\x1e\n\x1aSANDBOX_EVENT_KIND_STOPPED\x10\x06\x12\x1d\n\x19SANDBOX_EVENT_KIND_FAILED\x10\x07\x12\x1e\n\x1aSANDBOX_EVENT_KIND_REMOVED\x10\x08\x12\x1e\n\x1aSANDBOX_EVENT_KIND_PAUSING\x10\t\x12\x1d\n\x19SANDBOX_EVENT_KIND_PAUSED\x10\n\x12\x1e\n\x1aSANDBOX_EVENT_KIND_RESUMED\x10\x0b*V\n\nIdleAction\x12\x1b\n\x17IDLE_ACTION_UNSPECIFIED\x10\x00\x12\x14\n\x10IDLE_ACTION_KILL\x10\x01\x12\x15\n\x11IDLE_ACTION_PAUSE\x10\x02*\\\n\x0bNetworkMode\x12\x1c\n\x18NETWORK_MODE_UNSPECIFIED\x10\x00\x12\x18\n\x14NETWORK_MODE_ENABLED\x10\x01\x12\x15\n\x11NETWORK_MODE_NONE\x10\x02*[\n\x0cPortProtocol\x12\x1d\n\x19PORT_PROTOCOL_UNSPECIFIED\x10\x00\x12\x15\n\x11PORT_PROTOCOL_TCP\x10\x01\x12\x15\n\x11PORT_PROTOCOL_UDP\x10\x02\x32\x85\x08\n\x0eSandboxService\x12[\n\x06\x43reate\x12\'.arcbox.sandbox.v1.CreateSandboxRequest\x1a(.arcbox.sandbox.v1.CreateSandboxResponse\x12\x45\n\x04Stop\x12%.arcbox.sandbox.v1.StopSandboxRequest\x1a\x16.google.protobuf.Empty\x12I\n\x06Remove\x12\'.arcbox.sandbox.v1.RemoveSandboxRequest\x1a\x16.google.protobuf.Empty\x12G\n\x05Pause\x12&.arcbox.sandbox.v1.PauseSandboxRequest\x1a\x16.google.protobuf.Empty\x12I\n\x06Resume\x12\'.arcbox.sandbox.v1.ResumeSandboxRequest\x1a\x16.google.protobuf.Empty\x12N\n\x0cSetLifecycle\x12&.arcbox.sandbox.v1.SetLifecycleRequest\x1a\x16.google.protobuf.Empty\x12h\n\x0fGetCapabilities\x12).arcbox.sandbox.v1.GetCapabilitiesRequest\x1a*.arcbox.sandbox.v1.GetCapabilitiesResponse\x12S\n\x07Inspect\x12(.arcbox.sandbox.v1.InspectSandboxRequest\x1a\x1e.arcbox.sandbox.v1.SandboxInfo\x12Y\n\x04List\x12\'.arcbox.sandbox.v1.ListSandboxesRequest\x1a(.arcbox.sandbox.v1.ListSandboxesResponse\x12[\n\x06\x45vents\x12\'.arcbox.sandbox.v1.SandboxEventsRequest\x1a&.arcbox.sandbox.v1.WatchEventsResponse0\x01\x12Y\n\nExposePort\x12$.arcbox.sandbox.v1.ExposePortRequest\x1a%.arcbox.sandbox.v1.ExposePortResponse\x12N\n\x0cUnexposePort\x12&.arcbox.sandbox.v1.UnexposePortRequest\x1a\x16.google.protobuf.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.sandbox_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_CREATESANDBOXREQUEST_LABELSENTRY']._loaded_options = None + _globals['_CREATESANDBOXREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_CREATESANDBOXREQUEST_ENVENTRY']._loaded_options = None + _globals['_CREATESANDBOXREQUEST_ENVENTRY']._serialized_options = b'8\001' + _globals['_SANDBOXINFO_LABELSENTRY']._loaded_options = None + _globals['_SANDBOXINFO_LABELSENTRY']._serialized_options = b'8\001' + _globals['_LISTSANDBOXESREQUEST_LABELSENTRY']._loaded_options = None + _globals['_LISTSANDBOXESREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_SANDBOXSUMMARY_LABELSENTRY']._loaded_options = None + _globals['_SANDBOXSUMMARY_LABELSENTRY']._serialized_options = b'8\001' + _globals['_SANDBOXEVENT_ATTRIBUTESENTRY']._loaded_options = None + _globals['_SANDBOXEVENT_ATTRIBUTESENTRY']._serialized_options = b'8\001' + _globals['_SANDBOXSTATE']._serialized_start=4215 + _globals['_SANDBOXSTATE']._serialized_end=4474 + _globals['_SANDBOXEVENTKIND']._serialized_start=4477 + _globals['_SANDBOXEVENTKIND']._serialized_end=4877 + _globals['_IDLEACTION']._serialized_start=4879 + _globals['_IDLEACTION']._serialized_end=4965 + _globals['_NETWORKMODE']._serialized_start=4967 + _globals['_NETWORKMODE']._serialized_end=5059 + _globals['_PORTPROTOCOL']._serialized_start=5061 + _globals['_PORTPROTOCOL']._serialized_end=5152 + _globals['_NETWORKSPEC']._serialized_start=116 + _globals['_NETWORKSPEC']._serialized_end=175 + _globals['_MOUNT']._serialized_start=177 + _globals['_MOUNT']._serialized_end=234 + _globals['_RESOURCELIMITS']._serialized_start=236 + _globals['_RESOURCELIMITS']._serialized_end=287 + _globals['_EXITSTATUS']._serialized_start=289 + _globals['_EXITSTATUS']._serialized_end=345 + _globals['_KEEPALIVE']._serialized_start=347 + _globals['_KEEPALIVE']._serialized_end=358 + _globals['_CREATESANDBOXREQUEST']._serialized_start=361 + _globals['_CREATESANDBOXREQUEST']._serialized_end=1085 + _globals['_CREATESANDBOXREQUEST_LABELSENTRY']._serialized_start=913 + _globals['_CREATESANDBOXREQUEST_LABELSENTRY']._serialized_end=958 + _globals['_CREATESANDBOXREQUEST_ENVENTRY']._serialized_start=960 + _globals['_CREATESANDBOXREQUEST_ENVENTRY']._serialized_end=1002 + _globals['_CREATESANDBOXRESPONSE']._serialized_start=1087 + _globals['_CREATESANDBOXRESPONSE']._serialized_end=1190 + _globals['_STOPSANDBOXREQUEST']._serialized_start=1192 + _globals['_STOPSANDBOXREQUEST']._serialized_end=1249 + _globals['_REMOVESANDBOXREQUEST']._serialized_start=1251 + _globals['_REMOVESANDBOXREQUEST']._serialized_end=1300 + _globals['_PAUSESANDBOXREQUEST']._serialized_start=1302 + _globals['_PAUSESANDBOXREQUEST']._serialized_end=1335 + _globals['_RESUMESANDBOXREQUEST']._serialized_start=1337 + _globals['_RESUMESANDBOXREQUEST']._serialized_end=1371 + _globals['_SETLIFECYCLEREQUEST']._serialized_start=1374 + _globals['_SETLIFECYCLEREQUEST']._serialized_end=1574 + _globals['_GETCAPABILITIESREQUEST']._serialized_start=1576 + _globals['_GETCAPABILITIESREQUEST']._serialized_end=1600 + _globals['_GETCAPABILITIESRESPONSE']._serialized_start=1603 + _globals['_GETCAPABILITIESRESPONSE']._serialized_end=1750 + _globals['_NESTEDVIRTCAPABILITY']._serialized_start=1752 + _globals['_NESTEDVIRTCAPABILITY']._serialized_end=1809 + _globals['_INSPECTSANDBOXREQUEST']._serialized_start=1811 + _globals['_INSPECTSANDBOXREQUEST']._serialized_end=1846 + _globals['_SANDBOXINFO']._serialized_start=1849 + _globals['_SANDBOXINFO']._serialized_end=2613 + _globals['_SANDBOXINFO_LABELSENTRY']._serialized_start=913 + _globals['_SANDBOXINFO_LABELSENTRY']._serialized_end=958 + _globals['_SANDBOXNETWORK']._serialized_start=2615 + _globals['_SANDBOXNETWORK']._serialized_end=2684 + _globals['_LISTSANDBOXESREQUEST']._serialized_start=2687 + _globals['_LISTSANDBOXESREQUEST']._serialized_end=2912 + _globals['_LISTSANDBOXESREQUEST_LABELSENTRY']._serialized_start=913 + _globals['_LISTSANDBOXESREQUEST_LABELSENTRY']._serialized_end=958 + _globals['_LISTSANDBOXESRESPONSE']._serialized_start=2914 + _globals['_LISTSANDBOXESRESPONSE']._serialized_end=3016 + _globals['_SANDBOXSUMMARY']._serialized_start=3019 + _globals['_SANDBOXSUMMARY']._serialized_end=3436 + _globals['_SANDBOXSUMMARY_LABELSENTRY']._serialized_start=913 + _globals['_SANDBOXSUMMARY_LABELSENTRY']._serialized_end=958 + _globals['_SANDBOXEVENTSREQUEST']._serialized_start=3438 + _globals['_SANDBOXEVENTSREQUEST']._serialized_end=3531 + _globals['_WATCHEVENTSRESPONSE']._serialized_start=3534 + _globals['_WATCHEVENTSRESPONSE']._serialized_end=3668 + _globals['_SANDBOXEVENT']._serialized_start=3671 + _globals['_SANDBOXEVENT']._serialized_end=3918 + _globals['_SANDBOXEVENT_ATTRIBUTESENTRY']._serialized_start=3869 + _globals['_SANDBOXEVENT_ATTRIBUTESENTRY']._serialized_end=3918 + _globals['_EXPOSEPORTREQUEST']._serialized_start=3920 + _globals['_EXPOSEPORTREQUEST']._serialized_end=4043 + _globals['_EXPOSEPORTRESPONSE']._serialized_start=4045 + _globals['_EXPOSEPORTRESPONSE']._serialized_end=4104 + _globals['_UNEXPOSEPORTREQUEST']._serialized_start=4106 + _globals['_UNEXPOSEPORTREQUEST']._serialized_end=4212 + _globals['_SANDBOXSERVICE']._serialized_start=5155 + _globals['_SANDBOXSERVICE']._serialized_end=6184 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/sandbox_pb2.pyi b/sdk/python/src/arcbox/_gen/sandbox_pb2.pyi new file mode 100644 index 000000000..a88c9963a --- /dev/null +++ b/sdk/python/src/arcbox/_gen/sandbox_pb2.pyi @@ -0,0 +1,427 @@ +import datetime + +from google.protobuf import empty_pb2 as _empty_pb2 +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 collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class SandboxState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SANDBOX_STATE_UNSPECIFIED: _ClassVar[SandboxState] + SANDBOX_STATE_STARTING: _ClassVar[SandboxState] + SANDBOX_STATE_READY: _ClassVar[SandboxState] + SANDBOX_STATE_RUNNING: _ClassVar[SandboxState] + SANDBOX_STATE_STOPPING: _ClassVar[SandboxState] + SANDBOX_STATE_STOPPED: _ClassVar[SandboxState] + SANDBOX_STATE_FAILED: _ClassVar[SandboxState] + SANDBOX_STATE_PAUSING: _ClassVar[SandboxState] + SANDBOX_STATE_PAUSED: _ClassVar[SandboxState] + +class SandboxEventKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + SANDBOX_EVENT_KIND_UNSPECIFIED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_CREATED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_READY: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_RUNNING: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_IDLE: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_STOPPING: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_STOPPED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_FAILED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_REMOVED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_PAUSING: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_PAUSED: _ClassVar[SandboxEventKind] + SANDBOX_EVENT_KIND_RESUMED: _ClassVar[SandboxEventKind] + +class IdleAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + IDLE_ACTION_UNSPECIFIED: _ClassVar[IdleAction] + IDLE_ACTION_KILL: _ClassVar[IdleAction] + IDLE_ACTION_PAUSE: _ClassVar[IdleAction] + +class NetworkMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + NETWORK_MODE_UNSPECIFIED: _ClassVar[NetworkMode] + NETWORK_MODE_ENABLED: _ClassVar[NetworkMode] + NETWORK_MODE_NONE: _ClassVar[NetworkMode] + +class PortProtocol(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + PORT_PROTOCOL_UNSPECIFIED: _ClassVar[PortProtocol] + PORT_PROTOCOL_TCP: _ClassVar[PortProtocol] + PORT_PROTOCOL_UDP: _ClassVar[PortProtocol] +SANDBOX_STATE_UNSPECIFIED: SandboxState +SANDBOX_STATE_STARTING: SandboxState +SANDBOX_STATE_READY: SandboxState +SANDBOX_STATE_RUNNING: SandboxState +SANDBOX_STATE_STOPPING: SandboxState +SANDBOX_STATE_STOPPED: SandboxState +SANDBOX_STATE_FAILED: SandboxState +SANDBOX_STATE_PAUSING: SandboxState +SANDBOX_STATE_PAUSED: SandboxState +SANDBOX_EVENT_KIND_UNSPECIFIED: SandboxEventKind +SANDBOX_EVENT_KIND_CREATED: SandboxEventKind +SANDBOX_EVENT_KIND_READY: SandboxEventKind +SANDBOX_EVENT_KIND_RUNNING: SandboxEventKind +SANDBOX_EVENT_KIND_IDLE: SandboxEventKind +SANDBOX_EVENT_KIND_STOPPING: SandboxEventKind +SANDBOX_EVENT_KIND_STOPPED: SandboxEventKind +SANDBOX_EVENT_KIND_FAILED: SandboxEventKind +SANDBOX_EVENT_KIND_REMOVED: SandboxEventKind +SANDBOX_EVENT_KIND_PAUSING: SandboxEventKind +SANDBOX_EVENT_KIND_PAUSED: SandboxEventKind +SANDBOX_EVENT_KIND_RESUMED: SandboxEventKind +IDLE_ACTION_UNSPECIFIED: IdleAction +IDLE_ACTION_KILL: IdleAction +IDLE_ACTION_PAUSE: IdleAction +NETWORK_MODE_UNSPECIFIED: NetworkMode +NETWORK_MODE_ENABLED: NetworkMode +NETWORK_MODE_NONE: NetworkMode +PORT_PROTOCOL_UNSPECIFIED: PortProtocol +PORT_PROTOCOL_TCP: PortProtocol +PORT_PROTOCOL_UDP: PortProtocol + +class NetworkSpec(_message.Message): + __slots__ = ("mode",) + MODE_FIELD_NUMBER: _ClassVar[int] + mode: NetworkMode + def __init__(self, mode: _Optional[_Union[NetworkMode, str]] = ...) -> None: ... + +class Mount(_message.Message): + __slots__ = ("source", "target", "readonly") + SOURCE_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + READONLY_FIELD_NUMBER: _ClassVar[int] + source: str + target: str + readonly: bool + def __init__(self, source: _Optional[str] = ..., target: _Optional[str] = ..., readonly: _Optional[bool] = ...) -> None: ... + +class ResourceLimits(_message.Message): + __slots__ = ("vcpus", "memory_mib") + VCPUS_FIELD_NUMBER: _ClassVar[int] + MEMORY_MIB_FIELD_NUMBER: _ClassVar[int] + vcpus: int + memory_mib: int + def __init__(self, vcpus: _Optional[int] = ..., memory_mib: _Optional[int] = ...) -> None: ... + +class ExitStatus(_message.Message): + __slots__ = ("code", "signal") + CODE_FIELD_NUMBER: _ClassVar[int] + SIGNAL_FIELD_NUMBER: _ClassVar[int] + code: int + signal: int + def __init__(self, code: _Optional[int] = ..., signal: _Optional[int] = ...) -> None: ... + +class KeepAlive(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class CreateSandboxRequest(_message.Message): + __slots__ = ("id", "labels", "limits", "cmd", "no_default_cmd", "env", "no_default_env", "working_dir", "user", "mounts", "network", "ttl_seconds", "ssh_public_key", "template", "idle_timeout_seconds", "on_idle") + class LabelsEntry(_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: ... + class EnvEntry(_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: ... + ID_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + LIMITS_FIELD_NUMBER: _ClassVar[int] + CMD_FIELD_NUMBER: _ClassVar[int] + NO_DEFAULT_CMD_FIELD_NUMBER: _ClassVar[int] + ENV_FIELD_NUMBER: _ClassVar[int] + NO_DEFAULT_ENV_FIELD_NUMBER: _ClassVar[int] + WORKING_DIR_FIELD_NUMBER: _ClassVar[int] + USER_FIELD_NUMBER: _ClassVar[int] + MOUNTS_FIELD_NUMBER: _ClassVar[int] + NETWORK_FIELD_NUMBER: _ClassVar[int] + TTL_SECONDS_FIELD_NUMBER: _ClassVar[int] + SSH_PUBLIC_KEY_FIELD_NUMBER: _ClassVar[int] + TEMPLATE_FIELD_NUMBER: _ClassVar[int] + IDLE_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + ON_IDLE_FIELD_NUMBER: _ClassVar[int] + id: str + labels: _containers.ScalarMap[str, str] + limits: ResourceLimits + cmd: _containers.RepeatedScalarFieldContainer[str] + no_default_cmd: bool + env: _containers.ScalarMap[str, str] + no_default_env: bool + working_dir: str + user: str + mounts: _containers.RepeatedCompositeFieldContainer[Mount] + network: NetworkSpec + ttl_seconds: int + ssh_public_key: str + template: str + idle_timeout_seconds: int + on_idle: IdleAction + def __init__(self, id: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ..., limits: _Optional[_Union[ResourceLimits, _Mapping]] = ..., cmd: _Optional[_Iterable[str]] = ..., no_default_cmd: _Optional[bool] = ..., env: _Optional[_Mapping[str, str]] = ..., no_default_env: _Optional[bool] = ..., working_dir: _Optional[str] = ..., user: _Optional[str] = ..., mounts: _Optional[_Iterable[_Union[Mount, _Mapping]]] = ..., network: _Optional[_Union[NetworkSpec, _Mapping]] = ..., ttl_seconds: _Optional[int] = ..., ssh_public_key: _Optional[str] = ..., template: _Optional[str] = ..., idle_timeout_seconds: _Optional[int] = ..., on_idle: _Optional[_Union[IdleAction, str]] = ...) -> None: ... + +class CreateSandboxResponse(_message.Message): + __slots__ = ("id", "ip_address", "state") + ID_FIELD_NUMBER: _ClassVar[int] + IP_ADDRESS_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + id: str + ip_address: str + state: SandboxState + def __init__(self, id: _Optional[str] = ..., ip_address: _Optional[str] = ..., state: _Optional[_Union[SandboxState, str]] = ...) -> None: ... + +class StopSandboxRequest(_message.Message): + __slots__ = ("id", "timeout_seconds") + ID_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + id: str + timeout_seconds: int + def __init__(self, id: _Optional[str] = ..., timeout_seconds: _Optional[int] = ...) -> None: ... + +class RemoveSandboxRequest(_message.Message): + __slots__ = ("id", "force") + ID_FIELD_NUMBER: _ClassVar[int] + FORCE_FIELD_NUMBER: _ClassVar[int] + id: str + force: bool + def __init__(self, id: _Optional[str] = ..., force: _Optional[bool] = ...) -> None: ... + +class PauseSandboxRequest(_message.Message): + __slots__ = ("id",) + ID_FIELD_NUMBER: _ClassVar[int] + id: str + def __init__(self, id: _Optional[str] = ...) -> None: ... + +class ResumeSandboxRequest(_message.Message): + __slots__ = ("id",) + ID_FIELD_NUMBER: _ClassVar[int] + id: str + def __init__(self, id: _Optional[str] = ...) -> None: ... + +class SetLifecycleRequest(_message.Message): + __slots__ = ("id", "ttl_seconds", "idle_timeout_seconds", "on_idle") + ID_FIELD_NUMBER: _ClassVar[int] + TTL_SECONDS_FIELD_NUMBER: _ClassVar[int] + IDLE_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + ON_IDLE_FIELD_NUMBER: _ClassVar[int] + id: str + ttl_seconds: int + idle_timeout_seconds: int + on_idle: IdleAction + def __init__(self, id: _Optional[str] = ..., ttl_seconds: _Optional[int] = ..., idle_timeout_seconds: _Optional[int] = ..., on_idle: _Optional[_Union[IdleAction, str]] = ...) -> None: ... + +class GetCapabilitiesRequest(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class GetCapabilitiesResponse(_message.Message): + __slots__ = ("daemon_version", "protocol", "features", "nested_virt") + DAEMON_VERSION_FIELD_NUMBER: _ClassVar[int] + PROTOCOL_FIELD_NUMBER: _ClassVar[int] + FEATURES_FIELD_NUMBER: _ClassVar[int] + NESTED_VIRT_FIELD_NUMBER: _ClassVar[int] + daemon_version: str + protocol: int + features: _containers.RepeatedScalarFieldContainer[str] + nested_virt: NestedVirtCapability + def __init__(self, daemon_version: _Optional[str] = ..., protocol: _Optional[int] = ..., features: _Optional[_Iterable[str]] = ..., nested_virt: _Optional[_Union[NestedVirtCapability, _Mapping]] = ...) -> None: ... + +class NestedVirtCapability(_message.Message): + __slots__ = ("supported", "reason") + SUPPORTED_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + supported: bool + reason: str + def __init__(self, supported: _Optional[bool] = ..., reason: _Optional[str] = ...) -> None: ... + +class InspectSandboxRequest(_message.Message): + __slots__ = ("id",) + ID_FIELD_NUMBER: _ClassVar[int] + id: str + def __init__(self, id: _Optional[str] = ...) -> None: ... + +class SandboxInfo(_message.Message): + __slots__ = ("id", "state", "labels", "limits", "network", "created_at", "ready_at", "last_exited_at", "last_exit_status", "error", "template", "ttl_deadline", "idle_timeout_seconds", "on_idle", "paused_at", "failed_at", "storage_bytes") + class LabelsEntry(_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: ... + ID_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + LIMITS_FIELD_NUMBER: _ClassVar[int] + NETWORK_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + READY_AT_FIELD_NUMBER: _ClassVar[int] + LAST_EXITED_AT_FIELD_NUMBER: _ClassVar[int] + LAST_EXIT_STATUS_FIELD_NUMBER: _ClassVar[int] + ERROR_FIELD_NUMBER: _ClassVar[int] + TEMPLATE_FIELD_NUMBER: _ClassVar[int] + TTL_DEADLINE_FIELD_NUMBER: _ClassVar[int] + IDLE_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + ON_IDLE_FIELD_NUMBER: _ClassVar[int] + PAUSED_AT_FIELD_NUMBER: _ClassVar[int] + FAILED_AT_FIELD_NUMBER: _ClassVar[int] + STORAGE_BYTES_FIELD_NUMBER: _ClassVar[int] + id: str + state: SandboxState + labels: _containers.ScalarMap[str, str] + limits: ResourceLimits + network: SandboxNetwork + created_at: _timestamp_pb2.Timestamp + ready_at: _timestamp_pb2.Timestamp + last_exited_at: _timestamp_pb2.Timestamp + last_exit_status: ExitStatus + error: str + template: str + ttl_deadline: _timestamp_pb2.Timestamp + idle_timeout_seconds: int + on_idle: IdleAction + paused_at: _timestamp_pb2.Timestamp + failed_at: _timestamp_pb2.Timestamp + storage_bytes: int + def __init__(self, id: _Optional[str] = ..., state: _Optional[_Union[SandboxState, str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., limits: _Optional[_Union[ResourceLimits, _Mapping]] = ..., network: _Optional[_Union[SandboxNetwork, _Mapping]] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., ready_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., last_exited_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., last_exit_status: _Optional[_Union[ExitStatus, _Mapping]] = ..., error: _Optional[str] = ..., template: _Optional[str] = ..., ttl_deadline: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., idle_timeout_seconds: _Optional[int] = ..., on_idle: _Optional[_Union[IdleAction, str]] = ..., paused_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., failed_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., storage_bytes: _Optional[int] = ...) -> None: ... + +class SandboxNetwork(_message.Message): + __slots__ = ("ip_address", "gateway") + IP_ADDRESS_FIELD_NUMBER: _ClassVar[int] + GATEWAY_FIELD_NUMBER: _ClassVar[int] + ip_address: str + gateway: str + def __init__(self, ip_address: _Optional[str] = ..., gateway: _Optional[str] = ...) -> None: ... + +class ListSandboxesRequest(_message.Message): + __slots__ = ("state", "labels", "page_size", "page_token") + class LabelsEntry(_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: ... + STATE_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + PAGE_SIZE_FIELD_NUMBER: _ClassVar[int] + PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + state: SandboxState + labels: _containers.ScalarMap[str, str] + page_size: int + page_token: str + def __init__(self, state: _Optional[_Union[SandboxState, str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., page_size: _Optional[int] = ..., page_token: _Optional[str] = ...) -> None: ... + +class ListSandboxesResponse(_message.Message): + __slots__ = ("sandboxes", "next_page_token") + SANDBOXES_FIELD_NUMBER: _ClassVar[int] + NEXT_PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + sandboxes: _containers.RepeatedCompositeFieldContainer[SandboxSummary] + next_page_token: str + def __init__(self, sandboxes: _Optional[_Iterable[_Union[SandboxSummary, _Mapping]]] = ..., next_page_token: _Optional[str] = ...) -> None: ... + +class SandboxSummary(_message.Message): + __slots__ = ("id", "state", "labels", "ip_address", "created_at", "ready_at", "paused_at", "failed_at", "storage_bytes") + class LabelsEntry(_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: ... + ID_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + IP_ADDRESS_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + READY_AT_FIELD_NUMBER: _ClassVar[int] + PAUSED_AT_FIELD_NUMBER: _ClassVar[int] + FAILED_AT_FIELD_NUMBER: _ClassVar[int] + STORAGE_BYTES_FIELD_NUMBER: _ClassVar[int] + id: str + state: SandboxState + labels: _containers.ScalarMap[str, str] + ip_address: str + created_at: _timestamp_pb2.Timestamp + ready_at: _timestamp_pb2.Timestamp + paused_at: _timestamp_pb2.Timestamp + failed_at: _timestamp_pb2.Timestamp + storage_bytes: int + def __init__(self, id: _Optional[str] = ..., state: _Optional[_Union[SandboxState, str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., ip_address: _Optional[str] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., ready_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., paused_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., failed_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., storage_bytes: _Optional[int] = ...) -> None: ... + +class SandboxEventsRequest(_message.Message): + __slots__ = ("sandbox_id", "kind") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + KIND_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + kind: SandboxEventKind + def __init__(self, sandbox_id: _Optional[str] = ..., kind: _Optional[_Union[SandboxEventKind, str]] = ...) -> None: ... + +class WatchEventsResponse(_message.Message): + __slots__ = ("event", "keep_alive") + EVENT_FIELD_NUMBER: _ClassVar[int] + KEEP_ALIVE_FIELD_NUMBER: _ClassVar[int] + event: SandboxEvent + keep_alive: KeepAlive + def __init__(self, event: _Optional[_Union[SandboxEvent, _Mapping]] = ..., keep_alive: _Optional[_Union[KeepAlive, _Mapping]] = ...) -> None: ... + +class SandboxEvent(_message.Message): + __slots__ = ("sandbox_id", "kind", "time", "attributes") + class AttributesEntry(_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: ... + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + KIND_FIELD_NUMBER: _ClassVar[int] + TIME_FIELD_NUMBER: _ClassVar[int] + ATTRIBUTES_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + kind: SandboxEventKind + time: _timestamp_pb2.Timestamp + attributes: _containers.ScalarMap[str, str] + def __init__(self, sandbox_id: _Optional[str] = ..., kind: _Optional[_Union[SandboxEventKind, str]] = ..., time: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., attributes: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class ExposePortRequest(_message.Message): + __slots__ = ("id", "sandbox_port", "host_port", "protocol") + ID_FIELD_NUMBER: _ClassVar[int] + SANDBOX_PORT_FIELD_NUMBER: _ClassVar[int] + HOST_PORT_FIELD_NUMBER: _ClassVar[int] + PROTOCOL_FIELD_NUMBER: _ClassVar[int] + id: str + sandbox_port: int + host_port: int + protocol: PortProtocol + def __init__(self, id: _Optional[str] = ..., sandbox_port: _Optional[int] = ..., host_port: _Optional[int] = ..., protocol: _Optional[_Union[PortProtocol, str]] = ...) -> None: ... + +class ExposePortResponse(_message.Message): + __slots__ = ("host_port", "guest_port") + HOST_PORT_FIELD_NUMBER: _ClassVar[int] + GUEST_PORT_FIELD_NUMBER: _ClassVar[int] + host_port: int + guest_port: int + def __init__(self, host_port: _Optional[int] = ..., guest_port: _Optional[int] = ...) -> None: ... + +class UnexposePortRequest(_message.Message): + __slots__ = ("id", "sandbox_port", "protocol") + ID_FIELD_NUMBER: _ClassVar[int] + SANDBOX_PORT_FIELD_NUMBER: _ClassVar[int] + PROTOCOL_FIELD_NUMBER: _ClassVar[int] + id: str + sandbox_port: int + protocol: PortProtocol + def __init__(self, id: _Optional[str] = ..., sandbox_port: _Optional[int] = ..., protocol: _Optional[_Union[PortProtocol, str]] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/snapshot_pb2.py b/sdk/python/src/arcbox/_gen/snapshot_pb2.py new file mode 100644 index 000000000..3ee81b0dc --- /dev/null +++ b/sdk/python/src/arcbox/_gen/snapshot_pb2.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/snapshot.proto +# Protobuf Python Version: 7.35.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 runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/snapshot.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n arcbox/sandbox/v1/snapshot.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa6\x01\n\x11\x43heckpointRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12@\n\x06labels\x18\x03 \x03(\x0b\x32\x30.arcbox.sandbox.v1.CheckpointRequest.LabelsEntry\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"m\n\x12\x43heckpointResponse\x12\x13\n\x0bsnapshot_id\x18\x01 \x01(\t\x12.\n\ncreated_at\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.TimestampJ\x04\x08\x02\x10\x03R\x0csnapshot_dir\"\xce\x01\n\x0eRestoreRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x13\n\x0bsnapshot_id\x18\x02 \x01(\t\x12=\n\x06labels\x18\x03 \x03(\x0b\x32-.arcbox.sandbox.v1.RestoreRequest.LabelsEntry\x12\x18\n\x10network_override\x18\x04 \x01(\x08\x12\x13\n\x0bttl_seconds\x18\x05 \x01(\r\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"1\n\x0fRestoreResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nip_address\x18\x02 \x01(\t\"\xc5\x01\n\x14ListSnapshotsRequest\x12\x12\n\nsandbox_id\x18\x01 \x01(\t\x12\x43\n\x06labels\x18\x02 \x03(\x0b\x32\x33.arcbox.sandbox.v1.ListSnapshotsRequest.LabelsEntry\x12\x11\n\tpage_size\x18\x03 \x01(\r\x12\x12\n\npage_token\x18\x04 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"g\n\x15ListSnapshotsResponse\x12\x35\n\tsnapshots\x18\x01 \x03(\x0b\x32\".arcbox.sandbox.v1.SnapshotSummary\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\"\xf2\x01\n\x0fSnapshotSummary\x12\n\n\x02id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12>\n\x06labels\x18\x04 \x03(\x0b\x32..arcbox.sandbox.v1.SnapshotSummary.LabelsEntry\x12.\n\ncreated_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x05\x10\x06R\x0csnapshot_dir\",\n\x15\x44\x65leteSnapshotRequest\x12\x13\n\x0bsnapshot_id\x18\x01 \x01(\t2\xfd\x02\n\x16SandboxSnapshotService\x12Y\n\nCheckpoint\x12$.arcbox.sandbox.v1.CheckpointRequest\x1a%.arcbox.sandbox.v1.CheckpointResponse\x12P\n\x07Restore\x12!.arcbox.sandbox.v1.RestoreRequest\x1a\".arcbox.sandbox.v1.RestoreResponse\x12\x62\n\rListSnapshots\x12\'.arcbox.sandbox.v1.ListSnapshotsRequest\x1a(.arcbox.sandbox.v1.ListSnapshotsResponse\x12R\n\x0e\x44\x65leteSnapshot\x12(.arcbox.sandbox.v1.DeleteSnapshotRequest\x1a\x16.google.protobuf.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.snapshot_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_CHECKPOINTREQUEST_LABELSENTRY']._loaded_options = None + _globals['_CHECKPOINTREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_RESTOREREQUEST_LABELSENTRY']._loaded_options = None + _globals['_RESTOREREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_LISTSNAPSHOTSREQUEST_LABELSENTRY']._loaded_options = None + _globals['_LISTSNAPSHOTSREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_SNAPSHOTSUMMARY_LABELSENTRY']._loaded_options = None + _globals['_SNAPSHOTSUMMARY_LABELSENTRY']._serialized_options = b'8\001' + _globals['_CHECKPOINTREQUEST']._serialized_start=118 + _globals['_CHECKPOINTREQUEST']._serialized_end=284 + _globals['_CHECKPOINTREQUEST_LABELSENTRY']._serialized_start=239 + _globals['_CHECKPOINTREQUEST_LABELSENTRY']._serialized_end=284 + _globals['_CHECKPOINTRESPONSE']._serialized_start=286 + _globals['_CHECKPOINTRESPONSE']._serialized_end=395 + _globals['_RESTOREREQUEST']._serialized_start=398 + _globals['_RESTOREREQUEST']._serialized_end=604 + _globals['_RESTOREREQUEST_LABELSENTRY']._serialized_start=239 + _globals['_RESTOREREQUEST_LABELSENTRY']._serialized_end=284 + _globals['_RESTORERESPONSE']._serialized_start=606 + _globals['_RESTORERESPONSE']._serialized_end=655 + _globals['_LISTSNAPSHOTSREQUEST']._serialized_start=658 + _globals['_LISTSNAPSHOTSREQUEST']._serialized_end=855 + _globals['_LISTSNAPSHOTSREQUEST_LABELSENTRY']._serialized_start=239 + _globals['_LISTSNAPSHOTSREQUEST_LABELSENTRY']._serialized_end=284 + _globals['_LISTSNAPSHOTSRESPONSE']._serialized_start=857 + _globals['_LISTSNAPSHOTSRESPONSE']._serialized_end=960 + _globals['_SNAPSHOTSUMMARY']._serialized_start=963 + _globals['_SNAPSHOTSUMMARY']._serialized_end=1205 + _globals['_SNAPSHOTSUMMARY_LABELSENTRY']._serialized_start=239 + _globals['_SNAPSHOTSUMMARY_LABELSENTRY']._serialized_end=284 + _globals['_DELETESNAPSHOTREQUEST']._serialized_start=1207 + _globals['_DELETESNAPSHOTREQUEST']._serialized_end=1251 + _globals['_SANDBOXSNAPSHOTSERVICE']._serialized_start=1254 + _globals['_SANDBOXSNAPSHOTSERVICE']._serialized_end=1635 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/snapshot_pb2.pyi b/sdk/python/src/arcbox/_gen/snapshot_pb2.pyi new file mode 100644 index 000000000..d1545e5bc --- /dev/null +++ b/sdk/python/src/arcbox/_gen/snapshot_pb2.pyi @@ -0,0 +1,119 @@ +import datetime + +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class CheckpointRequest(_message.Message): + __slots__ = ("sandbox_id", "name", "labels") + class LabelsEntry(_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: ... + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + name: str + labels: _containers.ScalarMap[str, str] + def __init__(self, sandbox_id: _Optional[str] = ..., name: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class CheckpointResponse(_message.Message): + __slots__ = ("snapshot_id", "created_at") + SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + snapshot_id: str + created_at: _timestamp_pb2.Timestamp + def __init__(self, snapshot_id: _Optional[str] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... + +class RestoreRequest(_message.Message): + __slots__ = ("id", "snapshot_id", "labels", "network_override", "ttl_seconds") + class LabelsEntry(_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: ... + ID_FIELD_NUMBER: _ClassVar[int] + SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + NETWORK_OVERRIDE_FIELD_NUMBER: _ClassVar[int] + TTL_SECONDS_FIELD_NUMBER: _ClassVar[int] + id: str + snapshot_id: str + labels: _containers.ScalarMap[str, str] + network_override: bool + ttl_seconds: int + def __init__(self, id: _Optional[str] = ..., snapshot_id: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ..., network_override: _Optional[bool] = ..., ttl_seconds: _Optional[int] = ...) -> None: ... + +class RestoreResponse(_message.Message): + __slots__ = ("id", "ip_address") + ID_FIELD_NUMBER: _ClassVar[int] + IP_ADDRESS_FIELD_NUMBER: _ClassVar[int] + id: str + ip_address: str + def __init__(self, id: _Optional[str] = ..., ip_address: _Optional[str] = ...) -> None: ... + +class ListSnapshotsRequest(_message.Message): + __slots__ = ("sandbox_id", "labels", "page_size", "page_token") + class LabelsEntry(_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: ... + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + PAGE_SIZE_FIELD_NUMBER: _ClassVar[int] + PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + labels: _containers.ScalarMap[str, str] + page_size: int + page_token: str + def __init__(self, sandbox_id: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ..., page_size: _Optional[int] = ..., page_token: _Optional[str] = ...) -> None: ... + +class ListSnapshotsResponse(_message.Message): + __slots__ = ("snapshots", "next_page_token") + SNAPSHOTS_FIELD_NUMBER: _ClassVar[int] + NEXT_PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + snapshots: _containers.RepeatedCompositeFieldContainer[SnapshotSummary] + next_page_token: str + def __init__(self, snapshots: _Optional[_Iterable[_Union[SnapshotSummary, _Mapping]]] = ..., next_page_token: _Optional[str] = ...) -> None: ... + +class SnapshotSummary(_message.Message): + __slots__ = ("id", "sandbox_id", "name", "labels", "created_at") + class LabelsEntry(_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: ... + ID_FIELD_NUMBER: _ClassVar[int] + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + id: str + sandbox_id: str + name: str + labels: _containers.ScalarMap[str, str] + created_at: _timestamp_pb2.Timestamp + def __init__(self, id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., name: _Optional[str] = ..., labels: _Optional[_Mapping[str, str]] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... + +class DeleteSnapshotRequest(_message.Message): + __slots__ = ("snapshot_id",) + SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + snapshot_id: str + def __init__(self, snapshot_id: _Optional[str] = ...) -> None: ... diff --git a/sdk/python/src/arcbox/_gen/template_pb2.py b/sdk/python/src/arcbox/_gen/template_pb2.py new file mode 100644 index 000000000..7dc0a8643 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/template_pb2.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: arcbox/sandbox/v1/template.proto +# Protobuf Python Version: 7.35.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 runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 1, + '', + 'arcbox/sandbox/v1/template.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import sandbox_pb2 as arcbox_dot_sandbox_dot_v1_dot_sandbox__pb2 +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n arcbox/sandbox/v1/template.proto\x12\x11\x61rcbox.sandbox.v1\x1a\x1f\x61rcbox/sandbox/v1/sandbox.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xca\x02\n\x08Template\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0e\n\x06\x64igest\x18\x03 \x01(\t\x12\x12\n\nrootfs_ref\x18\x04 \x01(\t\x12\x18\n\x10warm_snapshot_id\x18\x05 \x01(\t\x12\x35\n\x08\x64\x65\x66\x61ults\x18\x06 \x01(\x0b\x32#.arcbox.sandbox.v1.TemplateDefaults\x12.\n\ncreated_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x06labels\x18\x08 \x03(\x0b\x32\'.arcbox.sandbox.v1.Template.LabelsEntry\x12\x12\n\nsize_bytes\x18\t \x01(\x04\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x84\x02\n\x10TemplateDefaults\x12\x31\n\x06limits\x18\x01 \x01(\x0b\x32!.arcbox.sandbox.v1.ResourceLimits\x12\x0b\n\x03\x63md\x18\x02 \x03(\t\x12\x39\n\x03\x65nv\x18\x03 \x03(\x0b\x32,.arcbox.sandbox.v1.TemplateDefaults.EnvEntry\x12\x15\n\rexposed_ports\x18\x04 \x03(\r\x12\x32\n\x0bready_probe\x18\x05 \x01(\x0b\x32\x1d.arcbox.sandbox.v1.ReadyProbe\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"r\n\nReadyProbe\x12\x0e\n\x04port\x18\x01 \x01(\rH\x00\x12\x32\n\x07\x63ommand\x18\x02 \x01(\x0b\x32\x1f.arcbox.sandbox.v1.CommandProbeH\x00\x12\x17\n\x0ftimeout_seconds\x18\x03 \x01(\rB\x07\n\x05probe\"\x1b\n\x0c\x43ommandProbe\x12\x0b\n\x03\x63md\x18\x01 \x03(\t\"\xad\x02\n\x14\x42uildTemplateRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\ndocker_ref\x18\x02 \x01(\tH\x00\x12\x14\n\ndockerfile\x18\x03 \x01(\tH\x00\x12\x15\n\x0bsnapshot_id\x18\x04 \x01(\tH\x00\x12\x35\n\x08\x64\x65\x66\x61ults\x18\x05 \x01(\x0b\x32#.arcbox.sandbox.v1.TemplateDefaults\x12\x43\n\x06labels\x18\x06 \x03(\x0b\x32\x33.arcbox.sandbox.v1.BuildTemplateRequest.LabelsEntry\x12\x0f\n\x07prewarm\x18\x07 \x01(\x08\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x08\n\x06source\"7\n\x16PublishTemplateRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\"\'\n\x12GetTemplateRequest\x12\x11\n\treference\x18\x01 \x01(\t\"\xb1\x01\n\x14ListTemplatesRequest\x12\x43\n\x06labels\x18\x01 \x03(\x0b\x32\x33.arcbox.sandbox.v1.ListTemplatesRequest.LabelsEntry\x12\x11\n\tpage_size\x18\x02 \x01(\r\x12\x12\n\npage_token\x18\x03 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"`\n\x15ListTemplatesResponse\x12.\n\ttemplates\x18\x01 \x03(\x0b\x32\x1b.arcbox.sandbox.v1.Template\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\"*\n\x15\x44\x65leteTemplateRequest\x12\x11\n\treference\x18\x01 \x01(\t2\xa5\x03\n\x0fTemplateService\x12M\n\x05\x42uild\x12\'.arcbox.sandbox.v1.BuildTemplateRequest\x1a\x1b.arcbox.sandbox.v1.Template\x12Q\n\x07Publish\x12).arcbox.sandbox.v1.PublishTemplateRequest\x1a\x1b.arcbox.sandbox.v1.Template\x12I\n\x03Get\x12%.arcbox.sandbox.v1.GetTemplateRequest\x1a\x1b.arcbox.sandbox.v1.Template\x12Y\n\x04List\x12\'.arcbox.sandbox.v1.ListTemplatesRequest\x1a(.arcbox.sandbox.v1.ListTemplatesResponse\x12J\n\x06\x44\x65lete\x12(.arcbox.sandbox.v1.DeleteTemplateRequest\x1a\x16.google.protobuf.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'arcbox.sandbox.v1.template_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_TEMPLATE_LABELSENTRY']._loaded_options = None + _globals['_TEMPLATE_LABELSENTRY']._serialized_options = b'8\001' + _globals['_TEMPLATEDEFAULTS_ENVENTRY']._loaded_options = None + _globals['_TEMPLATEDEFAULTS_ENVENTRY']._serialized_options = b'8\001' + _globals['_BUILDTEMPLATEREQUEST_LABELSENTRY']._loaded_options = None + _globals['_BUILDTEMPLATEREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_LISTTEMPLATESREQUEST_LABELSENTRY']._loaded_options = None + _globals['_LISTTEMPLATESREQUEST_LABELSENTRY']._serialized_options = b'8\001' + _globals['_TEMPLATE']._serialized_start=151 + _globals['_TEMPLATE']._serialized_end=481 + _globals['_TEMPLATE_LABELSENTRY']._serialized_start=436 + _globals['_TEMPLATE_LABELSENTRY']._serialized_end=481 + _globals['_TEMPLATEDEFAULTS']._serialized_start=484 + _globals['_TEMPLATEDEFAULTS']._serialized_end=744 + _globals['_TEMPLATEDEFAULTS_ENVENTRY']._serialized_start=702 + _globals['_TEMPLATEDEFAULTS_ENVENTRY']._serialized_end=744 + _globals['_READYPROBE']._serialized_start=746 + _globals['_READYPROBE']._serialized_end=860 + _globals['_COMMANDPROBE']._serialized_start=862 + _globals['_COMMANDPROBE']._serialized_end=889 + _globals['_BUILDTEMPLATEREQUEST']._serialized_start=892 + _globals['_BUILDTEMPLATEREQUEST']._serialized_end=1193 + _globals['_BUILDTEMPLATEREQUEST_LABELSENTRY']._serialized_start=436 + _globals['_BUILDTEMPLATEREQUEST_LABELSENTRY']._serialized_end=481 + _globals['_PUBLISHTEMPLATEREQUEST']._serialized_start=1195 + _globals['_PUBLISHTEMPLATEREQUEST']._serialized_end=1250 + _globals['_GETTEMPLATEREQUEST']._serialized_start=1252 + _globals['_GETTEMPLATEREQUEST']._serialized_end=1291 + _globals['_LISTTEMPLATESREQUEST']._serialized_start=1294 + _globals['_LISTTEMPLATESREQUEST']._serialized_end=1471 + _globals['_LISTTEMPLATESREQUEST_LABELSENTRY']._serialized_start=436 + _globals['_LISTTEMPLATESREQUEST_LABELSENTRY']._serialized_end=481 + _globals['_LISTTEMPLATESRESPONSE']._serialized_start=1473 + _globals['_LISTTEMPLATESRESPONSE']._serialized_end=1569 + _globals['_DELETETEMPLATEREQUEST']._serialized_start=1571 + _globals['_DELETETEMPLATEREQUEST']._serialized_end=1613 + _globals['_TEMPLATESERVICE']._serialized_start=1616 + _globals['_TEMPLATESERVICE']._serialized_end=2037 +# @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/arcbox/_gen/template_pb2.pyi b/sdk/python/src/arcbox/_gen/template_pb2.pyi new file mode 100644 index 000000000..1577a20d1 --- /dev/null +++ b/sdk/python/src/arcbox/_gen/template_pb2.pyi @@ -0,0 +1,148 @@ +import datetime + +from . import sandbox_pb2 as _sandbox_pb2 +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Template(_message.Message): + __slots__ = ("name", "version", "digest", "rootfs_ref", "warm_snapshot_id", "defaults", "created_at", "labels", "size_bytes") + class LabelsEntry(_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: ... + NAME_FIELD_NUMBER: _ClassVar[int] + VERSION_FIELD_NUMBER: _ClassVar[int] + DIGEST_FIELD_NUMBER: _ClassVar[int] + ROOTFS_REF_FIELD_NUMBER: _ClassVar[int] + WARM_SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + DEFAULTS_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + SIZE_BYTES_FIELD_NUMBER: _ClassVar[int] + name: str + version: str + digest: str + rootfs_ref: str + warm_snapshot_id: str + defaults: TemplateDefaults + created_at: _timestamp_pb2.Timestamp + labels: _containers.ScalarMap[str, str] + size_bytes: int + def __init__(self, name: _Optional[str] = ..., version: _Optional[str] = ..., digest: _Optional[str] = ..., rootfs_ref: _Optional[str] = ..., warm_snapshot_id: _Optional[str] = ..., defaults: _Optional[_Union[TemplateDefaults, _Mapping]] = ..., created_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., labels: _Optional[_Mapping[str, str]] = ..., size_bytes: _Optional[int] = ...) -> None: ... + +class TemplateDefaults(_message.Message): + __slots__ = ("limits", "cmd", "env", "exposed_ports", "ready_probe") + class EnvEntry(_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: ... + LIMITS_FIELD_NUMBER: _ClassVar[int] + CMD_FIELD_NUMBER: _ClassVar[int] + ENV_FIELD_NUMBER: _ClassVar[int] + EXPOSED_PORTS_FIELD_NUMBER: _ClassVar[int] + READY_PROBE_FIELD_NUMBER: _ClassVar[int] + limits: _sandbox_pb2.ResourceLimits + cmd: _containers.RepeatedScalarFieldContainer[str] + env: _containers.ScalarMap[str, str] + exposed_ports: _containers.RepeatedScalarFieldContainer[int] + ready_probe: ReadyProbe + def __init__(self, limits: _Optional[_Union[_sandbox_pb2.ResourceLimits, _Mapping]] = ..., cmd: _Optional[_Iterable[str]] = ..., env: _Optional[_Mapping[str, str]] = ..., exposed_ports: _Optional[_Iterable[int]] = ..., ready_probe: _Optional[_Union[ReadyProbe, _Mapping]] = ...) -> None: ... + +class ReadyProbe(_message.Message): + __slots__ = ("port", "command", "timeout_seconds") + PORT_FIELD_NUMBER: _ClassVar[int] + COMMAND_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + port: int + command: CommandProbe + timeout_seconds: int + def __init__(self, port: _Optional[int] = ..., command: _Optional[_Union[CommandProbe, _Mapping]] = ..., timeout_seconds: _Optional[int] = ...) -> None: ... + +class CommandProbe(_message.Message): + __slots__ = ("cmd",) + CMD_FIELD_NUMBER: _ClassVar[int] + cmd: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, cmd: _Optional[_Iterable[str]] = ...) -> None: ... + +class BuildTemplateRequest(_message.Message): + __slots__ = ("name", "docker_ref", "dockerfile", "snapshot_id", "defaults", "labels", "prewarm") + class LabelsEntry(_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: ... + NAME_FIELD_NUMBER: _ClassVar[int] + DOCKER_REF_FIELD_NUMBER: _ClassVar[int] + DOCKERFILE_FIELD_NUMBER: _ClassVar[int] + SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + DEFAULTS_FIELD_NUMBER: _ClassVar[int] + LABELS_FIELD_NUMBER: _ClassVar[int] + PREWARM_FIELD_NUMBER: _ClassVar[int] + name: str + docker_ref: str + dockerfile: str + snapshot_id: str + defaults: TemplateDefaults + labels: _containers.ScalarMap[str, str] + prewarm: bool + def __init__(self, name: _Optional[str] = ..., docker_ref: _Optional[str] = ..., dockerfile: _Optional[str] = ..., snapshot_id: _Optional[str] = ..., defaults: _Optional[_Union[TemplateDefaults, _Mapping]] = ..., labels: _Optional[_Mapping[str, str]] = ..., prewarm: _Optional[bool] = ...) -> None: ... + +class PublishTemplateRequest(_message.Message): + __slots__ = ("name", "version") + NAME_FIELD_NUMBER: _ClassVar[int] + VERSION_FIELD_NUMBER: _ClassVar[int] + name: str + version: str + def __init__(self, name: _Optional[str] = ..., version: _Optional[str] = ...) -> None: ... + +class GetTemplateRequest(_message.Message): + __slots__ = ("reference",) + REFERENCE_FIELD_NUMBER: _ClassVar[int] + reference: str + def __init__(self, reference: _Optional[str] = ...) -> None: ... + +class ListTemplatesRequest(_message.Message): + __slots__ = ("labels", "page_size", "page_token") + class LabelsEntry(_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: ... + LABELS_FIELD_NUMBER: _ClassVar[int] + PAGE_SIZE_FIELD_NUMBER: _ClassVar[int] + PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + labels: _containers.ScalarMap[str, str] + page_size: int + page_token: str + def __init__(self, labels: _Optional[_Mapping[str, str]] = ..., page_size: _Optional[int] = ..., page_token: _Optional[str] = ...) -> None: ... + +class ListTemplatesResponse(_message.Message): + __slots__ = ("templates", "next_page_token") + TEMPLATES_FIELD_NUMBER: _ClassVar[int] + NEXT_PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + templates: _containers.RepeatedCompositeFieldContainer[Template] + next_page_token: str + def __init__(self, templates: _Optional[_Iterable[_Union[Template, _Mapping]]] = ..., next_page_token: _Optional[str] = ...) -> None: ... + +class DeleteTemplateRequest(_message.Message): + __slots__ = ("reference",) + REFERENCE_FIELD_NUMBER: _ClassVar[int] + reference: str + def __init__(self, reference: _Optional[str] = ...) -> None: ... From a89fa962ecef4afbb6537ee54bbb51aef66901b0 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:09:28 +0800 Subject: [PATCH 03/20] feat(sdk): typed errors, connection resolution, Connect framing errors.py mirrors the TS hierarchy exactly and derives the registry mapping from the generated ErrorCode enum; unknown codes stay on the base class with the raw code preserved. _connection.py resolves option > env > default (ARCBOX_SOCKET / ARCBOX_API_URL / ARCBOX_API_KEY / ARCBOX_DATA_DIR / ARCBOX_PROFILE) like the TS SDK and paths.rs. _envelope.py owns the Connect streaming envelope (1 flag byte + u32 length) and the JSON error bodies, decoded with typed msgspec Structs at the single untrusted-input boundary. --- sdk/python/pyproject.toml | 1 + sdk/python/src/arcbox/_connection.py | 115 ++++++++++++ sdk/python/src/arcbox/_envelope.py | 150 ++++++++++++++++ sdk/python/src/arcbox/errors.py | 253 +++++++++++++++++++++++++++ 4 files changed, 519 insertions(+) create mode 100644 sdk/python/src/arcbox/_connection.py create mode 100644 sdk/python/src/arcbox/_envelope.py create mode 100644 sdk/python/src/arcbox/errors.py diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 3b6523d78..64c6a0802 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -21,6 +21,7 @@ build-backend = "uv_build" [tool.ruff] target-version = "py310" +line-length = 100 extend-exclude = ["src/arcbox/_gen"] [tool.ruff.lint] diff --git a/sdk/python/src/arcbox/_connection.py b/sdk/python/src/arcbox/_connection.py new file mode 100644 index 000000000..d796aa210 --- /dev/null +++ b/sdk/python/src/arcbox/_connection.py @@ -0,0 +1,115 @@ +"""Connection configuration for reaching an ArcBox daemon. + +Resolution order for every field: explicit option > environment > default. +The default tier is the local daemon's Unix socket; setting an API URL +(option or ``ARCBOX_API_URL``) selects the remote tier instead (CORE-63, +reserved — no cloud front door exists yet). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from arcbox.errors import InvalidArgumentError + +if TYPE_CHECKING: + from collections.abc import Mapping + + import httpx + +#: Placeholder authority for Unix-socket requests. httpx dials the +#: transport's ``uds`` path and uses the URL only for the Host header +#: and request path, so any stable name works here. +UDS_BASE_URL = "http://arcbox" + + +@dataclass(frozen=True) +class Connection: + """Connection options accepted by every SDK entry point.""" + + #: Unix socket path of the local daemon (env: ``ARCBOX_SOCKET``). + socket_path: str | None = None + #: Base URL of a remote daemon / cloud front door (env: + #: ``ARCBOX_API_URL``). Reserved (CORE-63). + api_url: str | None = None + #: Bearer credential attached as an ``Authorization`` header when set + #: (env: ``ARCBOX_API_KEY``). Unused by the local daemon, which + #: trusts socket file permissions instead. + api_key: str | None = None + #: Per-RPC deadline in seconds for unary calls. Streams are exempt. + request_timeout: float | None = None + #: Injected httpx client, replacing socket/URL resolution entirely — + #: the mock/testing seam. The async surface requires an + #: ``httpx.AsyncClient``; the sync surface an ``httpx.Client``. + http_client: httpx.AsyncClient | httpx.Client | None = None + + +@dataclass(frozen=True) +class ResolvedConnection: + """A fully resolved connection target.""" + + #: Base URL handed to the transport. On the Unix-socket tier this is + #: the :data:`UDS_BASE_URL` placeholder. + base_url: str + #: Unix socket to dial; ``None`` on the remote (TCP) tier. + socket_path: str | None + #: Bearer credential to attach, when set. + api_key: str | None + #: Per-unary-RPC deadline in seconds, when set. + request_timeout: float | None + + +def _profile_data_dir_name(profile: str | None) -> str: + """``ARCBOX_PROFILE`` -> default data dir name. + + Parsing mirrors the daemon's ``ArcboxProfile::from_str`` (trimmed, + case-insensitive, ``development``/``dev``) with unknown values + falling back to production, exactly like ``from_env_or_default``. + """ + parsed = (profile or "").strip().lower() + return ".arcbox-dev" if parsed in ("development", "dev") else ".arcbox" + + +def _default_socket_path(env: Mapping[str, str]) -> str: + """Default socket location relative to the daemon data dir. + + Mirrors arcbox-constants paths.rs (``HostLayout::resolve_for_profile_from_env``): + ``/run/arcbox.sock``, data dir from a non-empty + ``ARCBOX_DATA_DIR``, else the ``ARCBOX_PROFILE`` default — + ``~/.arcbox``, or ``~/.arcbox-dev`` for the development profile. + """ + data_dir = env.get("ARCBOX_DATA_DIR") or str( + Path.home() / _profile_data_dir_name(env.get("ARCBOX_PROFILE")) + ) + return str(Path(data_dir) / "run" / "arcbox.sock") + + +def resolve_connection(options: Connection | None, env: Mapping[str, str]) -> ResolvedConnection: + """Resolve connection options against the environment. + + Tier selection: an explicit ``socket_path`` and an explicit + ``api_url`` are contradictory and rejected. At the environment level + ``ARCBOX_API_URL`` wins over ``ARCBOX_SOCKET`` — setting it selects + the remote tier. + """ + options = options or Connection() + if options.socket_path is not None and options.api_url is not None: + raise InvalidArgumentError( + "Connection.socket_path and Connection.api_url are mutually " + "exclusive: a connection dials either the local Unix socket " + "or a remote URL" + ) + + api_key = options.api_key if options.api_key is not None else env.get("ARCBOX_API_KEY") + request_timeout = options.request_timeout + + if options.socket_path is not None: + return ResolvedConnection(UDS_BASE_URL, options.socket_path, api_key, request_timeout) + if options.api_url is not None: + return ResolvedConnection(options.api_url, None, api_key, request_timeout) + if env.get("ARCBOX_API_URL"): + return ResolvedConnection(env["ARCBOX_API_URL"], None, api_key, request_timeout) + socket_path = env.get("ARCBOX_SOCKET") or _default_socket_path(env) + return ResolvedConnection(UDS_BASE_URL, socket_path, api_key, request_timeout) diff --git a/sdk/python/src/arcbox/_envelope.py b/sdk/python/src/arcbox/_envelope.py new file mode 100644 index 000000000..975622e0d --- /dev/null +++ b/sdk/python/src/arcbox/_envelope.py @@ -0,0 +1,150 @@ +"""Connect protocol framing and error-body shapes. + +The Connect protocol (https://connectrpc.com/docs/protocol) frames +streaming payloads in envelopes — 1 flags byte + 4-byte big-endian +length + payload — and reports errors as JSON: unary errors as the +non-200 response body, streaming errors inside the terminal +EndStreamResponse envelope. This module owns both encodings; it is pure +and shared by the async and sync trees. + +Error bodies are decoded with msgspec Structs — typed validation at the +single untrusted-input boundary instead of hand-walked dicts. +""" + +from __future__ import annotations + +import base64 +import struct + +import msgspec +from google.protobuf.message import DecodeError + +from arcbox._gen import errors_pb2 +from arcbox.errors import ArcBoxError, error_from_wire + +#: Envelope flag bits (Connect streaming RPCs). +FLAG_COMPRESSED = 0b01 +FLAG_END_STREAM = 0b10 + +#: Ceiling on a single received envelope. The daemon streams file bytes +#: in small chunks and the per-file cap is 256 MiB, so anything past +#: this is a corrupt frame, not data. +MAX_ENVELOPE_BYTES = 32 * 1024 * 1024 + +_HEADER = struct.Struct(">BI") + +#: Fully-qualified type of the daemon's error detail (`errors.proto`). +_ERROR_INFO_TYPE = "arcbox.sandbox.v1.ErrorInfo" + +# HTTP status -> Connect code, used only when a unary error body is not +# valid Connect error JSON (the spec's fallback table). +_HTTP_FALLBACK_CODES = { + 400: "internal", + 401: "unauthenticated", + 403: "permission_denied", + 404: "unimplemented", + 429: "unavailable", + 502: "unavailable", + 503: "unavailable", + 504: "unavailable", +} + + +def encode_envelope(flags: int, payload: bytes) -> bytes: + """Encode one Connect streaming envelope.""" + return _HEADER.pack(flags, len(payload)) + payload + + +class EnvelopeDecoder: + """Incremental envelope decoder: feed bytes, collect (flags, payload) frames.""" + + def __init__(self) -> None: + self._buffer = bytearray() + + def feed(self, data: bytes) -> list[tuple[int, bytes]]: + self._buffer += data + frames: list[tuple[int, bytes]] = [] + while len(self._buffer) >= _HEADER.size: + flags, length = _HEADER.unpack_from(self._buffer) + if length > MAX_ENVELOPE_BYTES: + raise ArcBoxError( + f"stream envelope of {length} bytes exceeds the " + f"{MAX_ENVELOPE_BYTES}-byte sanity cap" + ) + end = _HEADER.size + length + if len(self._buffer) < end: + break + frames.append((flags, bytes(self._buffer[_HEADER.size : end]))) + del self._buffer[:end] + return frames + + +class ConnectErrorDetail(msgspec.Struct): + """One error detail: a type name plus base64-encoded protobuf bytes.""" + + type: str = "" + value: str = "" + + +class ConnectWireError(msgspec.Struct): + """The JSON `error` object of the Connect protocol.""" + + code: str = "unknown" + message: str = "" + details: list[ConnectErrorDetail] = msgspec.field(default_factory=list[ConnectErrorDetail]) + + +class EndStreamPayload(msgspec.Struct): + """The terminal EndStreamResponse envelope of a streaming RPC.""" + + error: ConnectWireError | None = None + + +_WIRE_ERROR_DECODER = msgspec.json.Decoder(ConnectWireError) +_END_STREAM_DECODER = msgspec.json.Decoder(EndStreamPayload) + + +def _error_info(wire: ConnectWireError) -> errors_pb2.ErrorInfo | None: + """Extract and decode the daemon's `ErrorInfo` detail, if attached.""" + for detail in wire.details: + if detail.type != _ERROR_INFO_TYPE: + continue + # The spec allows both padded and unpadded base64. + padded = detail.value + "=" * (-len(detail.value) % 4) + try: + raw = base64.b64decode(padded) + return errors_pb2.ErrorInfo.FromString(raw) + except (ValueError, DecodeError): + # Already on the error path: a malformed optional detail + # degrades to the coarse Connect code instead of masking the + # daemon's error with a decode exception. + return None + return None + + +def wire_error_to_exception(wire: ConnectWireError, operation: str | None = None) -> ArcBoxError: + """Map a decoded Connect error to the typed hierarchy.""" + return error_from_wire(wire.code, wire.message, _error_info(wire), operation) + + +def unary_error(status_code: int, body: bytes, operation: str | None = None) -> ArcBoxError: + """Map a non-200 unary response to the typed hierarchy.""" + try: + wire = _WIRE_ERROR_DECODER.decode(body) + except msgspec.DecodeError: + wire = ConnectWireError( + code=_HTTP_FALLBACK_CODES.get(status_code, "unknown"), + message=f"HTTP {status_code}", + ) + return wire_error_to_exception(wire, operation) + + +def end_stream_error(payload: bytes, operation: str | None = None) -> ArcBoxError | None: + """Decode an EndStreamResponse; return its error, or None on success.""" + try: + end = _END_STREAM_DECODER.decode(payload) + except msgspec.DecodeError as exc: + return ArcBoxError(f"malformed EndStreamResponse: {exc}", operation=operation) + if end.error is None: + return None + return wire_error_to_exception(end.error, operation) diff --git a/sdk/python/src/arcbox/errors.py b/sdk/python/src/arcbox/errors.py new file mode 100644 index 000000000..c7852ff58 --- /dev/null +++ b/sdk/python/src/arcbox/errors.py @@ -0,0 +1,253 @@ +"""Typed error hierarchy for the ArcBox Sandbox SDK. + +Every error raised by this SDK derives from :class:`ArcBoxError` and +carries the machine-readable ``code`` from the daemon's error registry +(``arcbox.sandbox.v1.ErrorCode``) when one was attached, an actionable +``suggestion``, structured ``context``, and the failed ``operation``. + +The registry-driven mapping lives at the single transport boundary +(:func:`error_from_wire`): call sites never inspect Connect errors +themselves. + +Two class names deliberately shadow builtins (``FileNotFoundError``, +``TimeoutError``) — the hierarchy is shared verbatim with the TypeScript +SDK, and both subclass :class:`ArcBoxError`, not ``OSError``. Import +them with an alias when the builtin is also in scope. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from arcbox._gen import errors_pb2 + +if TYPE_CHECKING: + from collections.abc import Mapping + +__all__ = [ + "ArcBoxError", + "AuthenticationError", + "CapabilityError", + "CommandFailedError", + "CommandFailure", + "CommandNotFoundError", + "CommandTimeoutError", + "ConnectionFailedError", + "FileNotFoundError", + "FileTooLargeError", + "InvalidArgumentError", + "NotFoundError", + "ProtocolMismatchError", + "RequestTimeoutError", + "SandboxDiedError", + "SandboxNotFoundError", + "SandboxStateError", + "SandboxTtlError", + "TemplateNotFoundError", + "TimeoutError", +] + +DAEMON_START_SUGGESTION = "run `abctl daemon start` (or launch the ArcBox app)" + + +class ArcBoxError(Exception): + """Base class of every error raised by this SDK.""" + + def __init__( + self, + message: str, + *, + code: str | None = None, + suggestion: str | None = None, + context: Mapping[str, str] | None = None, + operation: str | None = None, + ) -> None: + super().__init__(message) + #: Machine-readable cause from the daemon's error registry + #: (`errors.proto`), or the coarse Connect code when no + #: `ErrorInfo` detail rode along. + self.code = code + #: Actionable fix, phrased for direct display + #: ("run `abctl daemon start`"). + self.suggestion = suggestion + #: Structured facts about the failure (which limit, which state, ...). + self.context: dict[str, str] = dict(context) if context else {} + #: The SDK operation that failed (e.g. "commands.run"); stamped by + #: the call site that surfaced the error. + self.operation = operation + + +class InvalidArgumentError(ArcBoxError): + """A request was rejected at the boundary: unknown, contradictory, or malformed input.""" + + +class ConnectionFailedError(ArcBoxError): + """The daemon is unreachable (socket missing, connection refused, ...).""" + + +class AuthenticationError(ArcBoxError): + """Authentication is required or was rejected. Reserved for the remote tier (CORE-63).""" + + +class ProtocolMismatchError(ArcBoxError): + """SDK and daemon protocol levels are incompatible.""" + + +class NotFoundError(ArcBoxError): + """The addressed resource does not exist.""" + + +class SandboxNotFoundError(NotFoundError): + """The addressed sandbox does not exist.""" + + +class TemplateNotFoundError(NotFoundError): + """The addressed template does not exist.""" + + +class CommandNotFoundError(NotFoundError): + """The addressed command (execution) does not exist.""" + + +class FileNotFoundError(NotFoundError): + """The addressed path does not exist inside the sandbox.""" + + +class CapabilityError(ArcBoxError): + """This host cannot run sandboxes (e.g. no nested virtualization; CORE-13).""" + + +class SandboxStateError(ArcBoxError): + """The sandbox is in a state that does not permit the operation.""" + + +class SandboxDiedError(SandboxStateError): + """The sandbox died out from under the operation; context carries the terminal state.""" + + +class TimeoutError(ArcBoxError): + """A timeout fired. Subclasses name exactly which knob.""" + + +class SandboxTtlError(TimeoutError): + """The sandbox's hard maximum lifetime (`ttl`) expired and the daemon destroyed it.""" + + +class CommandTimeoutError(TimeoutError): + """A per-command timeout (`run(timeout=...)`) fired and the process group was killed.""" + + +class RequestTimeoutError(TimeoutError): + """A per-RPC deadline (`Connection.request_timeout`) fired.""" + + +class FileTooLargeError(ArcBoxError): + """A file transfer exceeded the per-file size cap; context carries the limit.""" + + +class CommandFailure(Protocol): + """Shape of a finished command, as carried by :class:`CommandFailedError`.""" + + @property + def exit_code(self) -> int: ... + @property + def signal(self) -> str | None: ... + @property + def stdout(self) -> str: ... + @property + def stderr(self) -> str: ... + + +class CommandFailedError(ArcBoxError): + """Raised ONLY by ``CommandResult.expect()`` / ``run(check=True)``. + + Non-zero exit is data everywhere else. Carries the full result. + """ + + def __init__(self, result: CommandFailure, *, operation: str | None = None) -> None: + what = ( + f"exit code {result.exit_code}" if result.signal is None else f"signal {result.signal}" + ) + super().__init__(f"command failed with {what}", operation=operation) + self.result = result + + +_ERROR_CODE_PREFIX = "ERROR_CODE_" + +# The registry-driven mapping (`errors.proto` -> class), applied when the +# daemon attached `ErrorInfo`. STDIN_CLOSED and RESOURCE_EXHAUSTED_HOST +# stay on the base class: the preserved `code` string is their precise +# identity. +_REGISTRY_CLASSES: dict[int, type[ArcBoxError]] = { + errors_pb2.ERROR_CODE_SANDBOX_NOT_FOUND: SandboxNotFoundError, + errors_pb2.ERROR_CODE_TEMPLATE_NOT_FOUND: TemplateNotFoundError, + errors_pb2.ERROR_CODE_EXECUTION_NOT_FOUND: CommandNotFoundError, + errors_pb2.ERROR_CODE_FILE_NOT_FOUND: FileNotFoundError, + errors_pb2.ERROR_CODE_SANDBOX_PAUSED: SandboxStateError, + errors_pb2.ERROR_CODE_SANDBOX_NOT_READY: SandboxStateError, + errors_pb2.ERROR_CODE_SANDBOX_FAILED: SandboxStateError, + errors_pb2.ERROR_CODE_TTL_EXPIRED: SandboxTtlError, + errors_pb2.ERROR_CODE_COMMAND_TIMEOUT: CommandTimeoutError, + errors_pb2.ERROR_CODE_NESTED_VIRT_UNSUPPORTED: CapabilityError, + errors_pb2.ERROR_CODE_TEMPLATE_INVALID: InvalidArgumentError, + errors_pb2.ERROR_CODE_FILE_TOO_LARGE: FileTooLargeError, + errors_pb2.ERROR_CODE_TTY_REQUIRED: InvalidArgumentError, + errors_pb2.ERROR_CODE_PORT_IN_USE: InvalidArgumentError, + errors_pb2.ERROR_CODE_AUTH_REQUIRED: AuthenticationError, + errors_pb2.ERROR_CODE_PROTOCOL_MISMATCH: ProtocolMismatchError, +} + +# Fallback routing on the coarse Connect code (the wire's snake_case +# string) when no `ErrorInfo` detail rode along. +_CONNECT_CODE_CLASSES: dict[str, type[ArcBoxError]] = { + "not_found": NotFoundError, + "invalid_argument": InvalidArgumentError, + "failed_precondition": SandboxStateError, + "deadline_exceeded": RequestTimeoutError, + "unavailable": ConnectionFailedError, + "unauthenticated": AuthenticationError, +} + +_CONNECT_CODE_SUGGESTIONS: dict[str, str] = { + "deadline_exceeded": "increase Connection.request_timeout", + "unavailable": DAEMON_START_SUGGESTION, +} + + +def error_code_name(code: int) -> str: + """`ErrorCode` numeric value -> its registry name (e.g. "SANDBOX_NOT_FOUND").""" + try: + name = errors_pb2.ErrorCode.Name(code) + except ValueError: + return f"{_ERROR_CODE_PREFIX}{code}" + return name.removeprefix(_ERROR_CODE_PREFIX) + + +def error_from_wire( + connect_code: str, + message: str, + info: errors_pb2.ErrorInfo | None, + operation: str | None = None, +) -> ArcBoxError: + """Map one daemon error to the typed hierarchy. + + Precedence: an `ErrorInfo` detail (the daemon's error registry) wins; + otherwise the coarse Connect code routes. Unknown registry codes stay + on the base class with the raw code preserved. + """ + if info is None: + cls = _CONNECT_CODE_CLASSES.get(connect_code, ArcBoxError) + return cls( + message, + code=connect_code, + suggestion=_CONNECT_CODE_SUGGESTIONS.get(connect_code), + operation=operation, + ) + cls = _REGISTRY_CLASSES.get(info.code, ArcBoxError) + return cls( + message, + code=error_code_name(info.code), + suggestion=info.suggestion or None, + context=dict(info.context), + operation=operation, + ) From 5cf78112383a3f8cc2735488b5c567759713dc23 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:11:00 +0800 Subject: [PATCH 04/20] feat(sdk): hand-written public DTOs and their wire mappings SandboxInfo/SandboxSummary/CommandResult/OutputChunk plus the proto mapping helpers, shared by the async and sync trees. Exit is data: command_result_from_execution turns signal death into 128+signal with the signal name, raises SandboxDiedError when the execution ended without an observed exit, and expect() is the opt-in raise. Python time units are seconds (floats), mirroring the design doc's per-language convention. --- sdk/python/src/arcbox/_types.py | 245 ++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 sdk/python/src/arcbox/_types.py diff --git a/sdk/python/src/arcbox/_types.py b/sdk/python/src/arcbox/_types.py new file mode 100644 index 000000000..6f3051fb2 --- /dev/null +++ b/sdk/python/src/arcbox/_types.py @@ -0,0 +1,245 @@ +"""Hand-written public shapes and their wire mappings. + +Generated types never appear in a public signature — these DTOs are +mapped from the wire messages at the transport boundary. Shared by the +async and sync trees (nothing here is async). + +Time units follow the Python convention of this SDK: seconds as floats, +no unit suffix (the TypeScript SDK uses ``*Ms`` milliseconds). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Literal + +from arcbox._gen import process_pb2, sandbox_pb2 +from arcbox.errors import ArcBoxError, CommandFailedError, SandboxDiedError + +if TYPE_CHECKING: + from google.protobuf import timestamp_pb2 + +#: Lifecycle state of a sandbox. See `sandbox.proto` for the state machine. +SandboxState = Literal[ + "unknown", + "starting", + "ready", + "running", + "stopping", + "stopped", + "failed", + "pausing", + "paused", +] + +#: What the daemon does when the idle timeout expires. +IdlePolicy = Literal["kill", "pause"] + +#: Signals deliverable to a command's process group. +SignalName = Literal["SIGTERM", "SIGKILL", "SIGINT", "SIGHUP", "SIGQUIT"] + +SIGNAL_VALUES: dict[SignalName, int] = { + "SIGHUP": process_pb2.SIGNAL_SIGHUP, + "SIGINT": process_pb2.SIGNAL_SIGINT, + "SIGQUIT": process_pb2.SIGNAL_SIGQUIT, + "SIGKILL": process_pb2.SIGNAL_SIGKILL, + "SIGTERM": process_pb2.SIGNAL_SIGTERM, +} + +#: One chunk of command output. +OutputChannel = Literal["stdout", "stderr", "pty"] + + +@dataclass(frozen=True) +class OutputChunk: + """One chunk of command output.""" + + channel: OutputChannel + data: bytes + + +@dataclass(frozen=True) +class CommandResult: + """A finished command. Non-zero exit is data, not an exception — + :meth:`expect` is the opt-in raise.""" + + #: Process exit code. When the process was killed by a signal this is + #: ``128 + signal`` (shell convention) and :attr:`signal` is set. + exit_code: int + stdout: str + #: Stderr content — warnings land here too; it is not "errors". + stderr: str + #: Signal name when killed by a signal (e.g. "SIGKILL"). + signal: str | None = None + #: True when the daemon's output retention (8 MiB per channel) + #: dropped bytes before they were collected — :attr:`stdout` / + #: :attr:`stderr` then hold only the newest retained output. + truncated: bool = False + + def expect(self) -> CommandResult: + """Raise :class:`arcbox.errors.CommandFailedError` on non-zero exit.""" + if self.exit_code != 0: + raise CommandFailedError(self) + return self + + +@dataclass(frozen=True) +class SandboxInfo: + """Full sandbox state, always fetched fresh — never a cached mirror.""" + + id: str + state: SandboxState + labels: dict[str, str] = field(default_factory=dict[str, str]) + #: Template reference the sandbox was created from ("" = built-in minimal). + template: str = "" + #: Effective vCPU count, when reported. + vcpus: int | None = None + #: Effective memory in MiB, when reported. + memory_mib: int | None = None + ip_address: str | None = None + created_at: datetime | None = None + ready_at: datetime | None = None + paused_at: datetime | None = None + failed_at: datetime | None = None + #: Failure reason; set exactly when state is "failed". + error: str | None = None + #: When the hard maximum lifetime fires (None = no limit). + ttl_deadline: datetime | None = None + #: Idle timeout in seconds (None = no idle detection). + idle_timeout: float | None = None + #: Action applied when the idle timeout expires (None = daemon default). + on_idle: IdlePolicy | None = None + #: On-disk footprint of retained state; paused sandboxes keep paying this. + storage_bytes: int = 0 + + +@dataclass(frozen=True) +class SandboxSummary: + """One row of a sandbox listing.""" + + id: str + state: SandboxState + labels: dict[str, str] = field(default_factory=dict[str, str]) + ip_address: str | None = None + created_at: datetime | None = None + ready_at: datetime | None = None + paused_at: datetime | None = None + failed_at: datetime | None = None + storage_bytes: int = 0 + + +_STATE_NAMES: dict[int, SandboxState] = { + sandbox_pb2.SANDBOX_STATE_STARTING: "starting", + sandbox_pb2.SANDBOX_STATE_READY: "ready", + sandbox_pb2.SANDBOX_STATE_RUNNING: "running", + sandbox_pb2.SANDBOX_STATE_STOPPING: "stopping", + sandbox_pb2.SANDBOX_STATE_STOPPED: "stopped", + sandbox_pb2.SANDBOX_STATE_FAILED: "failed", + sandbox_pb2.SANDBOX_STATE_PAUSING: "pausing", + sandbox_pb2.SANDBOX_STATE_PAUSED: "paused", +} + +_STATE_VALUES: dict[SandboxState, int] = { + "unknown": sandbox_pb2.SANDBOX_STATE_UNSPECIFIED, + **{name: value for value, name in _STATE_NAMES.items()}, +} + + +def sandbox_state_from_proto(state: int) -> SandboxState: + """Wire state -> public state ("unknown" for values this SDK predates).""" + return _STATE_NAMES.get(state, "unknown") + + +def sandbox_state_to_proto(state: SandboxState) -> int: + """Public state filter -> wire state.""" + return _STATE_VALUES[state] + + +def _optional_time(msg: timestamp_pb2.Timestamp, present: bool) -> datetime | None: + return msg.ToDatetime(tzinfo=timezone.utc) if present else None + + +def sandbox_info_from_proto(info: sandbox_pb2.SandboxInfo) -> SandboxInfo: + """Map the Inspect response to the public DTO.""" + limits = info.limits + idle_timeout = float(info.idle_timeout_seconds) if info.idle_timeout_seconds != 0 else None + on_idle: IdlePolicy | None = None + if idle_timeout is not None: + if info.on_idle == sandbox_pb2.IDLE_ACTION_KILL: + on_idle = "kill" + elif info.on_idle == sandbox_pb2.IDLE_ACTION_PAUSE: + on_idle = "pause" + return SandboxInfo( + id=info.id, + state=sandbox_state_from_proto(info.state), + labels=dict(info.labels), + template=info.template, + vcpus=limits.vcpus if limits.vcpus != 0 else None, + memory_mib=int(limits.memory_mib) if limits.memory_mib != 0 else None, + ip_address=info.network.ip_address or None, + created_at=_optional_time(info.created_at, info.HasField("created_at")), + ready_at=_optional_time(info.ready_at, info.HasField("ready_at")), + paused_at=_optional_time(info.paused_at, info.HasField("paused_at")), + failed_at=_optional_time(info.failed_at, info.HasField("failed_at")), + error=info.error or None, + ttl_deadline=_optional_time(info.ttl_deadline, info.HasField("ttl_deadline")), + idle_timeout=idle_timeout, + on_idle=on_idle, + storage_bytes=int(info.storage_bytes), + ) + + +def sandbox_summary_from_proto(summary: sandbox_pb2.SandboxSummary) -> SandboxSummary: + """Map one List row to the public DTO.""" + return SandboxSummary( + id=summary.id, + state=sandbox_state_from_proto(summary.state), + labels=dict(summary.labels), + ip_address=summary.ip_address or None, + created_at=_optional_time(summary.created_at, summary.HasField("created_at")), + ready_at=_optional_time(summary.ready_at, summary.HasField("ready_at")), + paused_at=_optional_time(summary.paused_at, summary.HasField("paused_at")), + failed_at=_optional_time(summary.failed_at, summary.HasField("failed_at")), + storage_bytes=int(summary.storage_bytes), + ) + + +def signal_display_name(value: int) -> str: + """Map a POSIX signal number to its conventional name.""" + try: + name = process_pb2.Signal.Name(value) + except ValueError: + return f"SIG{value}" + if name == "SIGNAL_UNSPECIFIED": + return f"SIG{value}" + return name.removeprefix("SIGNAL_") + + +def command_result_from_execution( + execution: process_pb2.Execution, + stdout: str, + stderr: str, + truncated: bool = False, +) -> CommandResult: + """Build the exit-as-data result from a terminal execution. + + An execution that ended without an observed exit (session broke, + sandbox stopped) raises :class:`SandboxDiedError` instead — that is + not an exit. + """ + if execution.error != "": + raise SandboxDiedError( + f"command ended without an exit: {execution.error}", + context={"execution_id": execution.id, "error": execution.error}, + ) + status = execution.exit_status.WhichOneof("status") + if status == "code": + return CommandResult(execution.exit_status.code, stdout, stderr, None, truncated) + if status == "signal": + value = execution.exit_status.signal + return CommandResult(128 + value, stdout, stderr, signal_display_name(value), truncated) + raise ArcBoxError( + "execution exited without an exit status", + context={"execution_id": execution.id}, + ) From b865aaa5f255e7745dc22f9af190c24627283cdc Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:14:11 +0800 Subject: [PATCH 05/20] feat(sdk): Connect-over-httpx transport core (async tree) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AsyncConnectClient serves the three RPC shapes the phase-1 surface uses: unary (POST, application/proto, connect-timeout-ms mirrored into the httpx deadline), server-streaming (AsyncServerStream — entering the context sends the request, which is what arms a subscription before Create; iteration decodes envelopes and ends deterministically on the EndStreamResponse), and client-streaming with the request sequence known up front. UDS via httpx.AsyncHTTPTransport(uds=...); injected http_client is the mock seam. _boundary.wrap_errors is the single transport-to-exception boundary. --- sdk/python/src/arcbox/_async/__init__.py | 2 + sdk/python/src/arcbox/_async/_client.py | 214 +++++++++++++++++++++++ sdk/python/src/arcbox/_boundary.py | 56 ++++++ 3 files changed, 272 insertions(+) create mode 100644 sdk/python/src/arcbox/_async/__init__.py create mode 100644 sdk/python/src/arcbox/_async/_client.py create mode 100644 sdk/python/src/arcbox/_boundary.py diff --git a/sdk/python/src/arcbox/_async/__init__.py b/sdk/python/src/arcbox/_async/__init__.py new file mode 100644 index 000000000..5294e9bd4 --- /dev/null +++ b/sdk/python/src/arcbox/_async/__init__.py @@ -0,0 +1,2 @@ +"""Async core of the SDK. The sync tree under `arcbox._sync` is +generated from this package by scripts/gen_sync.py — edit here.""" diff --git a/sdk/python/src/arcbox/_async/_client.py b/sdk/python/src/arcbox/_async/_client.py new file mode 100644 index 000000000..a1acb385e --- /dev/null +++ b/sdk/python/src/arcbox/_async/_client.py @@ -0,0 +1,214 @@ +"""Connect-over-httpx transport. + +Unary RPCs are plain POSTs with binary-protobuf bodies +(``application/proto``); streaming RPCs speak the Connect streaming +envelope (``application/connect+proto``, framing in ``_envelope``). +The local tier dials the daemon's Unix socket through httpx's ``uds`` +transport; the same code path serves a future remote tier over HTTPS. +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Generic, TypeVar + +import httpx + +from arcbox._connection import Connection, resolve_connection +from arcbox._envelope import ( + FLAG_COMPRESSED, + FLAG_END_STREAM, + EnvelopeDecoder, + encode_envelope, + end_stream_error, + unary_error, +) +from arcbox.errors import ArcBoxError, InvalidArgumentError + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterable, Mapping + from contextlib import AbstractAsyncContextManager + from types import TracebackType + + from google.protobuf.message import Message + +M = TypeVar("M", bound="Message") + +_UNARY_HEADERS = { + "content-type": "application/proto", + "connect-protocol-version": "1", +} +_STREAM_HEADERS = { + "content-type": "application/connect+proto", + "connect-protocol-version": "1", +} + + +class _UseDefault: + """Sentinel: apply the connection's ``request_timeout``.""" + + +_USE_DEFAULT = _UseDefault() + + +class AsyncConnectClient: + """One resolved connection to a daemon; every handle shares it.""" + + def __init__( + self, + connection: Connection | None = None, + env: Mapping[str, str] | None = None, + ) -> None: + resolved = resolve_connection(connection, os.environ if env is None else env) + #: Per-unary-RPC deadline in seconds, when configured. Streams + #: and long-polls never receive it. + self.request_timeout = resolved.request_timeout + injected = connection.http_client if connection is not None else None + if injected is not None: + required = httpx.AsyncClient + if not isinstance(injected, required): + raise InvalidArgumentError( + f"Connection.http_client must be an httpx.{required.__name__} for this surface" + ) + self._http = injected + return + headers = {"authorization": f"Bearer {resolved.api_key}"} if resolved.api_key else None + transport = ( + httpx.AsyncHTTPTransport(uds=resolved.socket_path) + if resolved.socket_path is not None + else None + ) + self._http = httpx.AsyncClient( + base_url=resolved.base_url, + transport=transport, + headers=headers, + timeout=None, + ) + + async def unary( + self, + path: str, + request: Message, + response_type: type[M], + timeout: float | _UseDefault | None = _USE_DEFAULT, + ) -> M: + """One unary RPC. ``timeout`` overrides the configured deadline; + an explicit ``None`` means no deadline (checkpoint restores take + as long as they take).""" + effective = self.request_timeout if isinstance(timeout, _UseDefault) else timeout + headers = dict(_UNARY_HEADERS) + if effective is not None: + headers["connect-timeout-ms"] = str(int(effective * 1000)) + response = await self._http.post( + path, + content=request.SerializeToString(), + headers=headers, + timeout=None if effective is None else httpx.Timeout(effective), + ) + if response.status_code != 200: + raise unary_error(response.status_code, response.content) + content_type = response.headers.get("content-type", "") + if not content_type.startswith("application/proto"): + raise ArcBoxError(f"unexpected unary response content-type {content_type!r}") + message = response_type() + message.ParseFromString(response.content) + return message + + def stream(self, path: str, request: Message, response_type: type[M]) -> AsyncServerStream[M]: + """One server-streaming RPC. Entering the returned context sends + the request (that is what registers a subscription server-side); + iterate it for messages.""" + return AsyncServerStream(self._http, path, request, response_type) + + async def client_stream( + self, path: str, requests: Iterable[Message], response_type: type[M] + ) -> M: + """One client-streaming RPC with the full request sequence known + up front: the envelopes are sent as one body and the single + response message is returned.""" + body = b"".join(encode_envelope(0, m.SerializeToString()) for m in requests) + response = await self._http.post(path, content=body, headers=_STREAM_HEADERS, timeout=None) + if response.status_code != 200: + raise unary_error(response.status_code, response.content) + decoder = EnvelopeDecoder() + message: M | None = None + for flags, payload in decoder.feed(response.content): + if flags & FLAG_COMPRESSED: + raise ArcBoxError("received a compressed frame without negotiating compression") + if flags & FLAG_END_STREAM: + error = end_stream_error(payload) + if error is not None: + raise error + else: + decoded = response_type() + decoded.ParseFromString(payload) + message = decoded + if message is None: + raise ArcBoxError("the stream ended without a response message") + return message + + +class AsyncServerStream(Generic[M]): + """One server-streaming call: a context manager yielding an iterator. + + The stream ends deterministically — with the terminal + EndStreamResponse frame or a typed error, never silence. + """ + + def __init__( + self, + http: httpx.AsyncClient, + path: str, + request: Message, + response_type: type[M], + ) -> None: + self._http = http + self._path = path + self._body = encode_envelope(0, request.SerializeToString()) + self._response_type = response_type + self._cm: AbstractAsyncContextManager[httpx.Response] | None = None + self._response: httpx.Response | None = None + + async def __aenter__(self) -> AsyncServerStream[M]: + cm = self._http.stream( + "POST", self._path, content=self._body, headers=_STREAM_HEADERS, timeout=None + ) + response = await cm.__aenter__() + if response.status_code != 200: + body = await response.aread() + await cm.__aexit__(None, None, None) + raise unary_error(response.status_code, body) + self._cm = cm + self._response = response + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + if self._cm is not None: + await self._cm.__aexit__(exc_type, exc, tb) + + def __aiter__(self) -> AsyncIterator[M]: + return self._messages() + + async def _messages(self) -> AsyncIterator[M]: + response = self._response + if response is None: + raise ArcBoxError("stream iterated before it was entered") + decoder = EnvelopeDecoder() + async for data in response.aiter_bytes(): + for flags, payload in decoder.feed(data): + if flags & FLAG_COMPRESSED: + raise ArcBoxError("received a compressed frame without negotiating compression") + if flags & FLAG_END_STREAM: + error = end_stream_error(payload) + if error is not None: + raise error + return + message = self._response_type() + message.ParseFromString(payload) + yield message + raise ArcBoxError("the stream ended without an EndStreamResponse") diff --git a/sdk/python/src/arcbox/_boundary.py b/sdk/python/src/arcbox/_boundary.py new file mode 100644 index 000000000..634118a58 --- /dev/null +++ b/sdk/python/src/arcbox/_boundary.py @@ -0,0 +1,56 @@ +"""The single transport→exception boundary. + +Call sites wrap every RPC in :func:`wrap_errors` and never inspect +Connect or httpx errors themselves. Shared by the async and sync trees +(a non-suspending ``with`` block is valid in both). +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +import httpx + +from arcbox.errors import ( + DAEMON_START_SUGGESTION, + ArcBoxError, + ConnectionFailedError, + RequestTimeoutError, +) + +if TYPE_CHECKING: + from collections.abc import Generator + + +@contextmanager +def wrap_errors(operation: str) -> Generator[None]: + """Map transport failures to the typed hierarchy, stamping ``operation``. + + Precedence mirrors the TypeScript SDK: an already-typed + :class:`ArcBoxError` passes through (gaining the operation name); + connection-level failures become :class:`ConnectionFailedError` with + a start-the-daemon suggestion; client-side deadline expiry becomes + :class:`RequestTimeoutError`; any other httpx transport failure is + preserved as the cause of a base :class:`ArcBoxError`. + """ + try: + yield + except ArcBoxError as exc: + if exc.operation is None: + exc.operation = operation + raise + except (httpx.ConnectError, httpx.ConnectTimeout) as exc: + raise ConnectionFailedError( + "the ArcBox daemon is not reachable", + suggestion=DAEMON_START_SUGGESTION, + operation=operation, + ) from exc + except httpx.TimeoutException as exc: + raise RequestTimeoutError( + "the request deadline elapsed before the daemon answered", + suggestion="increase Connection.request_timeout", + operation=operation, + ) from exc + except httpx.HTTPError as exc: + raise ArcBoxError(f"transport failure: {exc}", operation=operation) from exc From cec706b05f8612aa83a14b1d39ab716b1ae6c78e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:19:26 +0800 Subject: [PATCH 06/20] =?UTF-8?q?feat(sdk):=20async=20Sandbox=20surface=20?= =?UTF-8?q?=E2=80=94=20create/connect/list,=20commands,=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the TS SDK's phase-1 surface: AsyncArcBox.create mints the id client-side and arms the Events subscription before Create (residual registration window covered by an immediate Inspect plus keepalive re-inspects), cleans up best-effort on a failed create; connect resumes PAUSED and waits out STARTING/PAUSING; list auto-paginates. commands.run is overloaded on background (foreground exit-as-data with check= subprocess semantics; background handle with streamed output, long-poll wait_for_exit in 30s slices, group kill). files does whole-file read/write with the 256 MiB cap and the mode=0-is-default wire reservation documented. --- sdk/python/src/arcbox/_async/commands.py | 276 ++++++++++++++ sdk/python/src/arcbox/_async/files.py | 105 ++++++ sdk/python/src/arcbox/_async/sandbox.py | 436 +++++++++++++++++++++++ sdk/python/src/arcbox/_types.py | 31 +- 4 files changed, 834 insertions(+), 14 deletions(-) create mode 100644 sdk/python/src/arcbox/_async/commands.py create mode 100644 sdk/python/src/arcbox/_async/files.py create mode 100644 sdk/python/src/arcbox/_async/sandbox.py diff --git a/sdk/python/src/arcbox/_async/commands.py b/sdk/python/src/arcbox/_async/commands.py new file mode 100644 index 000000000..a4b419b5a --- /dev/null +++ b/sdk/python/src/arcbox/_async/commands.py @@ -0,0 +1,276 @@ +"""The ``sandbox.commands`` namespace: run processes inside one sandbox.""" + +from __future__ import annotations + +import math +import time +import uuid +from typing import TYPE_CHECKING, Literal, overload + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import process_pb2 +from arcbox._types import ( + SIGNAL_VALUES, + CommandResult, + OutputChunk, + command_result_from_execution, +) +from arcbox.errors import InvalidArgumentError, TimeoutError + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Mapping, Sequence + + from arcbox._types import OutputChannel, SignalName + + from ._client import AsyncConnectClient, AsyncServerStream + +_PROCESS = "/arcbox.sandbox.v1.SandboxProcessService/" + +#: Longest single WaitExecution long-poll slice, so a dropped daemon +#: surfaces as an error instead of an infinite silent wait. +_WAIT_SLICE_SECONDS = 30 + + +def _normalize_cmd(cmd: str | Sequence[str]) -> list[str]: + """``str`` is sugar for a shell command; a sequence is argv, executed + directly with no shell involved.""" + return ["/bin/sh", "-lc", cmd] if isinstance(cmd, str) else list(cmd) + + +def _channel_name(channel: int) -> OutputChannel: + if channel == process_pb2.STDIO_CHANNEL_STDERR: + return "stderr" + if channel == process_pb2.STDIO_CHANNEL_PTY: + return "pty" + return "stdout" + + +def _decode_output(chunks: list[bytes]) -> str: + return b"".join(chunks).decode("utf-8", errors="replace") + + +class AsyncCommandHandle: + """A handle to a running (or finished) command. The process is + decoupled from this object: dropping the handle never kills the + process.""" + + def __init__(self, client: AsyncConnectClient, sandbox_id: str, command_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + #: Execution id, unique within the sandbox; addressable across clients. + self.command_id = command_id + + @property + def output(self) -> AsyncIterator[OutputChunk]: + """Stream the command's output from the beginning — or from the + earliest byte the daemon still retains (8 MiB per channel); + replayed buffered output comes first, then live output follows; + the stream ends when the process exits (deterministic + termination — never silence).""" + return self._stream_output() + + async def _stream_output(self) -> AsyncIterator[OutputChunk]: + with wrap_errors("commands.output"): + async with self._attach() as stream: + async for event in stream: + kind = event.WhichOneof("event") + if kind == "output": + chunk = event.output + yield OutputChunk(_channel_name(chunk.channel), chunk.data) + elif kind == "exited": + return + + async def wait_for_exit(self, timeout: float | None = None) -> CommandResult: + """Wait until the command exits and return its result (server-side + long-poll; no client-side spinning). ``timeout`` bounds the WAIT, + not the process: on expiry a :class:`TimeoutError` is raised and + the process keeps running.""" + deadline = None if timeout is None else time.monotonic() + timeout + with wrap_errors("commands.wait_for_exit"): + while True: + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + raise TimeoutError( + "wait_for_exit(timeout) elapsed before the command exited", + suggestion=( + "increase the wait_for_exit timeout argument, or kill() the command" + ), + context={"command_id": self.command_id}, + ) + slice_seconds = ( + _WAIT_SLICE_SECONDS + if remaining is None + else min(_WAIT_SLICE_SECONDS, max(1, math.ceil(remaining))) + ) + execution = await self._client.unary( + _PROCESS + "WaitExecution", + process_pb2.WaitExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + timeout_seconds=slice_seconds, + ), + process_pb2.Execution, + # Exempt from request_timeout: this unary deliberately + # parks server-side for the slice; grant it that long + # plus grace. + timeout=float(slice_seconds + 5), + ) + if execution.state == process_pb2.EXECUTION_STATE_EXITED: + break + return await self._collect_result(execution) + + async def kill(self, signal: SignalName = "SIGTERM") -> None: + """Deliver a signal to the whole process group (default SIGTERM).""" + with wrap_errors("commands.kill"): + await self._client.unary( + _PROCESS + "SignalExecution", + process_pb2.SignalExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + signal=SIGNAL_VALUES[signal], + ), + empty_pb2.Empty, + ) + + def _attach(self) -> AsyncServerStream[process_pb2.ExecutionEvent]: + return self._client.stream( + _PROCESS + "AttachExecution", + process_pb2.AttachExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + stdout_offset=0, + stderr_offset=0, + ), + process_pb2.ExecutionEvent, + ) + + async def _collect_result(self, execution: process_pb2.Execution) -> CommandResult: + """Assemble the result of an exited execution. Output is re-read + from offset 0 — the daemon retains and replays it, so the result + is complete even when nobody consumed the live stream, UNLESS the + command outgrew the daemon's per-channel retention (8 MiB): chunk + offsets expose the dropped head, reported as ``truncated``.""" + stdout: list[bytes] = [] + stderr: list[bytes] = [] + next_stdout = 0 + next_stderr = 0 + truncated = False + async with self._attach() as stream: + async for event in stream: + kind = event.WhichOneof("event") + if kind == "output": + chunk = event.output + is_stderr = chunk.channel == process_pb2.STDIO_CHANNEL_STDERR + # A chunk landing past the expected offset means + # retention already dropped bytes we asked for. + if chunk.offset > (next_stderr if is_stderr else next_stdout): + truncated = True + after = chunk.offset + len(chunk.data) + if is_stderr: + next_stderr = after + stderr.append(chunk.data) + else: + next_stdout = after + stdout.append(chunk.data) + elif kind == "exited": + break + return command_result_from_execution( + execution, _decode_output(stdout), _decode_output(stderr), truncated + ) + + +class AsyncCommands: + """Run processes inside one sandbox.""" + + def __init__(self, client: AsyncConnectClient, sandbox_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + + @overload + async def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + check: bool = False, + background: Literal[False] = False, + ) -> CommandResult: ... + + @overload + async def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + background: Literal[True], + ) -> AsyncCommandHandle: ... + + async def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + check: bool = False, + background: bool = False, + ) -> CommandResult | AsyncCommandHandle: + """Run a command. Foreground (default): returns the complete + :class:`CommandResult` once the process exits — non-zero exit is + data unless ``check=True`` (`subprocess.run` semantics). + Background (``background=True``): returns as soon as the process + is started, with a handle for streaming and waiting. + + ``timeout`` kills the whole process group after that many + seconds; expiry surfaces as signal death in the result + (exit-as-data), not as a raised error.""" + if background and check: + raise InvalidArgumentError( + "check=True applies to foreground runs; call " + ".wait_for_exit() and .expect() on the handle instead", + operation="commands.run", + ) + handle = await self._start(cmd, cwd=cwd, env=env, user=user, timeout=timeout) + if background: + return handle + result = await handle.wait_for_exit() + return result.expect() if check else result + + async def _start( + self, + cmd: str | Sequence[str], + *, + cwd: str | None, + env: Mapping[str, str] | None, + user: str | None, + timeout: float | None, + ) -> AsyncCommandHandle: + with wrap_errors("commands.run"): + # The execution id is minted client-side: a lost response + # leaves an addressable execution, and retries are idempotent + # by contract. + execution_id = str(uuid.uuid4()) + execution = await self._client.unary( + _PROCESS + "StartExecution", + process_pb2.StartExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=execution_id, + cmd=_normalize_cmd(cmd), + env=dict(env) if env else {}, + working_dir=cwd or "", + user=user or "", + timeout_seconds=0 if timeout is None else math.ceil(timeout), + stdin=False, + ), + process_pb2.Execution, + ) + return AsyncCommandHandle(self._client, self._sandbox_id, execution.id) diff --git a/sdk/python/src/arcbox/_async/files.py b/sdk/python/src/arcbox/_async/files.py new file mode 100644 index 000000000..2844cef4a --- /dev/null +++ b/sdk/python/src/arcbox/_async/files.py @@ -0,0 +1,105 @@ +"""The ``sandbox.files`` namespace: move bytes in and out of one sandbox. + +Bytes-first — text variants are explicit UTF-8 conveniences, never a +silent default. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import filesystem_pb2 +from arcbox._types import MAX_FILE_BYTES +from arcbox.errors import FileTooLargeError + +if TYPE_CHECKING: + from ._client import AsyncConnectClient + +_FILESYSTEM = "/arcbox.sandbox.v1.SandboxFilesystemService/" + +#: Chunk size for streamed writes. +_WRITE_CHUNK_BYTES = 256 * 1024 + +#: Default permission bits for created files (mirrors the daemon's default). +_DEFAULT_WRITE_MODE = 0o644 + + +class AsyncFiles: + """Move bytes in and out of one sandbox.""" + + def __init__(self, client: AsyncConnectClient, sandbox_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + + async def read_bytes(self, path: str) -> bytes: + """Read a file as raw bytes.""" + with wrap_errors("files.read_bytes"): + chunks: list[bytes] = [] + async with self._client.stream( + _FILESYSTEM + "ReadFile", + filesystem_pb2.ReadFileRequest(id=self._sandbox_id, path=path), + filesystem_pb2.FileChunk, + ) as stream: + async for chunk in stream: + if chunk.data: + chunks.append(chunk.data) + if chunk.done: + break + return b"".join(chunks) + + async def read_text(self, path: str, encoding: str = "utf-8") -> str: + """Read a file and decode it (UTF-8 by default).""" + return (await self.read_bytes(path)).decode(encoding) + + async def write_bytes(self, path: str, data: bytes, mode: int | None = None) -> None: + """Write raw bytes to a file, creating or truncating it. + + ``mode`` is the Unix permission bits for the created file + (default 0o644). The wire protocol reserves 0 as "use the + default" (`filesystem.proto`), so a literal mode of 0 is not + expressible — it also yields 0o644. + """ + if len(data) > MAX_FILE_BYTES: + raise FileTooLargeError( + f"file of {len(data)} bytes exceeds the {MAX_FILE_BYTES}-byte per-file cap", + operation="files.write_bytes", + context={ + "path": path, + "limit": str(MAX_FILE_BYTES), + "size": str(len(data)), + }, + ) + requests = [ + filesystem_pb2.WriteFileRequest( + open=filesystem_pb2.WriteFileOpen( + id=self._sandbox_id, + path=path, + mode=_DEFAULT_WRITE_MODE if mode is None else mode, + ) + ) + ] + # Always send at least one chunk so `done` is observed, even for + # an empty file. + offset = 0 + while True: + end = min(offset + _WRITE_CHUNK_BYTES, len(data)) + done = end == len(data) + requests.append( + filesystem_pb2.WriteFileRequest( + chunk=filesystem_pb2.FileChunk(data=data[offset:end], done=done) + ) + ) + if done: + break + offset = end + with wrap_errors("files.write_bytes"): + await self._client.client_stream(_FILESYSTEM + "WriteFile", requests, empty_pb2.Empty) + + async def write_text( + self, path: str, text: str, encoding: str = "utf-8", mode: int | None = None + ) -> None: + """Write text to a file (UTF-8 by default).""" + await self.write_bytes(path, text.encode(encoding), mode) diff --git a/sdk/python/src/arcbox/_async/sandbox.py b/sdk/python/src/arcbox/_async/sandbox.py new file mode 100644 index 000000000..32e7c5e0a --- /dev/null +++ b/sdk/python/src/arcbox/_async/sandbox.py @@ -0,0 +1,436 @@ +"""Sandbox handles and the client entry point. + +A handle holds only the sandbox id and the connection — state is never +cached; ``info()`` always fetches fresh. +""" + +from __future__ import annotations + +import math +import uuid +from contextlib import suppress +from typing import TYPE_CHECKING + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import sandbox_pb2 +from arcbox._types import ( + SandboxInfo, + SandboxSummary, + sandbox_info_from_proto, + sandbox_state_from_proto, + sandbox_state_to_proto, + sandbox_summary_from_proto, +) +from arcbox.errors import ArcBoxError, NotFoundError, SandboxStateError + +from ._client import AsyncConnectClient +from .commands import AsyncCommands +from .files import AsyncFiles + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Mapping, Sequence + from types import TracebackType + + from arcbox._connection import Connection + from arcbox._types import IdlePolicy, SandboxState + + from ._client import AsyncServerStream + +_SANDBOX = "/arcbox.sandbox.v1.SandboxService/" + +_READY_STATES = (sandbox_pb2.SANDBOX_STATE_READY, sandbox_pb2.SANDBOX_STATE_RUNNING) +_GONE_STATES = (sandbox_pb2.SANDBOX_STATE_STOPPING, sandbox_pb2.SANDBOX_STATE_STOPPED) +_READY_EVENTS = ( + sandbox_pb2.SANDBOX_EVENT_KIND_READY, + sandbox_pb2.SANDBOX_EVENT_KIND_RUNNING, + sandbox_pb2.SANDBOX_EVENT_KIND_IDLE, +) +_GONE_EVENTS = ( + sandbox_pb2.SANDBOX_EVENT_KIND_STOPPING, + sandbox_pb2.SANDBOX_EVENT_KIND_STOPPED, + sandbox_pb2.SANDBOX_EVENT_KIND_REMOVED, +) + + +def _seconds_to_wire(seconds: float | None) -> int: + return 0 if seconds is None else math.ceil(seconds) + + +class AsyncArcBox: + """Client entry point. Holds one resolved connection; every handle it + creates shares it. The :class:`AsyncSandbox` classmethods are sugar + over a throwaway instance resolved from options/environment.""" + + def __init__(self, connection: Connection | None = None) -> None: + self._client = AsyncConnectClient(connection) + + async def create( + self, + template: str = "", + *, + ttl: float | None = None, + idle_timeout: float | None = None, + on_idle: IdlePolicy | None = None, + vcpus: int | None = None, + memory_mib: int | None = None, + cmd: Sequence[str] | None = None, + env: Mapping[str, str] | None = None, + labels: Mapping[str, str] | None = None, + network: bool | None = None, + wait_until_ready: bool = True, + ) -> AsyncSandbox: + """Create a sandbox and (by default) wait until it is READY. + + ``ttl`` is the hard maximum lifetime — on expiry the daemon + always destroys the sandbox. ``idle_timeout`` + ``on_idle`` is + the independent idle-reaping knob; never conflate the two. + + The sandbox id is minted client-side so the readiness + subscription can be armed BEFORE the create call — + subscribe-then-act, so no transition is missed (the CORE-67 + rule) — and so retries stay idempotent. + """ + with wrap_errors("sandbox.create"): + sandbox_id = str(uuid.uuid4()) + try: + if wait_until_ready: + async with self._client.stream( + _SANDBOX + "Events", + sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), + sandbox_pb2.WatchEventsResponse, + ) as events: + await self._create( + sandbox_id, + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + ) + await self._wait_ready(sandbox_id, events) + else: + await self._create( + sandbox_id, + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + ) + return AsyncSandbox(self._client, sandbox_id) + except BaseException: + # The sandbox may exist even though create() failed + # (readiness failed, response lost) and ttl is optional, + # so a leaked VM could run forever. Best-effort removal; a + # failure here (e.g. nothing was created) must not mask + # the original error. + with suppress(Exception): + await self._client.unary( + _SANDBOX + "Remove", + sandbox_pb2.RemoveSandboxRequest(id=sandbox_id, force=True), + empty_pb2.Empty, + ) + raise + + async def connect(self, sandbox_id: str) -> AsyncSandbox: + """Attach to an existing sandbox. A PAUSED sandbox is resumed + (resume completes once it is READY again); a STARTING one is + waited for. A terminal state is a typed error carrying the + observed state. Connecting never touches the sandbox's lifecycle + deadlines.""" + with wrap_errors("sandbox.connect"): + info = await self._inspect(sandbox_id) + if info.state in _READY_STATES: + return AsyncSandbox(self._client, sandbox_id) + if info.state == sandbox_pb2.SANDBOX_STATE_PAUSED: + # Deliberately no per-request deadline: restoring a + # checkpoint takes as long as it takes, and the RPC + # returns once READY. + await self._client.unary( + _SANDBOX + "Resume", + sandbox_pb2.ResumeSandboxRequest(id=sandbox_id), + empty_pb2.Empty, + timeout=None, + ) + return AsyncSandbox(self._client, sandbox_id) + if info.state in ( + sandbox_pb2.SANDBOX_STATE_STARTING, + sandbox_pb2.SANDBOX_STATE_PAUSING, + ): + async with self._client.stream( + _SANDBOX + "Events", + sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), + sandbox_pb2.WatchEventsResponse, + ) as events: + await self._wait_ready(sandbox_id, events) + return AsyncSandbox(self._client, sandbox_id) + state = sandbox_state_from_proto(info.state) + raise SandboxStateError( + f"sandbox {sandbox_id} is {state} and cannot be connected to", + context={"id": sandbox_id, "state": state, "error": info.error}, + ) + + async def list( + self, + *, + state: SandboxState | None = None, + labels: Mapping[str, str] | None = None, + ) -> AsyncIterator[SandboxSummary]: + """List sandboxes, auto-paginating server-side pages.""" + with wrap_errors("sandbox.list"): + page_token = "" + while True: + page = await self._client.unary( + _SANDBOX + "List", + sandbox_pb2.ListSandboxesRequest( + state=( + sandbox_pb2.SANDBOX_STATE_UNSPECIFIED + if state is None + else sandbox_state_to_proto(state) + ), + labels=dict(labels) if labels else {}, + page_token=page_token, + ), + sandbox_pb2.ListSandboxesResponse, + ) + for row in page.sandboxes: + yield sandbox_summary_from_proto(row) + page_token = page.next_page_token + if page_token == "": + return + + async def _inspect(self, sandbox_id: str) -> sandbox_pb2.SandboxInfo: + return await self._client.unary( + _SANDBOX + "Inspect", + sandbox_pb2.InspectSandboxRequest(id=sandbox_id), + sandbox_pb2.SandboxInfo, + ) + + async def _create( + self, + sandbox_id: str, + template: str, + *, + ttl: float | None, + idle_timeout: float | None, + on_idle: IdlePolicy | None, + vcpus: int | None, + memory_mib: int | None, + cmd: Sequence[str] | None, + env: Mapping[str, str] | None, + labels: Mapping[str, str] | None, + network: bool | None, + ) -> None: + request = sandbox_pb2.CreateSandboxRequest( + id=sandbox_id, + template=template, + labels=dict(labels) if labels else {}, + cmd=list(cmd) if cmd else [], + env=dict(env) if env else {}, + ttl_seconds=_seconds_to_wire(ttl), + idle_timeout_seconds=_seconds_to_wire(idle_timeout), + on_idle=( + sandbox_pb2.IDLE_ACTION_UNSPECIFIED + if on_idle is None + else sandbox_pb2.IDLE_ACTION_KILL + if on_idle == "kill" + else sandbox_pb2.IDLE_ACTION_PAUSE + ), + ) + if vcpus is not None or memory_mib is not None: + # Setting either sends `limits`, replacing template defaults + # WHOLESALE (a zero subfield means the daemon default). + request.limits.vcpus = vcpus or 0 + request.limits.memory_mib = memory_mib or 0 + if network is not None: + request.network.mode = ( + sandbox_pb2.NETWORK_MODE_ENABLED if network else sandbox_pb2.NETWORK_MODE_NONE + ) + await self._client.unary(_SANDBOX + "Create", request, sandbox_pb2.CreateSandboxResponse) + + async def _wait_ready( + self, + sandbox_id: str, + events: AsyncServerStream[sandbox_pb2.WatchEventsResponse], + ) -> None: + """Consume lifecycle events until READY/RUNNING, or fail on a + terminal transition. The subscription was armed before Create, so + nothing can be missed; the one residual window — Create processed + before the subscription registered server-side — is covered by an + immediate Inspect and by re-inspecting on every keepalive frame.""" + + def check(state: int, error: str) -> bool: + if state in _READY_STATES: + return True + if state == sandbox_pb2.SANDBOX_STATE_FAILED: + raise SandboxStateError( + f"sandbox {sandbox_id} failed to start: {error}", + context={"id": sandbox_id, "state": "failed", "error": error}, + ) + if state in _GONE_STATES: + raise SandboxStateError( + f"sandbox {sandbox_id} stopped before becoming ready", + context={"id": sandbox_id, "state": "stopped"}, + ) + return False + + info = await self._inspect(sandbox_id) + if check(info.state, info.error): + return + async for frame in events: + payload = frame.WhichOneof("payload") + if payload == "event": + event = frame.event + if event.kind in _READY_EVENTS: + return + if event.kind == sandbox_pb2.SANDBOX_EVENT_KIND_FAILED: + error = event.attributes.get("error", "") + raise SandboxStateError( + f"sandbox {sandbox_id} failed to start: {error}", + context={"id": sandbox_id, "state": "failed", "error": error}, + ) + if event.kind in _GONE_EVENTS: + raise SandboxStateError( + f"sandbox {sandbox_id} stopped before becoming ready", + context={"id": sandbox_id, "state": "stopped"}, + ) + elif payload == "keep_alive": + fresh = await self._inspect(sandbox_id) + if check(fresh.state, fresh.error): + return + raise ArcBoxError( + f"the event stream ended before sandbox {sandbox_id} became ready", + context={"id": sandbox_id}, + ) + + +class AsyncSandbox: + """A handle to one sandbox.""" + + def __init__(self, client: AsyncConnectClient, sandbox_id: str) -> None: + self._client = client + #: Sandbox id. + self.id = sandbox_id + #: Run processes inside the sandbox. + self.commands = AsyncCommands(client, sandbox_id) + #: Move bytes in and out of the sandbox. + self.files = AsyncFiles(client, sandbox_id) + + @classmethod + async def create( + cls, + template: str = "", + *, + ttl: float | None = None, + idle_timeout: float | None = None, + on_idle: IdlePolicy | None = None, + vcpus: int | None = None, + memory_mib: int | None = None, + cmd: Sequence[str] | None = None, + env: Mapping[str, str] | None = None, + labels: Mapping[str, str] | None = None, + network: bool | None = None, + wait_until_ready: bool = True, + connection: Connection | None = None, + ) -> AsyncSandbox: + """Create a sandbox against the default (or given) connection.""" + return await AsyncArcBox(connection).create( + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + wait_until_ready=wait_until_ready, + ) + + @classmethod + async def connect( + cls, sandbox_id: str, *, connection: Connection | None = None + ) -> AsyncSandbox: + """Attach to an existing sandbox by id.""" + return await AsyncArcBox(connection).connect(sandbox_id) + + @classmethod + def list( + cls, + *, + state: SandboxState | None = None, + labels: Mapping[str, str] | None = None, + connection: Connection | None = None, + ) -> AsyncIterator[SandboxSummary]: + """List sandboxes (auto-paginating).""" + return AsyncArcBox(connection).list(state=state, labels=labels) + + async def info(self) -> SandboxInfo: + """Fetch the sandbox's current state — always fresh, never cached.""" + with wrap_errors("sandbox.info"): + return sandbox_info_from_proto( + await self._client.unary( + _SANDBOX + "Inspect", + sandbox_pb2.InspectSandboxRequest(id=self.id), + sandbox_pb2.SandboxInfo, + ) + ) + + async def kill(self) -> None: + """Destroy the sandbox and release all its resources immediately.""" + with wrap_errors("sandbox.kill"): + await self._client.unary( + _SANDBOX + "Remove", + sandbox_pb2.RemoveSandboxRequest(id=self.id, force=True), + empty_pb2.Empty, + ) + + async def pause(self) -> None: + """Checkpoint the sandbox to disk under the same id and release + its runtime resources. Resume happens on the next ``connect`` + (or transparently, daemon-side, on the next data-plane call). + Trades RAM for disk: a paused sandbox keeps paying + ``storage_bytes``. + + Requires daemon-side CORE-21: the current local daemon serves + Pause/Resume as contract-only stubs, so this raises an + unimplemented :class:`ArcBoxError` until that lands.""" + with wrap_errors("sandbox.pause"): + # No per-request deadline: checkpointing takes as long as it + # takes. + await self._client.unary( + _SANDBOX + "Pause", + sandbox_pb2.PauseSandboxRequest(id=self.id), + empty_pb2.Empty, + timeout=None, + ) + + async def __aenter__(self) -> AsyncSandbox: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + # Scope-exit disposal kills the sandbox so a leaked handle never + # leaks a VM. Swallows only "already gone" — the whole + # NotFoundError family, because the daemon does not attach + # ErrorInfo details yet and a coarse NotFound on this id can only + # mean the sandbox. + with suppress(NotFoundError): + await self.kill() diff --git a/sdk/python/src/arcbox/_types.py b/sdk/python/src/arcbox/_types.py index 6f3051fb2..4416f4d80 100644 --- a/sdk/python/src/arcbox/_types.py +++ b/sdk/python/src/arcbox/_types.py @@ -39,7 +39,7 @@ #: Signals deliverable to a command's process group. SignalName = Literal["SIGTERM", "SIGKILL", "SIGINT", "SIGHUP", "SIGQUIT"] -SIGNAL_VALUES: dict[SignalName, int] = { +SIGNAL_VALUES: dict[SignalName, process_pb2.Signal] = { "SIGHUP": process_pb2.SIGNAL_SIGHUP, "SIGINT": process_pb2.SIGNAL_SIGINT, "SIGQUIT": process_pb2.SIGNAL_SIGQUIT, @@ -50,6 +50,9 @@ #: One chunk of command output. OutputChannel = Literal["stdout", "stderr", "pty"] +#: Per-file transfer cap enforced by the daemon (`filesystem.proto`). +MAX_FILE_BYTES = 256 * 1024 * 1024 + @dataclass(frozen=True) class OutputChunk: @@ -129,20 +132,20 @@ class SandboxSummary: storage_bytes: int = 0 -_STATE_NAMES: dict[int, SandboxState] = { - sandbox_pb2.SANDBOX_STATE_STARTING: "starting", - sandbox_pb2.SANDBOX_STATE_READY: "ready", - sandbox_pb2.SANDBOX_STATE_RUNNING: "running", - sandbox_pb2.SANDBOX_STATE_STOPPING: "stopping", - sandbox_pb2.SANDBOX_STATE_STOPPED: "stopped", - sandbox_pb2.SANDBOX_STATE_FAILED: "failed", - sandbox_pb2.SANDBOX_STATE_PAUSING: "pausing", - sandbox_pb2.SANDBOX_STATE_PAUSED: "paused", +_STATE_VALUES: dict[SandboxState, sandbox_pb2.SandboxState] = { + "unknown": sandbox_pb2.SANDBOX_STATE_UNSPECIFIED, + "starting": sandbox_pb2.SANDBOX_STATE_STARTING, + "ready": sandbox_pb2.SANDBOX_STATE_READY, + "running": sandbox_pb2.SANDBOX_STATE_RUNNING, + "stopping": sandbox_pb2.SANDBOX_STATE_STOPPING, + "stopped": sandbox_pb2.SANDBOX_STATE_STOPPED, + "failed": sandbox_pb2.SANDBOX_STATE_FAILED, + "pausing": sandbox_pb2.SANDBOX_STATE_PAUSING, + "paused": sandbox_pb2.SANDBOX_STATE_PAUSED, } -_STATE_VALUES: dict[SandboxState, int] = { - "unknown": sandbox_pb2.SANDBOX_STATE_UNSPECIFIED, - **{name: value for value, name in _STATE_NAMES.items()}, +_STATE_NAMES: dict[int, SandboxState] = { + value: name for name, value in _STATE_VALUES.items() if name != "unknown" } @@ -151,7 +154,7 @@ def sandbox_state_from_proto(state: int) -> SandboxState: return _STATE_NAMES.get(state, "unknown") -def sandbox_state_to_proto(state: SandboxState) -> int: +def sandbox_state_to_proto(state: SandboxState) -> sandbox_pb2.SandboxState: """Public state filter -> wire state.""" return _STATE_VALUES[state] From 4fc0ae83d5d3486ec24d1b10a7203ff9b63110a1 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:21:20 +0800 Subject: [PATCH 07/20] feat(sdk): generated sync tree and the public export surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/gen_sync.py runs unasync over arcbox/_async with the project's extra token map (Async* class prefixes, httpx flavors, contextlib counterparts), stamps a do-not-edit header, and normalizes the result with the project's own ruff so the committed tree passes the repo gates verbatim; --check regenerates into a scratch dir and fails on drift. Streaming in the sync tree is a genuine blocking iterator over httpx.Client.stream — no smuggled event loop. arcbox/__init__ exports both surfaces; pyright strict passes on the generated tree. --- sdk/python/scripts/gen_sync.py | 117 ++++++ sdk/python/src/arcbox/__init__.py | 54 ++- sdk/python/src/arcbox/_async/__init__.py | 7 +- sdk/python/src/arcbox/_sync/__init__.py | 6 + sdk/python/src/arcbox/_sync/_client.py | 213 +++++++++++ sdk/python/src/arcbox/_sync/commands.py | 276 ++++++++++++++ sdk/python/src/arcbox/_sync/files.py | 106 ++++++ sdk/python/src/arcbox/_sync/sandbox.py | 435 +++++++++++++++++++++++ 8 files changed, 1209 insertions(+), 5 deletions(-) create mode 100644 sdk/python/scripts/gen_sync.py create mode 100644 sdk/python/src/arcbox/_sync/__init__.py create mode 100644 sdk/python/src/arcbox/_sync/_client.py create mode 100644 sdk/python/src/arcbox/_sync/commands.py create mode 100644 sdk/python/src/arcbox/_sync/files.py create mode 100644 sdk/python/src/arcbox/_sync/sandbox.py diff --git a/sdk/python/scripts/gen_sync.py b/sdk/python/scripts/gen_sync.py new file mode 100644 index 000000000..4e806f5e5 --- /dev/null +++ b/sdk/python/scripts/gen_sync.py @@ -0,0 +1,117 @@ +"""Generate the sync tree (`arcbox/_sync`) from the async core. + +Mechanical unasync transform: `async def` -> `def`, `await` dropped, +httpx AsyncClient -> Client, the `Async*` public class names lose their +prefix. The sync tree is committed; CI reruns the transform and fails on +drift (`--check`), and the parity test asserts identical public +surfaces. + +Usage: + uv run python scripts/gen_sync.py # regenerate arcbox/_sync + uv run python scripts/gen_sync.py --check # verify, exit 1 on drift +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import unasync + +SDK_ROOT = Path(__file__).resolve().parent.parent +ASYNC_DIR = SDK_ROOT / "src" / "arcbox" / "_async" +SYNC_DIR = SDK_ROOT / "src" / "arcbox" / "_sync" + +HEADER = ( + "# Generated by scripts/gen_sync.py from arcbox/_async/{name} — do not edit.\n" +) + +# On top of unasync's defaults (async def/await/async for/async with, +# __aenter__/__aexit__/__aiter__/__anext__, AsyncIterator/AsyncIterable, +# StopAsyncIteration, ...). +EXTRA_REPLACEMENTS = { + # Public class names lose their Async prefix. + "AsyncArcBox": "ArcBox", + "AsyncSandbox": "Sandbox", + "AsyncCommands": "Commands", + "AsyncCommandHandle": "CommandHandle", + "AsyncFiles": "Files", + "AsyncConnectClient": "ConnectClient", + "AsyncServerStream": "ServerStream", + # httpx sync flavors. + "AsyncClient": "Client", + "AsyncHTTPTransport": "HTTPTransport", + "aiter_bytes": "iter_bytes", + "aread": "read", + "aclose": "close", + # typing / contextlib counterparts not in unasync's defaults. + "AbstractAsyncContextManager": "AbstractContextManager", + "aclosing": "closing", + "anext": "next", +} + + +def generate(target_dir: Path) -> None: + sources = sorted(str(p) for p in ASYNC_DIR.glob("*.py")) + rule = unasync.Rule( + fromdir=str(ASYNC_DIR) + "/", + todir=str(target_dir) + "/", + additional_replacements=EXTRA_REPLACEMENTS, + ) + target_dir.mkdir(parents=True, exist_ok=True) + unasync.unasync_files(sources, [rule]) + for generated in target_dir.glob("*.py"): + content = generated.read_text() + generated.write_text(HEADER.format(name=generated.name) + content) + # The token transform can unsort imports (AsyncIterator -> Iterator); + # the project's own ruff normalizes the result so the committed tree + # passes the repo gates verbatim. --config pins the project config + # even when generating into a scratch dir (the --check path). + config = str(SDK_ROOT / "pyproject.toml") + for args in (["check", "--fix", "--quiet"], ["format", "--quiet"]): + subprocess.run( + ["ruff", *args, "--config", config, "--no-cache", str(target_dir)], + check=True, + ) + + +def check() -> list[str]: + """Regenerate into a scratch dir and diff against the committed tree. + + Returns the offending relative paths (empty = in lockstep). + """ + drifted: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + fresh_dir = Path(tmp) / "_sync" + generate(fresh_dir) + fresh = {p.name: p.read_text() for p in fresh_dir.glob("*.py")} + committed = {p.name: p.read_text() for p in SYNC_DIR.glob("*.py")} if SYNC_DIR.is_dir() else {} + for name in sorted(fresh.keys() | committed.keys()): + if fresh.get(name) != committed.get(name): + drifted.append(f"src/arcbox/_sync/{name}") + return drifted + + +def main() -> int: + if "--check" in sys.argv[1:]: + drifted = check() + if drifted: + print("sync tree out of lockstep with arcbox/_async; regenerate with") + print(" uv run python scripts/gen_sync.py") + for path in drifted: + print(f" drifted: {path}") + return 1 + print("sync tree in lockstep") + return 0 + if SYNC_DIR.exists(): + shutil.rmtree(SYNC_DIR) + generate(SYNC_DIR) + print(f"regenerated {SYNC_DIR}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sdk/python/src/arcbox/__init__.py b/sdk/python/src/arcbox/__init__.py index f95675814..0a149ce2e 100644 --- a/sdk/python/src/arcbox/__init__.py +++ b/sdk/python/src/arcbox/__init__.py @@ -1,6 +1,54 @@ """arcbox — run isolated microVM sandboxes on the local ArcBox daemon. -Public surface only: everything under `arcbox._gen` is generated wire -code and is deliberately NOT exported — public shapes are hand-written -and mapped at the transport boundary. +Two mirrored surfaces: the sync classes (`Sandbox`, `ArcBox`, ...) and +their `Async*` counterparts. Public shapes are hand-written and mapped +at the transport boundary; everything under `arcbox._gen` is generated +wire code and is deliberately NOT exported. The error hierarchy lives +in :mod:`arcbox.errors`. """ + +from arcbox._async._client import AsyncConnectClient +from arcbox._async.commands import AsyncCommandHandle, AsyncCommands +from arcbox._async.files import AsyncFiles +from arcbox._async.sandbox import AsyncArcBox, AsyncSandbox +from arcbox._connection import Connection +from arcbox._sync._client import ConnectClient +from arcbox._sync.commands import CommandHandle, Commands +from arcbox._sync.files import Files +from arcbox._sync.sandbox import ArcBox, Sandbox +from arcbox._types import ( + MAX_FILE_BYTES, + CommandResult, + IdlePolicy, + OutputChannel, + OutputChunk, + SandboxInfo, + SandboxState, + SandboxSummary, + SignalName, +) + +__all__ = [ + "MAX_FILE_BYTES", + "ArcBox", + "AsyncArcBox", + "AsyncCommandHandle", + "AsyncCommands", + "AsyncConnectClient", + "AsyncFiles", + "AsyncSandbox", + "CommandHandle", + "CommandResult", + "Commands", + "ConnectClient", + "Connection", + "Files", + "IdlePolicy", + "OutputChannel", + "OutputChunk", + "Sandbox", + "SandboxInfo", + "SandboxState", + "SandboxSummary", + "SignalName", +] diff --git a/sdk/python/src/arcbox/_async/__init__.py b/sdk/python/src/arcbox/_async/__init__.py index 5294e9bd4..d79d26ee4 100644 --- a/sdk/python/src/arcbox/_async/__init__.py +++ b/sdk/python/src/arcbox/_async/__init__.py @@ -1,2 +1,5 @@ -"""Async core of the SDK. The sync tree under `arcbox._sync` is -generated from this package by scripts/gen_sync.py — edit here.""" +"""One flavor of the SDK surface. + +The async core lives in `arcbox/_async`; `arcbox/_sync` is generated +from it by scripts/gen_sync.py — edit only the async tree. +""" diff --git a/sdk/python/src/arcbox/_sync/__init__.py b/sdk/python/src/arcbox/_sync/__init__.py new file mode 100644 index 000000000..faa718ece --- /dev/null +++ b/sdk/python/src/arcbox/_sync/__init__.py @@ -0,0 +1,6 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/__init__.py — do not edit. +"""One flavor of the SDK surface. + +The async core lives in `arcbox/_async`; `arcbox/_sync` is generated +from it by scripts/gen_sync.py — edit only the async tree. +""" diff --git a/sdk/python/src/arcbox/_sync/_client.py b/sdk/python/src/arcbox/_sync/_client.py new file mode 100644 index 000000000..efe604183 --- /dev/null +++ b/sdk/python/src/arcbox/_sync/_client.py @@ -0,0 +1,213 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/_client.py — do not edit. +"""Connect-over-httpx transport. + +Unary RPCs are plain POSTs with binary-protobuf bodies +(``application/proto``); streaming RPCs speak the Connect streaming +envelope (``application/connect+proto``, framing in ``_envelope``). +The local tier dials the daemon's Unix socket through httpx's ``uds`` +transport; the same code path serves a future remote tier over HTTPS. +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Generic, TypeVar + +import httpx + +from arcbox._connection import Connection, resolve_connection +from arcbox._envelope import ( + FLAG_COMPRESSED, + FLAG_END_STREAM, + EnvelopeDecoder, + encode_envelope, + end_stream_error, + unary_error, +) +from arcbox.errors import ArcBoxError, InvalidArgumentError + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Mapping + from contextlib import AbstractContextManager + from types import TracebackType + + from google.protobuf.message import Message + +M = TypeVar("M", bound="Message") + +_UNARY_HEADERS = { + "content-type": "application/proto", + "connect-protocol-version": "1", +} +_STREAM_HEADERS = { + "content-type": "application/connect+proto", + "connect-protocol-version": "1", +} + + +class _UseDefault: + """Sentinel: apply the connection's ``request_timeout``.""" + + +_USE_DEFAULT = _UseDefault() + + +class ConnectClient: + """One resolved connection to a daemon; every handle shares it.""" + + def __init__( + self, + connection: Connection | None = None, + env: Mapping[str, str] | None = None, + ) -> None: + resolved = resolve_connection(connection, os.environ if env is None else env) + #: Per-unary-RPC deadline in seconds, when configured. Streams + #: and long-polls never receive it. + self.request_timeout = resolved.request_timeout + injected = connection.http_client if connection is not None else None + if injected is not None: + required = httpx.Client + if not isinstance(injected, required): + raise InvalidArgumentError( + f"Connection.http_client must be an httpx.{required.__name__} for this surface" + ) + self._http = injected + return + headers = {"authorization": f"Bearer {resolved.api_key}"} if resolved.api_key else None + transport = ( + httpx.HTTPTransport(uds=resolved.socket_path) + if resolved.socket_path is not None + else None + ) + self._http = httpx.Client( + base_url=resolved.base_url, + transport=transport, + headers=headers, + timeout=None, + ) + + def unary( + self, + path: str, + request: Message, + response_type: type[M], + timeout: float | _UseDefault | None = _USE_DEFAULT, + ) -> M: + """One unary RPC. ``timeout`` overrides the configured deadline; + an explicit ``None`` means no deadline (checkpoint restores take + as long as they take).""" + effective = self.request_timeout if isinstance(timeout, _UseDefault) else timeout + headers = dict(_UNARY_HEADERS) + if effective is not None: + headers["connect-timeout-ms"] = str(int(effective * 1000)) + response = self._http.post( + path, + content=request.SerializeToString(), + headers=headers, + timeout=None if effective is None else httpx.Timeout(effective), + ) + if response.status_code != 200: + raise unary_error(response.status_code, response.content) + content_type = response.headers.get("content-type", "") + if not content_type.startswith("application/proto"): + raise ArcBoxError(f"unexpected unary response content-type {content_type!r}") + message = response_type() + message.ParseFromString(response.content) + return message + + def stream(self, path: str, request: Message, response_type: type[M]) -> ServerStream[M]: + """One server-streaming RPC. Entering the returned context sends + the request (that is what registers a subscription server-side); + iterate it for messages.""" + return ServerStream(self._http, path, request, response_type) + + def client_stream(self, path: str, requests: Iterable[Message], response_type: type[M]) -> M: + """One client-streaming RPC with the full request sequence known + up front: the envelopes are sent as one body and the single + response message is returned.""" + body = b"".join(encode_envelope(0, m.SerializeToString()) for m in requests) + response = self._http.post(path, content=body, headers=_STREAM_HEADERS, timeout=None) + if response.status_code != 200: + raise unary_error(response.status_code, response.content) + decoder = EnvelopeDecoder() + message: M | None = None + for flags, payload in decoder.feed(response.content): + if flags & FLAG_COMPRESSED: + raise ArcBoxError("received a compressed frame without negotiating compression") + if flags & FLAG_END_STREAM: + error = end_stream_error(payload) + if error is not None: + raise error + else: + decoded = response_type() + decoded.ParseFromString(payload) + message = decoded + if message is None: + raise ArcBoxError("the stream ended without a response message") + return message + + +class ServerStream(Generic[M]): + """One server-streaming call: a context manager yielding an iterator. + + The stream ends deterministically — with the terminal + EndStreamResponse frame or a typed error, never silence. + """ + + def __init__( + self, + http: httpx.Client, + path: str, + request: Message, + response_type: type[M], + ) -> None: + self._http = http + self._path = path + self._body = encode_envelope(0, request.SerializeToString()) + self._response_type = response_type + self._cm: AbstractContextManager[httpx.Response] | None = None + self._response: httpx.Response | None = None + + def __enter__(self) -> ServerStream[M]: + cm = self._http.stream( + "POST", self._path, content=self._body, headers=_STREAM_HEADERS, timeout=None + ) + response = cm.__enter__() + if response.status_code != 200: + body = response.read() + cm.__exit__(None, None, None) + raise unary_error(response.status_code, body) + self._cm = cm + self._response = response + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + if self._cm is not None: + self._cm.__exit__(exc_type, exc, tb) + + def __iter__(self) -> Iterator[M]: + return self._messages() + + def _messages(self) -> Iterator[M]: + response = self._response + if response is None: + raise ArcBoxError("stream iterated before it was entered") + decoder = EnvelopeDecoder() + for data in response.iter_bytes(): + for flags, payload in decoder.feed(data): + if flags & FLAG_COMPRESSED: + raise ArcBoxError("received a compressed frame without negotiating compression") + if flags & FLAG_END_STREAM: + error = end_stream_error(payload) + if error is not None: + raise error + return + message = self._response_type() + message.ParseFromString(payload) + yield message + raise ArcBoxError("the stream ended without an EndStreamResponse") diff --git a/sdk/python/src/arcbox/_sync/commands.py b/sdk/python/src/arcbox/_sync/commands.py new file mode 100644 index 000000000..2234e375e --- /dev/null +++ b/sdk/python/src/arcbox/_sync/commands.py @@ -0,0 +1,276 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/commands.py — do not edit. +"""The ``sandbox.commands`` namespace: run processes inside one sandbox.""" + +from __future__ import annotations + +import math +import time +import uuid +from typing import TYPE_CHECKING, Literal, overload + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import process_pb2 +from arcbox._types import ( + SIGNAL_VALUES, + CommandResult, + OutputChunk, + command_result_from_execution, +) +from arcbox.errors import InvalidArgumentError, TimeoutError + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence + + from arcbox._types import OutputChannel, SignalName + + from ._client import ConnectClient, ServerStream + +_PROCESS = "/arcbox.sandbox.v1.SandboxProcessService/" + +#: Longest single WaitExecution long-poll slice, so a dropped daemon +#: surfaces as an error instead of an infinite silent wait. +_WAIT_SLICE_SECONDS = 30 + + +def _normalize_cmd(cmd: str | Sequence[str]) -> list[str]: + """``str`` is sugar for a shell command; a sequence is argv, executed + directly with no shell involved.""" + return ["/bin/sh", "-lc", cmd] if isinstance(cmd, str) else list(cmd) + + +def _channel_name(channel: int) -> OutputChannel: + if channel == process_pb2.STDIO_CHANNEL_STDERR: + return "stderr" + if channel == process_pb2.STDIO_CHANNEL_PTY: + return "pty" + return "stdout" + + +def _decode_output(chunks: list[bytes]) -> str: + return b"".join(chunks).decode("utf-8", errors="replace") + + +class CommandHandle: + """A handle to a running (or finished) command. The process is + decoupled from this object: dropping the handle never kills the + process.""" + + def __init__(self, client: ConnectClient, sandbox_id: str, command_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + #: Execution id, unique within the sandbox; addressable across clients. + self.command_id = command_id + + @property + def output(self) -> Iterator[OutputChunk]: + """Stream the command's output from the beginning — or from the + earliest byte the daemon still retains (8 MiB per channel); + replayed buffered output comes first, then live output follows; + the stream ends when the process exits (deterministic + termination — never silence).""" + return self._stream_output() + + def _stream_output(self) -> Iterator[OutputChunk]: + with wrap_errors("commands.output"), self._attach() as stream: + for event in stream: + kind = event.WhichOneof("event") + if kind == "output": + chunk = event.output + yield OutputChunk(_channel_name(chunk.channel), chunk.data) + elif kind == "exited": + return + + def wait_for_exit(self, timeout: float | None = None) -> CommandResult: + """Wait until the command exits and return its result (server-side + long-poll; no client-side spinning). ``timeout`` bounds the WAIT, + not the process: on expiry a :class:`TimeoutError` is raised and + the process keeps running.""" + deadline = None if timeout is None else time.monotonic() + timeout + with wrap_errors("commands.wait_for_exit"): + while True: + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + raise TimeoutError( + "wait_for_exit(timeout) elapsed before the command exited", + suggestion=( + "increase the wait_for_exit timeout argument, or kill() the command" + ), + context={"command_id": self.command_id}, + ) + slice_seconds = ( + _WAIT_SLICE_SECONDS + if remaining is None + else min(_WAIT_SLICE_SECONDS, max(1, math.ceil(remaining))) + ) + execution = self._client.unary( + _PROCESS + "WaitExecution", + process_pb2.WaitExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + timeout_seconds=slice_seconds, + ), + process_pb2.Execution, + # Exempt from request_timeout: this unary deliberately + # parks server-side for the slice; grant it that long + # plus grace. + timeout=float(slice_seconds + 5), + ) + if execution.state == process_pb2.EXECUTION_STATE_EXITED: + break + return self._collect_result(execution) + + def kill(self, signal: SignalName = "SIGTERM") -> None: + """Deliver a signal to the whole process group (default SIGTERM).""" + with wrap_errors("commands.kill"): + self._client.unary( + _PROCESS + "SignalExecution", + process_pb2.SignalExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + signal=SIGNAL_VALUES[signal], + ), + empty_pb2.Empty, + ) + + def _attach(self) -> ServerStream[process_pb2.ExecutionEvent]: + return self._client.stream( + _PROCESS + "AttachExecution", + process_pb2.AttachExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + stdout_offset=0, + stderr_offset=0, + ), + process_pb2.ExecutionEvent, + ) + + def _collect_result(self, execution: process_pb2.Execution) -> CommandResult: + """Assemble the result of an exited execution. Output is re-read + from offset 0 — the daemon retains and replays it, so the result + is complete even when nobody consumed the live stream, UNLESS the + command outgrew the daemon's per-channel retention (8 MiB): chunk + offsets expose the dropped head, reported as ``truncated``.""" + stdout: list[bytes] = [] + stderr: list[bytes] = [] + next_stdout = 0 + next_stderr = 0 + truncated = False + with self._attach() as stream: + for event in stream: + kind = event.WhichOneof("event") + if kind == "output": + chunk = event.output + is_stderr = chunk.channel == process_pb2.STDIO_CHANNEL_STDERR + # A chunk landing past the expected offset means + # retention already dropped bytes we asked for. + if chunk.offset > (next_stderr if is_stderr else next_stdout): + truncated = True + after = chunk.offset + len(chunk.data) + if is_stderr: + next_stderr = after + stderr.append(chunk.data) + else: + next_stdout = after + stdout.append(chunk.data) + elif kind == "exited": + break + return command_result_from_execution( + execution, _decode_output(stdout), _decode_output(stderr), truncated + ) + + +class Commands: + """Run processes inside one sandbox.""" + + def __init__(self, client: ConnectClient, sandbox_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + + @overload + def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + check: bool = False, + background: Literal[False] = False, + ) -> CommandResult: ... + + @overload + def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + background: Literal[True], + ) -> CommandHandle: ... + + def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + user: str | None = None, + timeout: float | None = None, + check: bool = False, + background: bool = False, + ) -> CommandResult | CommandHandle: + """Run a command. Foreground (default): returns the complete + :class:`CommandResult` once the process exits — non-zero exit is + data unless ``check=True`` (`subprocess.run` semantics). + Background (``background=True``): returns as soon as the process + is started, with a handle for streaming and waiting. + + ``timeout`` kills the whole process group after that many + seconds; expiry surfaces as signal death in the result + (exit-as-data), not as a raised error.""" + if background and check: + raise InvalidArgumentError( + "check=True applies to foreground runs; call " + ".wait_for_exit() and .expect() on the handle instead", + operation="commands.run", + ) + handle = self._start(cmd, cwd=cwd, env=env, user=user, timeout=timeout) + if background: + return handle + result = handle.wait_for_exit() + return result.expect() if check else result + + def _start( + self, + cmd: str | Sequence[str], + *, + cwd: str | None, + env: Mapping[str, str] | None, + user: str | None, + timeout: float | None, + ) -> CommandHandle: + with wrap_errors("commands.run"): + # The execution id is minted client-side: a lost response + # leaves an addressable execution, and retries are idempotent + # by contract. + execution_id = str(uuid.uuid4()) + execution = self._client.unary( + _PROCESS + "StartExecution", + process_pb2.StartExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=execution_id, + cmd=_normalize_cmd(cmd), + env=dict(env) if env else {}, + working_dir=cwd or "", + user=user or "", + timeout_seconds=0 if timeout is None else math.ceil(timeout), + stdin=False, + ), + process_pb2.Execution, + ) + return CommandHandle(self._client, self._sandbox_id, execution.id) diff --git a/sdk/python/src/arcbox/_sync/files.py b/sdk/python/src/arcbox/_sync/files.py new file mode 100644 index 000000000..f069fe986 --- /dev/null +++ b/sdk/python/src/arcbox/_sync/files.py @@ -0,0 +1,106 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/files.py — do not edit. +"""The ``sandbox.files`` namespace: move bytes in and out of one sandbox. + +Bytes-first — text variants are explicit UTF-8 conveniences, never a +silent default. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import filesystem_pb2 +from arcbox._types import MAX_FILE_BYTES +from arcbox.errors import FileTooLargeError + +if TYPE_CHECKING: + from ._client import ConnectClient + +_FILESYSTEM = "/arcbox.sandbox.v1.SandboxFilesystemService/" + +#: Chunk size for streamed writes. +_WRITE_CHUNK_BYTES = 256 * 1024 + +#: Default permission bits for created files (mirrors the daemon's default). +_DEFAULT_WRITE_MODE = 0o644 + + +class Files: + """Move bytes in and out of one sandbox.""" + + def __init__(self, client: ConnectClient, sandbox_id: str) -> None: + self._client = client + self._sandbox_id = sandbox_id + + def read_bytes(self, path: str) -> bytes: + """Read a file as raw bytes.""" + with wrap_errors("files.read_bytes"): + chunks: list[bytes] = [] + with self._client.stream( + _FILESYSTEM + "ReadFile", + filesystem_pb2.ReadFileRequest(id=self._sandbox_id, path=path), + filesystem_pb2.FileChunk, + ) as stream: + for chunk in stream: + if chunk.data: + chunks.append(chunk.data) + if chunk.done: + break + return b"".join(chunks) + + def read_text(self, path: str, encoding: str = "utf-8") -> str: + """Read a file and decode it (UTF-8 by default).""" + return (self.read_bytes(path)).decode(encoding) + + def write_bytes(self, path: str, data: bytes, mode: int | None = None) -> None: + """Write raw bytes to a file, creating or truncating it. + + ``mode`` is the Unix permission bits for the created file + (default 0o644). The wire protocol reserves 0 as "use the + default" (`filesystem.proto`), so a literal mode of 0 is not + expressible — it also yields 0o644. + """ + if len(data) > MAX_FILE_BYTES: + raise FileTooLargeError( + f"file of {len(data)} bytes exceeds the {MAX_FILE_BYTES}-byte per-file cap", + operation="files.write_bytes", + context={ + "path": path, + "limit": str(MAX_FILE_BYTES), + "size": str(len(data)), + }, + ) + requests = [ + filesystem_pb2.WriteFileRequest( + open=filesystem_pb2.WriteFileOpen( + id=self._sandbox_id, + path=path, + mode=_DEFAULT_WRITE_MODE if mode is None else mode, + ) + ) + ] + # Always send at least one chunk so `done` is observed, even for + # an empty file. + offset = 0 + while True: + end = min(offset + _WRITE_CHUNK_BYTES, len(data)) + done = end == len(data) + requests.append( + filesystem_pb2.WriteFileRequest( + chunk=filesystem_pb2.FileChunk(data=data[offset:end], done=done) + ) + ) + if done: + break + offset = end + with wrap_errors("files.write_bytes"): + self._client.client_stream(_FILESYSTEM + "WriteFile", requests, empty_pb2.Empty) + + def write_text( + self, path: str, text: str, encoding: str = "utf-8", mode: int | None = None + ) -> None: + """Write text to a file (UTF-8 by default).""" + self.write_bytes(path, text.encode(encoding), mode) diff --git a/sdk/python/src/arcbox/_sync/sandbox.py b/sdk/python/src/arcbox/_sync/sandbox.py new file mode 100644 index 000000000..7d6a15857 --- /dev/null +++ b/sdk/python/src/arcbox/_sync/sandbox.py @@ -0,0 +1,435 @@ +# Generated by scripts/gen_sync.py from arcbox/_async/sandbox.py — do not edit. +"""Sandbox handles and the client entry point. + +A handle holds only the sandbox id and the connection — state is never +cached; ``info()`` always fetches fresh. +""" + +from __future__ import annotations + +import math +import uuid +from contextlib import suppress +from typing import TYPE_CHECKING + +from google.protobuf import empty_pb2 + +from arcbox._boundary import wrap_errors +from arcbox._gen import sandbox_pb2 +from arcbox._types import ( + SandboxInfo, + SandboxSummary, + sandbox_info_from_proto, + sandbox_state_from_proto, + sandbox_state_to_proto, + sandbox_summary_from_proto, +) +from arcbox.errors import ArcBoxError, NotFoundError, SandboxStateError + +from ._client import ConnectClient +from .commands import Commands +from .files import Files + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence + from types import TracebackType + + from arcbox._connection import Connection + from arcbox._types import IdlePolicy, SandboxState + + from ._client import ServerStream + +_SANDBOX = "/arcbox.sandbox.v1.SandboxService/" + +_READY_STATES = (sandbox_pb2.SANDBOX_STATE_READY, sandbox_pb2.SANDBOX_STATE_RUNNING) +_GONE_STATES = (sandbox_pb2.SANDBOX_STATE_STOPPING, sandbox_pb2.SANDBOX_STATE_STOPPED) +_READY_EVENTS = ( + sandbox_pb2.SANDBOX_EVENT_KIND_READY, + sandbox_pb2.SANDBOX_EVENT_KIND_RUNNING, + sandbox_pb2.SANDBOX_EVENT_KIND_IDLE, +) +_GONE_EVENTS = ( + sandbox_pb2.SANDBOX_EVENT_KIND_STOPPING, + sandbox_pb2.SANDBOX_EVENT_KIND_STOPPED, + sandbox_pb2.SANDBOX_EVENT_KIND_REMOVED, +) + + +def _seconds_to_wire(seconds: float | None) -> int: + return 0 if seconds is None else math.ceil(seconds) + + +class ArcBox: + """Client entry point. Holds one resolved connection; every handle it + creates shares it. The :class:`AsyncSandbox` classmethods are sugar + over a throwaway instance resolved from options/environment.""" + + def __init__(self, connection: Connection | None = None) -> None: + self._client = ConnectClient(connection) + + def create( + self, + template: str = "", + *, + ttl: float | None = None, + idle_timeout: float | None = None, + on_idle: IdlePolicy | None = None, + vcpus: int | None = None, + memory_mib: int | None = None, + cmd: Sequence[str] | None = None, + env: Mapping[str, str] | None = None, + labels: Mapping[str, str] | None = None, + network: bool | None = None, + wait_until_ready: bool = True, + ) -> Sandbox: + """Create a sandbox and (by default) wait until it is READY. + + ``ttl`` is the hard maximum lifetime — on expiry the daemon + always destroys the sandbox. ``idle_timeout`` + ``on_idle`` is + the independent idle-reaping knob; never conflate the two. + + The sandbox id is minted client-side so the readiness + subscription can be armed BEFORE the create call — + subscribe-then-act, so no transition is missed (the CORE-67 + rule) — and so retries stay idempotent. + """ + with wrap_errors("sandbox.create"): + sandbox_id = str(uuid.uuid4()) + try: + if wait_until_ready: + with self._client.stream( + _SANDBOX + "Events", + sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), + sandbox_pb2.WatchEventsResponse, + ) as events: + self._create( + sandbox_id, + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + ) + self._wait_ready(sandbox_id, events) + else: + self._create( + sandbox_id, + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + ) + return Sandbox(self._client, sandbox_id) + except BaseException: + # The sandbox may exist even though create() failed + # (readiness failed, response lost) and ttl is optional, + # so a leaked VM could run forever. Best-effort removal; a + # failure here (e.g. nothing was created) must not mask + # the original error. + with suppress(Exception): + self._client.unary( + _SANDBOX + "Remove", + sandbox_pb2.RemoveSandboxRequest(id=sandbox_id, force=True), + empty_pb2.Empty, + ) + raise + + def connect(self, sandbox_id: str) -> Sandbox: + """Attach to an existing sandbox. A PAUSED sandbox is resumed + (resume completes once it is READY again); a STARTING one is + waited for. A terminal state is a typed error carrying the + observed state. Connecting never touches the sandbox's lifecycle + deadlines.""" + with wrap_errors("sandbox.connect"): + info = self._inspect(sandbox_id) + if info.state in _READY_STATES: + return Sandbox(self._client, sandbox_id) + if info.state == sandbox_pb2.SANDBOX_STATE_PAUSED: + # Deliberately no per-request deadline: restoring a + # checkpoint takes as long as it takes, and the RPC + # returns once READY. + self._client.unary( + _SANDBOX + "Resume", + sandbox_pb2.ResumeSandboxRequest(id=sandbox_id), + empty_pb2.Empty, + timeout=None, + ) + return Sandbox(self._client, sandbox_id) + if info.state in ( + sandbox_pb2.SANDBOX_STATE_STARTING, + sandbox_pb2.SANDBOX_STATE_PAUSING, + ): + with self._client.stream( + _SANDBOX + "Events", + sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), + sandbox_pb2.WatchEventsResponse, + ) as events: + self._wait_ready(sandbox_id, events) + return Sandbox(self._client, sandbox_id) + state = sandbox_state_from_proto(info.state) + raise SandboxStateError( + f"sandbox {sandbox_id} is {state} and cannot be connected to", + context={"id": sandbox_id, "state": state, "error": info.error}, + ) + + def list( + self, + *, + state: SandboxState | None = None, + labels: Mapping[str, str] | None = None, + ) -> Iterator[SandboxSummary]: + """List sandboxes, auto-paginating server-side pages.""" + with wrap_errors("sandbox.list"): + page_token = "" + while True: + page = self._client.unary( + _SANDBOX + "List", + sandbox_pb2.ListSandboxesRequest( + state=( + sandbox_pb2.SANDBOX_STATE_UNSPECIFIED + if state is None + else sandbox_state_to_proto(state) + ), + labels=dict(labels) if labels else {}, + page_token=page_token, + ), + sandbox_pb2.ListSandboxesResponse, + ) + for row in page.sandboxes: + yield sandbox_summary_from_proto(row) + page_token = page.next_page_token + if page_token == "": + return + + def _inspect(self, sandbox_id: str) -> sandbox_pb2.SandboxInfo: + return self._client.unary( + _SANDBOX + "Inspect", + sandbox_pb2.InspectSandboxRequest(id=sandbox_id), + sandbox_pb2.SandboxInfo, + ) + + def _create( + self, + sandbox_id: str, + template: str, + *, + ttl: float | None, + idle_timeout: float | None, + on_idle: IdlePolicy | None, + vcpus: int | None, + memory_mib: int | None, + cmd: Sequence[str] | None, + env: Mapping[str, str] | None, + labels: Mapping[str, str] | None, + network: bool | None, + ) -> None: + request = sandbox_pb2.CreateSandboxRequest( + id=sandbox_id, + template=template, + labels=dict(labels) if labels else {}, + cmd=list(cmd) if cmd else [], + env=dict(env) if env else {}, + ttl_seconds=_seconds_to_wire(ttl), + idle_timeout_seconds=_seconds_to_wire(idle_timeout), + on_idle=( + sandbox_pb2.IDLE_ACTION_UNSPECIFIED + if on_idle is None + else sandbox_pb2.IDLE_ACTION_KILL + if on_idle == "kill" + else sandbox_pb2.IDLE_ACTION_PAUSE + ), + ) + if vcpus is not None or memory_mib is not None: + # Setting either sends `limits`, replacing template defaults + # WHOLESALE (a zero subfield means the daemon default). + request.limits.vcpus = vcpus or 0 + request.limits.memory_mib = memory_mib or 0 + if network is not None: + request.network.mode = ( + sandbox_pb2.NETWORK_MODE_ENABLED if network else sandbox_pb2.NETWORK_MODE_NONE + ) + self._client.unary(_SANDBOX + "Create", request, sandbox_pb2.CreateSandboxResponse) + + def _wait_ready( + self, + sandbox_id: str, + events: ServerStream[sandbox_pb2.WatchEventsResponse], + ) -> None: + """Consume lifecycle events until READY/RUNNING, or fail on a + terminal transition. The subscription was armed before Create, so + nothing can be missed; the one residual window — Create processed + before the subscription registered server-side — is covered by an + immediate Inspect and by re-inspecting on every keepalive frame.""" + + def check(state: int, error: str) -> bool: + if state in _READY_STATES: + return True + if state == sandbox_pb2.SANDBOX_STATE_FAILED: + raise SandboxStateError( + f"sandbox {sandbox_id} failed to start: {error}", + context={"id": sandbox_id, "state": "failed", "error": error}, + ) + if state in _GONE_STATES: + raise SandboxStateError( + f"sandbox {sandbox_id} stopped before becoming ready", + context={"id": sandbox_id, "state": "stopped"}, + ) + return False + + info = self._inspect(sandbox_id) + if check(info.state, info.error): + return + for frame in events: + payload = frame.WhichOneof("payload") + if payload == "event": + event = frame.event + if event.kind in _READY_EVENTS: + return + if event.kind == sandbox_pb2.SANDBOX_EVENT_KIND_FAILED: + error = event.attributes.get("error", "") + raise SandboxStateError( + f"sandbox {sandbox_id} failed to start: {error}", + context={"id": sandbox_id, "state": "failed", "error": error}, + ) + if event.kind in _GONE_EVENTS: + raise SandboxStateError( + f"sandbox {sandbox_id} stopped before becoming ready", + context={"id": sandbox_id, "state": "stopped"}, + ) + elif payload == "keep_alive": + fresh = self._inspect(sandbox_id) + if check(fresh.state, fresh.error): + return + raise ArcBoxError( + f"the event stream ended before sandbox {sandbox_id} became ready", + context={"id": sandbox_id}, + ) + + +class Sandbox: + """A handle to one sandbox.""" + + def __init__(self, client: ConnectClient, sandbox_id: str) -> None: + self._client = client + #: Sandbox id. + self.id = sandbox_id + #: Run processes inside the sandbox. + self.commands = Commands(client, sandbox_id) + #: Move bytes in and out of the sandbox. + self.files = Files(client, sandbox_id) + + @classmethod + def create( + cls, + template: str = "", + *, + ttl: float | None = None, + idle_timeout: float | None = None, + on_idle: IdlePolicy | None = None, + vcpus: int | None = None, + memory_mib: int | None = None, + cmd: Sequence[str] | None = None, + env: Mapping[str, str] | None = None, + labels: Mapping[str, str] | None = None, + network: bool | None = None, + wait_until_ready: bool = True, + connection: Connection | None = None, + ) -> Sandbox: + """Create a sandbox against the default (or given) connection.""" + return ArcBox(connection).create( + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + wait_until_ready=wait_until_ready, + ) + + @classmethod + def connect(cls, sandbox_id: str, *, connection: Connection | None = None) -> Sandbox: + """Attach to an existing sandbox by id.""" + return ArcBox(connection).connect(sandbox_id) + + @classmethod + def list( + cls, + *, + state: SandboxState | None = None, + labels: Mapping[str, str] | None = None, + connection: Connection | None = None, + ) -> Iterator[SandboxSummary]: + """List sandboxes (auto-paginating).""" + return ArcBox(connection).list(state=state, labels=labels) + + def info(self) -> SandboxInfo: + """Fetch the sandbox's current state — always fresh, never cached.""" + with wrap_errors("sandbox.info"): + return sandbox_info_from_proto( + self._client.unary( + _SANDBOX + "Inspect", + sandbox_pb2.InspectSandboxRequest(id=self.id), + sandbox_pb2.SandboxInfo, + ) + ) + + def kill(self) -> None: + """Destroy the sandbox and release all its resources immediately.""" + with wrap_errors("sandbox.kill"): + self._client.unary( + _SANDBOX + "Remove", + sandbox_pb2.RemoveSandboxRequest(id=self.id, force=True), + empty_pb2.Empty, + ) + + def pause(self) -> None: + """Checkpoint the sandbox to disk under the same id and release + its runtime resources. Resume happens on the next ``connect`` + (or transparently, daemon-side, on the next data-plane call). + Trades RAM for disk: a paused sandbox keeps paying + ``storage_bytes``. + + Requires daemon-side CORE-21: the current local daemon serves + Pause/Resume as contract-only stubs, so this raises an + unimplemented :class:`ArcBoxError` until that lands.""" + with wrap_errors("sandbox.pause"): + # No per-request deadline: checkpointing takes as long as it + # takes. + self._client.unary( + _SANDBOX + "Pause", + sandbox_pb2.PauseSandboxRequest(id=self.id), + empty_pb2.Empty, + timeout=None, + ) + + def __enter__(self) -> Sandbox: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + # Scope-exit disposal kills the sandbox so a leaked handle never + # leaks a VM. Swallows only "already gone" — the whole + # NotFoundError family, because the daemon does not attach + # ErrorInfo details yet and a coarse NotFound on this id can only + # mean the sandbox. + with suppress(NotFoundError): + self.kill() From 3f68755a8d842a1aa8fe23377153bd58c3b81cf2 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:23:05 +0800 Subject: [PATCH 08/20] test(sdk): connection resolution, error boundary, envelope framing Mirrors the TS SDK's unit suites: env-resolution precedence incl. the ARCBOX_PROFILE daemon parity cases; registry ErrorInfo details (padded and unpadded base64), unknown-code preservation, coarse Connect-code routing, HTTP-status fallback; wrap_errors mapping for missing-socket and deadline expiry; envelope roundtrip incl. byte-by-byte feeds, the frame sanity cap, and EndStreamResponse decoding. --- sdk/python/tests/conftest.py | 8 ++ sdk/python/tests/test_connection.py | 83 ++++++++++++++++ sdk/python/tests/test_envelope.py | 83 ++++++++++++++++ sdk/python/tests/test_errors.py | 145 ++++++++++++++++++++++++++++ 4 files changed, 319 insertions(+) create mode 100644 sdk/python/tests/conftest.py create mode 100644 sdk/python/tests/test_connection.py create mode 100644 sdk/python/tests/test_envelope.py create mode 100644 sdk/python/tests/test_errors.py diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py new file mode 100644 index 000000000..4d96d3e22 --- /dev/null +++ b/sdk/python/tests/conftest.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" diff --git a/sdk/python/tests/test_connection.py b/sdk/python/tests/test_connection.py new file mode 100644 index 000000000..24b50995e --- /dev/null +++ b/sdk/python/tests/test_connection.py @@ -0,0 +1,83 @@ +"""Connection resolution: explicit option > environment > default. + +Mirrors the TypeScript SDK's connection tests case for case. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from arcbox._connection import UDS_BASE_URL, Connection, resolve_connection +from arcbox.errors import InvalidArgumentError + + +def test_defaults_to_home_arcbox_socket() -> None: + conn = resolve_connection(None, {}) + assert conn.base_url == UDS_BASE_URL + assert conn.socket_path == str(Path.home() / ".arcbox" / "run" / "arcbox.sock") + assert conn.api_key is None + + +def test_data_dir_relocates_the_default_socket() -> None: + conn = resolve_connection(None, {"ARCBOX_DATA_DIR": "/tmp/abx"}) + assert conn.socket_path == "/tmp/abx/run/arcbox.sock" + + +@pytest.mark.parametrize("profile", ["development", "dev", " Development "]) +def test_profile_selects_arcbox_dev_like_the_daemon(profile: str) -> None: + conn = resolve_connection(None, {"ARCBOX_PROFILE": profile}) + assert conn.socket_path == str(Path.home() / ".arcbox-dev" / "run" / "arcbox.sock") + + +def test_unknown_profiles_fall_back_to_production() -> None: + conn = resolve_connection(None, {"ARCBOX_PROFILE": "staging"}) + assert conn.socket_path == str(Path.home() / ".arcbox" / "run" / "arcbox.sock") + + +def test_non_empty_data_dir_beats_the_profile_and_empty_is_unset() -> None: + beats = resolve_connection(None, {"ARCBOX_DATA_DIR": "/tmp/abx", "ARCBOX_PROFILE": "dev"}) + assert beats.socket_path == "/tmp/abx/run/arcbox.sock" + empty = resolve_connection(None, {"ARCBOX_DATA_DIR": "", "ARCBOX_PROFILE": "dev"}) + assert empty.socket_path == str(Path.home() / ".arcbox-dev" / "run" / "arcbox.sock") + + +def test_socket_env_overrides_the_data_dir_default() -> None: + conn = resolve_connection(None, {"ARCBOX_DATA_DIR": "/tmp/abx", "ARCBOX_SOCKET": "/tmp/x.sock"}) + assert conn.socket_path == "/tmp/x.sock" + + +def test_explicit_socket_path_beats_every_environment_variable() -> None: + conn = resolve_connection( + Connection(socket_path="/opt/d.sock"), + {"ARCBOX_SOCKET": "/tmp/x.sock", "ARCBOX_API_URL": "https://cloud.example"}, + ) + assert conn.socket_path == "/opt/d.sock" + assert conn.base_url == UDS_BASE_URL + + +def test_api_url_env_selects_the_remote_tier_over_the_socket() -> None: + conn = resolve_connection( + None, {"ARCBOX_API_URL": "https://cloud.example", "ARCBOX_SOCKET": "/tmp/x.sock"} + ) + assert conn.base_url == "https://cloud.example" + assert conn.socket_path is None + + +def test_explicit_api_url_selects_the_remote_tier() -> None: + conn = resolve_connection(Connection(api_url="https://cloud.example"), {}) + assert conn.base_url == "https://cloud.example" + assert conn.socket_path is None + + +def test_rejects_socket_path_together_with_api_url() -> None: + with pytest.raises(InvalidArgumentError): + resolve_connection( + Connection(socket_path="/tmp/x.sock", api_url="https://cloud.example"), {} + ) + + +def test_resolves_the_api_key_from_option_over_environment() -> None: + assert resolve_connection(Connection(api_key="opt"), {"ARCBOX_API_KEY": "env"}).api_key == "opt" + assert resolve_connection(None, {"ARCBOX_API_KEY": "env"}).api_key == "env" diff --git a/sdk/python/tests/test_envelope.py b/sdk/python/tests/test_envelope.py new file mode 100644 index 000000000..0d2f6de04 --- /dev/null +++ b/sdk/python/tests/test_envelope.py @@ -0,0 +1,83 @@ +"""Connect streaming envelope framing.""" + +from __future__ import annotations + +import json +import struct + +import pytest + +from arcbox._envelope import ( + FLAG_END_STREAM, + MAX_ENVELOPE_BYTES, + EnvelopeDecoder, + encode_envelope, + end_stream_error, +) +from arcbox.errors import ArcBoxError, SandboxNotFoundError + + +def test_roundtrips_one_envelope() -> None: + encoded = encode_envelope(0, b"abc") + assert encoded == b"\x00\x00\x00\x00\x03abc" + assert EnvelopeDecoder().feed(encoded) == [(0, b"abc")] + + +def test_decodes_frames_fed_byte_by_byte() -> None: + encoded = encode_envelope(0, b"hello") + encode_envelope(FLAG_END_STREAM, b"{}") + decoder = EnvelopeDecoder() + frames: list[tuple[int, bytes]] = [] + for i in range(len(encoded)): + frames.extend(decoder.feed(encoded[i : i + 1])) + assert frames == [(0, b"hello"), (FLAG_END_STREAM, b"{}")] + + +def test_decodes_multiple_frames_from_one_feed() -> None: + encoded = encode_envelope(0, b"a") + encode_envelope(0, b"b") + encode_envelope(2, b"{}") + assert EnvelopeDecoder().feed(encoded) == [(0, b"a"), (0, b"b"), (2, b"{}")] + + +def test_rejects_an_envelope_past_the_sanity_cap() -> None: + header = struct.pack(">BI", 0, MAX_ENVELOPE_BYTES + 1) + with pytest.raises(ArcBoxError, match="sanity cap"): + EnvelopeDecoder().feed(header) + + +def test_end_stream_success_is_none() -> None: + assert end_stream_error(b"{}") is None + assert end_stream_error(json.dumps({"metadata": {}}).encode()) is None + + +def test_end_stream_error_maps_through_the_registry() -> None: + payload = json.dumps({"error": {"code": "not_found", "message": "gone"}}).encode() + err = end_stream_error(payload, operation="files.read_bytes") + assert err is not None + assert err.operation == "files.read_bytes" + + +def test_end_stream_garbage_is_a_typed_error_not_a_crash() -> None: + err = end_stream_error(b"\xff\xfe") + assert isinstance(err, ArcBoxError) + + +def test_end_stream_error_carries_error_info_details() -> None: + import base64 + + from arcbox._gen import errors_pb2 + + info = errors_pb2.ErrorInfo(code=errors_pb2.ERROR_CODE_SANDBOX_NOT_FOUND) + payload = json.dumps( + { + "error": { + "code": "not_found", + "message": "gone", + "details": [ + { + "type": "arcbox.sandbox.v1.ErrorInfo", + "value": base64.b64encode(info.SerializeToString()).decode(), + } + ], + } + } + ).encode() + assert isinstance(end_stream_error(payload), SandboxNotFoundError) diff --git a/sdk/python/tests/test_errors.py b/sdk/python/tests/test_errors.py new file mode 100644 index 000000000..464c26e18 --- /dev/null +++ b/sdk/python/tests/test_errors.py @@ -0,0 +1,145 @@ +"""The single transport→exception boundary, mirrored from the TS tests.""" + +from __future__ import annotations + +import base64 +import json +from typing import cast + +import httpx +import pytest + +from arcbox._boundary import wrap_errors +from arcbox._envelope import unary_error +from arcbox._gen import errors_pb2 +from arcbox.errors import ( + ArcBoxError, + CapabilityError, + CommandTimeoutError, + ConnectionFailedError, + FileNotFoundError, + NotFoundError, + RequestTimeoutError, + SandboxNotFoundError, + SandboxStateError, +) + + +def daemon_error_body( + code: int, + suggestion: str = "", + context: dict[str, str] | None = None, + connect_code: str = "failed_precondition", + pad_base64: bool = True, +) -> bytes: + # cast: proto3 enums are open — the unknown-code test sends 999. + info = errors_pb2.ErrorInfo( + code=cast("errors_pb2.ErrorCode", code), suggestion=suggestion, context=context or {} + ) + value = base64.b64encode(info.SerializeToString()).decode() + if not pad_base64: + value = value.rstrip("=") + return json.dumps( + { + "code": connect_code, + "message": "boom", + "details": [{"type": "arcbox.sandbox.v1.ErrorInfo", "value": value}], + } + ).encode() + + +def test_maps_error_info_details_to_the_registry_class() -> None: + err = unary_error( + 400, + daemon_error_body( + errors_pb2.ERROR_CODE_SANDBOX_NOT_FOUND, + suggestion="list sandboxes with `abctl sandbox list`", + context={"id": "sb-1"}, + ), + operation="sandbox.info", + ) + assert isinstance(err, SandboxNotFoundError) + assert err.code == "SANDBOX_NOT_FOUND" + assert err.suggestion == "list sandboxes with `abctl sandbox list`" + assert err.context == {"id": "sb-1"} + assert err.operation == "sandbox.info" + + +@pytest.mark.parametrize( + ("code", "cls"), + [ + (errors_pb2.ERROR_CODE_SANDBOX_PAUSED, SandboxStateError), + (errors_pb2.ERROR_CODE_NESTED_VIRT_UNSUPPORTED, CapabilityError), + (errors_pb2.ERROR_CODE_COMMAND_TIMEOUT, CommandTimeoutError), + (errors_pb2.ERROR_CODE_FILE_NOT_FOUND, FileNotFoundError), + ], +) +def test_maps_state_capability_and_timeout_registry_codes( + code: int, cls: type[ArcBoxError] +) -> None: + assert isinstance(unary_error(400, daemon_error_body(code)), cls) + + +def test_accepts_unpadded_base64_details() -> None: + err = unary_error( + 400, + daemon_error_body(errors_pb2.ERROR_CODE_SANDBOX_NOT_FOUND, pad_base64=False), + ) + assert isinstance(err, SandboxNotFoundError) + + +def test_keeps_unknown_registry_codes_on_the_base_class() -> None: + err = unary_error(400, daemon_error_body(999)) + assert type(err) is ArcBoxError + assert err.code == "ERROR_CODE_999" + + +def test_routes_on_the_connect_code_without_an_error_info_detail() -> None: + body = json.dumps({"code": "not_found", "message": "gone"}).encode() + err = unary_error(404, body, operation="op") + assert isinstance(err, NotFoundError) + assert err.code == "not_found" + assert err.operation == "op" + + deadline = unary_error(408, json.dumps({"code": "deadline_exceeded"}).encode()) + assert isinstance(deadline, RequestTimeoutError) + assert deadline.suggestion == "increase Connection.request_timeout" + + unavailable = unary_error(503, json.dumps({"code": "unavailable"}).encode()) + assert isinstance(unavailable, ConnectionFailedError) + + +def test_non_json_bodies_fall_back_to_the_http_status_table() -> None: + err = unary_error(503, b"bad gateway") + assert isinstance(err, ConnectionFailedError) + assert err.code == "unavailable" + unknown = unary_error(500, b"?") + assert type(unknown) is ArcBoxError + assert unknown.code == "unknown" + + +def test_wrap_errors_maps_connection_failures_with_a_suggestion() -> None: + with pytest.raises(ConnectionFailedError) as exc_info, wrap_errors("sandbox.create"): + raise httpx.ConnectError("[Errno 2] No such file or directory") + err = exc_info.value + assert err.operation == "sandbox.create" + assert err.suggestion is not None and "abctl daemon start" in err.suggestion + assert isinstance(err.__cause__, httpx.ConnectError) + + +def test_wrap_errors_maps_client_side_deadline_expiry() -> None: + with pytest.raises(RequestTimeoutError), wrap_errors("op"): + raise httpx.ReadTimeout("slow daemon") + + +def test_wrap_errors_stamps_the_operation_on_passthrough() -> None: + original = SandboxNotFoundError("gone") + with pytest.raises(SandboxNotFoundError) as exc_info, wrap_errors("sandbox.kill"): + raise original + assert exc_info.value is original + assert original.operation == "sandbox.kill" + + stamped = SandboxNotFoundError("gone", operation="files.read_bytes") + with pytest.raises(SandboxNotFoundError), wrap_errors("sandbox.kill"): + raise stamped + assert stamped.operation == "files.read_bytes" From 52f1bddb4bc038251d19780a3c59006e4873c4db Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:46:29 +0800 Subject: [PATCH 09/20] test(sdk): mock-daemon run/files loops, sync parity, gated e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MockTransport suites drive the real request paths: foreground run (output re-read from offset 0, retention-gap truncation), shell-string sugar, check= semantics incl. the background+check runtime rejection, background streaming and group kill on both trees; files write mode default/explicit, empty-file done chunk, 256 KiB chunking, oversized fail-before-send, read assembly. The parity suite reruns the unasync transform (lockstep) and asserts identical public surfaces modulo async markers — the sentinel default gained a stable repr so signatures compare equal. test_e2e.py is the ARCBOX_SDK_E2E=1-gated hello world (sync + async), skipped cleanly otherwise. --- sdk/python/pyproject.toml | 10 ++ sdk/python/scripts/gen_sync.py | 8 +- sdk/python/src/arcbox/_async/_client.py | 5 + sdk/python/src/arcbox/_sync/_client.py | 5 + sdk/python/tests/test_commands.py | 203 ++++++++++++++++++++++++ sdk/python/tests/test_e2e.py | 81 ++++++++++ sdk/python/tests/test_files.py | 101 ++++++++++++ sdk/python/tests/test_sync_parity.py | 99 ++++++++++++ 8 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 sdk/python/tests/test_commands.py create mode 100644 sdk/python/tests/test_e2e.py create mode 100644 sdk/python/tests/test_files.py create mode 100644 sdk/python/tests/test_sync_parity.py diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 64c6a0802..b857d3c80 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -32,6 +32,16 @@ include = ["src", "tests", "scripts"] exclude = ["src/arcbox/_gen", ".venv"] typeCheckingMode = "strict" +# The codegen scripts drive tools that ship no type information +# (grpcio-tools' protoc entry point, unasync); only the unknown-type +# diagnostics that follow from those imports are relaxed there. +[[tool.pyright.executionEnvironments]] +root = "scripts" +reportMissingTypeStubs = false +reportUnknownMemberType = false +reportUnknownVariableType = false +reportUnknownArgumentType = false + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/sdk/python/scripts/gen_sync.py b/sdk/python/scripts/gen_sync.py index 4e806f5e5..be46aaefa 100644 --- a/sdk/python/scripts/gen_sync.py +++ b/sdk/python/scripts/gen_sync.py @@ -25,9 +25,7 @@ ASYNC_DIR = SDK_ROOT / "src" / "arcbox" / "_async" SYNC_DIR = SDK_ROOT / "src" / "arcbox" / "_sync" -HEADER = ( - "# Generated by scripts/gen_sync.py from arcbox/_async/{name} — do not edit.\n" -) +HEADER = "# Generated by scripts/gen_sync.py from arcbox/_async/{name} — do not edit.\n" # On top of unasync's defaults (async def/await/async for/async with, # __aenter__/__aexit__/__aiter__/__anext__, AsyncIterator/AsyncIterable, @@ -88,7 +86,9 @@ def check() -> list[str]: fresh_dir = Path(tmp) / "_sync" generate(fresh_dir) fresh = {p.name: p.read_text() for p in fresh_dir.glob("*.py")} - committed = {p.name: p.read_text() for p in SYNC_DIR.glob("*.py")} if SYNC_DIR.is_dir() else {} + committed = ( + {p.name: p.read_text() for p in SYNC_DIR.glob("*.py")} if SYNC_DIR.is_dir() else {} + ) for name in sorted(fresh.keys() | committed.keys()): if fresh.get(name) != committed.get(name): drifted.append(f"src/arcbox/_sync/{name}") diff --git a/sdk/python/src/arcbox/_async/_client.py b/sdk/python/src/arcbox/_async/_client.py index a1acb385e..5c300f711 100644 --- a/sdk/python/src/arcbox/_async/_client.py +++ b/sdk/python/src/arcbox/_async/_client.py @@ -47,6 +47,11 @@ class _UseDefault: """Sentinel: apply the connection's ``request_timeout``.""" + def __repr__(self) -> str: + # Stable across the async and sync trees so signatures compare + # equal in the parity test (and read cleanly in help()). + return "USE_DEFAULT" + _USE_DEFAULT = _UseDefault() diff --git a/sdk/python/src/arcbox/_sync/_client.py b/sdk/python/src/arcbox/_sync/_client.py index efe604183..7431f32a0 100644 --- a/sdk/python/src/arcbox/_sync/_client.py +++ b/sdk/python/src/arcbox/_sync/_client.py @@ -48,6 +48,11 @@ class _UseDefault: """Sentinel: apply the connection's ``request_timeout``.""" + def __repr__(self) -> str: + # Stable across the async and sync trees so signatures compare + # equal in the parity test (and read cleanly in help()). + return "USE_DEFAULT" + _USE_DEFAULT = _UseDefault() diff --git a/sdk/python/tests/test_commands.py b/sdk/python/tests/test_commands.py new file mode 100644 index 000000000..bc0b851f0 --- /dev/null +++ b/sdk/python/tests/test_commands.py @@ -0,0 +1,203 @@ +"""commands.run against a mock daemon: exit-as-data, streaming, kill.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import httpx +import pytest +from google.protobuf import empty_pb2 + +from arcbox import AsyncSandbox, Connection, Sandbox +from arcbox._async._client import AsyncConnectClient +from arcbox._envelope import FLAG_END_STREAM, encode_envelope +from arcbox._gen import process_pb2 +from arcbox._sync._client import ConnectClient +from arcbox._types import command_result_from_execution +from arcbox.errors import ( + ArcBoxError, + CommandFailedError, + InvalidArgumentError, + SandboxDiedError, +) + +if TYPE_CHECKING: + from google.protobuf.message import Message + + +def execution( + state: process_pb2.ExecutionState, + exit_code: int | None = None, + signal: int | None = None, +) -> process_pb2.Execution: + e = process_pb2.Execution(id="exec-1", sandbox_id="sb-1", state=state) + if exit_code is not None: + e.exit_status.code = exit_code + if signal is not None: + e.exit_status.signal = signal + return e + + +class TestResultMapping: + def test_exit_code_is_data(self) -> None: + result = command_result_from_execution( + execution(process_pb2.EXECUTION_STATE_EXITED, exit_code=3), "out", "err" + ) + assert result.exit_code == 3 + assert result.signal is None + with pytest.raises(CommandFailedError) as exc_info: + result.expect() + assert exc_info.value.result is result + + def test_signal_death_is_shell_convention(self) -> None: + result = command_result_from_execution( + execution(process_pb2.EXECUTION_STATE_EXITED, signal=9), "", "" + ) + assert result.exit_code == 137 + assert result.signal == "SIGKILL" + + def test_session_break_is_sandbox_died_not_an_exit(self) -> None: + broken = execution(process_pb2.EXECUTION_STATE_EXITED) + broken.error = "vsock session lost" + with pytest.raises(SandboxDiedError): + command_result_from_execution(broken, "", "") + + def test_missing_exit_status_is_a_typed_error(self) -> None: + with pytest.raises(ArcBoxError, match="without an exit status"): + command_result_from_execution(execution(process_pb2.EXECUTION_STATE_EXITED), "", "") + + +def proto_response(message: Message) -> httpx.Response: + return httpx.Response( + 200, + content=message.SerializeToString(), + headers={"content-type": "application/proto"}, + ) + + +def stream_response(*frames: bytes) -> httpx.Response: + body = b"".join(frames) + encode_envelope(FLAG_END_STREAM, b"{}") + return httpx.Response(200, content=body, headers={"content-type": "application/connect+proto"}) + + +class MockDaemon: + """Handles the process-service RPCs the run loop issues.""" + + def __init__(self, exit_code: int = 0) -> None: + self.exit_code = exit_code + self.started: list[process_pb2.StartExecutionRequest] = [] + self.signals: list[process_pb2.SignalExecutionRequest] = [] + #: (channel, offset, data) triples replayed by AttachExecution. + self.chunks: list[tuple[process_pb2.StdioChannel, int, bytes]] = [ + (process_pb2.STDIO_CHANNEL_STDOUT, 0, b"hello "), + (process_pb2.STDIO_CHANNEL_STDOUT, 6, b"world\n"), + (process_pb2.STDIO_CHANNEL_STDERR, 0, b"warn\n"), + ] + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/StartExecution"): + self.started.append(process_pb2.StartExecutionRequest.FromString(request.content)) + return proto_response(execution(process_pb2.EXECUTION_STATE_RUNNING)) + if path.endswith("/WaitExecution"): + return proto_response( + execution(process_pb2.EXECUTION_STATE_EXITED, exit_code=self.exit_code) + ) + if path.endswith("/SignalExecution"): + self.signals.append(process_pb2.SignalExecutionRequest.FromString(request.content)) + return proto_response(empty_pb2.Empty()) + if path.endswith("/AttachExecution"): + frames: list[bytes] = [] + for channel, offset, data in self.chunks: + event = process_pb2.ExecutionEvent() + event.output.channel = channel + event.output.offset = offset + event.output.data = data + frames.append(encode_envelope(0, event.SerializeToString())) + exited = process_pb2.ExecutionEvent() + exited.exited.execution.CopyFrom( + execution(process_pb2.EXECUTION_STATE_EXITED, exit_code=self.exit_code) + ) + frames.append(encode_envelope(0, exited.SerializeToString())) + return stream_response(*frames) + if path.endswith("/Remove"): + return proto_response(empty_pb2.Empty()) + return httpx.Response(404, content=b"unhandled: " + path.encode()) + + +def sync_sandbox(daemon: MockDaemon) -> Sandbox: + http = httpx.Client(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + return Sandbox(ConnectClient(Connection(http_client=http)), "sb-1") + + +def test_foreground_run_collects_output_and_exit() -> None: + daemon = MockDaemon(exit_code=0) + result = sync_sandbox(daemon).commands.run(["/bin/echo", "hello world"]) + assert result.exit_code == 0 + assert result.stdout == "hello world\n" + assert result.stderr == "warn\n" + assert not result.truncated + assert daemon.started[0].cmd[:1] == ["/bin/echo"] + assert daemon.started[0].execution_id != "" + + +def test_shell_string_form_wraps_in_sh_lc() -> None: + daemon = MockDaemon() + sync_sandbox(daemon).commands.run("exit 0") + assert list(daemon.started[0].cmd) == ["/bin/sh", "-lc", "exit 0"] + + +def test_non_zero_exit_is_data_and_check_raises() -> None: + daemon = MockDaemon(exit_code=3) + sandbox = sync_sandbox(daemon) + assert sandbox.commands.run("exit 3").exit_code == 3 + with pytest.raises(CommandFailedError): + sandbox.commands.run("exit 3", check=True) + + +def test_check_with_background_is_rejected() -> None: + daemon = MockDaemon() + with pytest.raises(InvalidArgumentError): + # type ignore: the overloads already forbid this statically; the + # runtime guard for untyped callers is what is under test. + sync_sandbox(daemon).commands.run("x", check=True, background=True) # type: ignore + assert daemon.started == [] + + +def test_background_streams_output_then_waits() -> None: + daemon = MockDaemon(exit_code=0) + handle = sync_sandbox(daemon).commands.run("emit", background=True) + streamed = [(chunk.channel, chunk.data) for chunk in handle.output] + assert streamed == [ + ("stdout", b"hello "), + ("stdout", b"world\n"), + ("stderr", b"warn\n"), + ] + result = handle.wait_for_exit(30) + assert result.exit_code == 0 + handle.kill("SIGKILL") + assert daemon.signals[0].signal == process_pb2.SIGNAL_SIGKILL + + +def test_retention_gap_reports_truncation() -> None: + daemon = MockDaemon(exit_code=0) + # First retained stdout byte starts at offset 100: the head was dropped. + daemon.chunks = [(process_pb2.STDIO_CHANNEL_STDOUT, 100, b"tail")] + result = sync_sandbox(daemon).commands.run("big output") + assert result.truncated + assert result.stdout == "tail" + + +@pytest.mark.anyio +async def test_async_tree_runs_the_same_loop() -> None: + daemon = MockDaemon(exit_code=0) + http = httpx.AsyncClient(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + sandbox = AsyncSandbox(AsyncConnectClient(Connection(http_client=http)), "sb-1") + handle = await sandbox.commands.run("emit", background=True) + streamed = b"" + async for chunk in handle.output: + if chunk.channel != "stderr": + streamed += chunk.data + assert streamed == b"hello world\n" + result = await handle.wait_for_exit(30) + assert result.exit_code == 0 diff --git a/sdk/python/tests/test_e2e.py b/sdk/python/tests/test_e2e.py new file mode 100644 index 000000000..4785f1d5b --- /dev/null +++ b/sdk/python/tests/test_e2e.py @@ -0,0 +1,81 @@ +"""The hello-world closed loop against a LIVE daemon socket. + +Opt-in: set ARCBOX_SDK_E2E=1 (and have `abctl daemon start` running, +with sandbox support on this host). Skipped cleanly otherwise. +Connection resolution applies, so ARCBOX_SOCKET / ARCBOX_DATA_DIR +point the loop at a dev daemon. + +This is the design doc's 20-line hello world minus the parts outside +phase 1's surface: ports.expose and wait_for_port are deferred, so the +background command is observed through its output stream and +wait_for_exit instead of a port probe. Both surfaces run the same +scenario — the sync flavor exercises the generated tree end to end. +""" + +from __future__ import annotations + +import os + +import pytest + +from arcbox import AsyncSandbox, Sandbox + +pytestmark = pytest.mark.skipif( + os.environ.get("ARCBOX_SDK_E2E") != "1", + reason="set ARCBOX_SDK_E2E=1 with a live daemon (sandbox support required)", +) + + +def test_sync_hello_world() -> None: + # Built-in minimal template (busybox) — no image pull involved. + sandbox = Sandbox.create("", ttl=300) + try: + # files: write then read back. + sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n") + assert sandbox.files.read_text("/tmp/hello.txt") == "hello from arcbox\n" + + # foreground command: exit-as-data plus the expect() sugar. + result = sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"]) + assert result.expect().stdout == "hello from arcbox\n" + + # non-zero exit is data, not an exception (string form = shell sugar). + failing = sandbox.commands.run("exit 3") + assert failing.exit_code == 3 + + # background command: iterable output, then wait_for_exit. + bg = sandbox.commands.run("for i in 1 2 3; do echo line$i; done", background=True) + streamed = b"".join(chunk.data for chunk in bg.output if chunk.channel != "stderr") + assert b"line3" in streamed + assert bg.wait_for_exit(30).exit_code == 0 + + # kill() delivers a signal to the whole process group. + sleeper = sandbox.commands.run(["/bin/sleep", "300"], background=True) + sleeper.kill("SIGKILL") + killed = sleeper.wait_for_exit(30) + assert killed.signal == "SIGKILL" + assert killed.exit_code == 137 + + # info() is always fresh. + assert sandbox.info().state in ("ready", "running") + finally: + sandbox.kill() + + +@pytest.mark.anyio +async def test_async_hello_world() -> None: + sandbox = await AsyncSandbox.create("", ttl=300) + async with sandbox: + await sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n") + assert await sandbox.files.read_text("/tmp/hello.txt") == "hello from arcbox\n" + + result = await sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"]) + assert result.expect().stdout == "hello from arcbox\n" + + bg = await sandbox.commands.run("for i in 1 2 3; do echo line$i; done", background=True) + streamed = b"" + async for chunk in bg.output: + if chunk.channel != "stderr": + streamed += chunk.data + assert b"line3" in streamed + assert (await bg.wait_for_exit(30)).exit_code == 0 + # context exit: sandbox killed, nothing leaked diff --git a/sdk/python/tests/test_files.py b/sdk/python/tests/test_files.py new file mode 100644 index 000000000..b9566a2fd --- /dev/null +++ b/sdk/python/tests/test_files.py @@ -0,0 +1,101 @@ +"""files read/write against a mock daemon, incl. the mode-handling contract.""" + +from __future__ import annotations + +import httpx +import pytest +from google.protobuf import empty_pb2 + +from arcbox import Connection, Sandbox +from arcbox._envelope import FLAG_END_STREAM, EnvelopeDecoder, encode_envelope +from arcbox._gen import filesystem_pb2 +from arcbox._sync._client import ConnectClient +from arcbox.errors import FileTooLargeError + + +class MockFilesystem: + def __init__(self) -> None: + self.writes: list[list[filesystem_pb2.WriteFileRequest]] = [] + #: chunks replayed by ReadFile. + self.read_chunks: list[tuple[bytes, bool]] = [(b"hello ", False), (b"world", True)] + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/WriteFile"): + decoded = [ + filesystem_pb2.WriteFileRequest.FromString(payload) + for _flags, payload in EnvelopeDecoder().feed(request.content) + ] + self.writes.append(decoded) + body = encode_envelope(0, empty_pb2.Empty().SerializeToString()) + encode_envelope( + FLAG_END_STREAM, b"{}" + ) + return httpx.Response( + 200, content=body, headers={"content-type": "application/connect+proto"} + ) + if path.endswith("/ReadFile"): + frames = b"" + for data, done in self.read_chunks: + chunk = filesystem_pb2.FileChunk(data=data, done=done) + frames += encode_envelope(0, chunk.SerializeToString()) + frames += encode_envelope(FLAG_END_STREAM, b"{}") + return httpx.Response( + 200, content=frames, headers={"content-type": "application/connect+proto"} + ) + return httpx.Response(404, content=b"unhandled: " + path.encode()) + + +def sandbox_for(daemon: MockFilesystem) -> Sandbox: + http = httpx.Client(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + return Sandbox(ConnectClient(Connection(http_client=http)), "sb-1") + + +def test_write_text_defaults_mode_to_0o644() -> None: + daemon = MockFilesystem() + sandbox_for(daemon).files.write_text("/tmp/hello.txt", "hi\n") + frames = daemon.writes[0] + assert frames[0].WhichOneof("payload") == "open" + assert frames[0].open.path == "/tmp/hello.txt" + assert frames[0].open.mode == 0o644 + assert frames[1].chunk.data == b"hi\n" + assert frames[1].chunk.done + + +def test_write_bytes_honors_an_explicit_mode() -> None: + daemon = MockFilesystem() + sandbox_for(daemon).files.write_bytes("/x", b"data", mode=0o755) + assert daemon.writes[0][0].open.mode == 0o755 + + +def test_empty_file_still_sends_one_done_chunk() -> None: + daemon = MockFilesystem() + sandbox_for(daemon).files.write_bytes("/empty", b"") + frames = daemon.writes[0] + assert len(frames) == 2 + assert frames[1].chunk.data == b"" + assert frames[1].chunk.done + + +def test_large_writes_are_chunked_with_done_only_on_the_last() -> None: + daemon = MockFilesystem() + payload = bytes(300 * 1024) # > one 256 KiB chunk + sandbox_for(daemon).files.write_bytes("/big", payload) + chunks = [f.chunk for f in daemon.writes[0][1:]] + assert [len(c.data) for c in chunks] == [256 * 1024, 300 * 1024 - 256 * 1024] + assert [c.done for c in chunks] == [False, True] + + +def test_oversized_writes_fail_before_any_request() -> None: + daemon = MockFilesystem() + sandbox = sandbox_for(daemon) + with pytest.raises(FileTooLargeError) as exc_info: + sandbox.files.write_bytes("/huge", bytes(256 * 1024 * 1024 + 1)) + assert exc_info.value.context["limit"] == str(256 * 1024 * 1024) + assert daemon.writes == [] + + +def test_read_bytes_assembles_chunks_until_done() -> None: + daemon = MockFilesystem() + sandbox = sandbox_for(daemon) + assert sandbox.files.read_bytes("/f") == b"hello world" + assert sandbox.files.read_text("/f") == "hello world" diff --git a/sdk/python/tests/test_sync_parity.py b/sdk/python/tests/test_sync_parity.py new file mode 100644 index 000000000..0efad0ff0 --- /dev/null +++ b/sdk/python/tests/test_sync_parity.py @@ -0,0 +1,99 @@ +"""The sync tree is a mechanical mirror of the async core. + +Two independent guards (both CI-enforced): + +1. Lockstep — rerunning the unasync transform reproduces the committed + `arcbox/_sync` byte for byte, so the tree cannot drift silently. +2. Surface parity — every public class pair exposes identical method + sets and signatures modulo async markers, catching skew even if a + file escaped the transform. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +from pathlib import Path + +import pytest + +from arcbox import ( + ArcBox, + AsyncArcBox, + AsyncCommandHandle, + AsyncCommands, + AsyncConnectClient, + AsyncFiles, + AsyncSandbox, + CommandHandle, + Commands, + ConnectClient, + Files, + Sandbox, +) + +SDK_ROOT = Path(__file__).resolve().parent.parent + +PAIRS = [ + (AsyncArcBox, ArcBox), + (AsyncSandbox, Sandbox), + (AsyncCommands, Commands), + (AsyncCommandHandle, CommandHandle), + (AsyncFiles, Files), + (AsyncConnectClient, ConnectClient), +] + + +def test_sync_tree_is_in_lockstep_with_the_async_core() -> None: + result = subprocess.run( + [sys.executable, str(SDK_ROOT / "scripts" / "gen_sync.py"), "--check"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"sync tree drifted:\n{result.stdout}{result.stderr}" + + +def _public_members(cls: type) -> dict[str, object]: + return { + name: member + for name, member in inspect.getmembers(cls) + if not name.startswith("_") or name in ("__enter__", "__exit__", "__aenter__", "__aexit__") + } + + +def _normalize(signature: inspect.Signature) -> str: + return str(signature).replace("Async", "") + + +def _normalize_name(name: str) -> str: + return name.replace("__aenter__", "__enter__").replace("__aexit__", "__exit__") + + +@pytest.mark.parametrize(("async_cls", "sync_cls"), PAIRS, ids=lambda cls: cls.__name__) +def test_public_surfaces_are_identical_modulo_async_markers( + async_cls: type, sync_cls: type +) -> None: + async_members = _public_members(async_cls) + sync_members = _public_members(sync_cls) + assert sorted(_normalize_name(n) for n in async_members) == sorted(sync_members) + + for name, member in async_members.items(): + counterpart = sync_members[_normalize_name(name)] + if isinstance(member, property): + assert isinstance(counterpart, property), name + assert member.fget is not None and counterpart.fget is not None + assert _normalize(inspect.signature(member.fget)) == _normalize( + inspect.signature(counterpart.fget) + ), name + elif callable(member) and callable(counterpart): + assert _normalize(inspect.signature(member)) == _normalize( + inspect.signature(counterpart) + ), name + + +def test_the_sync_flavor_has_no_coroutine_methods() -> None: + for _async_cls, sync_cls in PAIRS: + for name, member in inspect.getmembers(sync_cls, inspect.isfunction): + assert not inspect.iscoroutinefunction(member), f"{sync_cls.__name__}.{name}" From 2094c17a6c0d17206131a65ed30be96f8c830528 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:52:33 +0800 Subject: [PATCH 10/20] docs(sdk): Python SDK README and scoped prek hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README covers install, both hello-world flavors, connection env table, regen + sync-tree workflow, the toolchain verdicts (pyright authoritative; ty 0.0.65 currently false-positives on protobuf generated members; pyrefly 1.2.0 clean but informational), uv publish flow, the maturin/PyO3 future note, and the phase-1 status/deferred list. prek.yaml carries sdk/python-scoped local hooks (ruff, pyright, sync lockstep, pytest) for prek or classic pre-commit — named prek.yaml because the repo gitignores .pre-commit-config.yaml, so it is passed with -c. --- sdk/python/README.md | 146 ++++++++++++++++++++++++++++++++++++++++++- sdk/python/prek.yaml | 35 +++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 sdk/python/prek.yaml diff --git a/sdk/python/README.md b/sdk/python/README.md index b1557701f..6d726c138 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -1,3 +1,147 @@ # arcbox -ArcBox Sandbox SDK for Python (scaffold — full README lands with the surface). +Python SDK for ArcBox sandboxes: isolated microVMs on your Mac, driven +over the local daemon's Unix socket with the Connect protocol. Requires +Python ≥ 3.10. + +```sh +uv add arcbox # or: pip install arcbox +``` + +## Hello world + +With the daemon running (`abctl daemon start`): + +```python +from arcbox import Sandbox + +# Local daemon over ~/.arcbox/run/arcbox.sock — zero config. +with Sandbox.create("", ttl=300) as sandbox: + sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n") + + check = sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"]) + print(check.expect().stdout, "→ exit", check.exit_code) + + job = sandbox.commands.run("for i in 1 2 3; do echo line$i; done", background=True) + for chunk in job.output: + print(chunk.data.decode(), end="") + print("background job exited", job.wait_for_exit().exit_code) +# context exit: sandbox killed, nothing leaked +``` + +Async is a first-class mirror (`AsyncSandbox`, `async with`, `async for`): + +```python +from arcbox import AsyncSandbox + +async def main() -> None: + sandbox = await AsyncSandbox.create("", ttl=300) + async with sandbox: + await sandbox.files.write_text("/tmp/hello.txt", "hello from arcbox\n") + result = await sandbox.commands.run(["/bin/cat", "/tmp/hello.txt"]) + print(result.expect().stdout) +``` + +Non-zero exit is data (`result.exit_code`), never an exception — +`result.expect()` (or `run(..., check=True)`, `subprocess.run`-style) is +the opt-in raise. Every daemon error maps to a typed class in +`arcbox.errors` (`SandboxNotFoundError`, `CapabilityError`, +`ConnectionFailedError`, ...) carrying a machine-readable `code`, an +actionable `suggestion`, and the failed `operation`. Time arguments are +seconds (floats) everywhere. + +## Connection + +Resolution order: explicit option > environment > default. + +| Environment | Meaning | +| ---------------- | --------------------------------------------------------------------------------------------- | +| `ARCBOX_SOCKET` | daemon Unix socket (default `$ARCBOX_DATA_DIR/run/arcbox.sock`; data dir default `~/.arcbox`, or `~/.arcbox-dev` under `ARCBOX_PROFILE=development`) | +| `ARCBOX_API_URL` | remote daemon / cloud front door; setting it selects the remote tier (reserved, CORE-63) | +| `ARCBOX_API_KEY` | bearer credential, attached as `Authorization` when set; unused by the local daemon | + +Every entry point takes a `connection=Connection(...)` slot +(`socket_path` / `api_url` / `api_key` / `request_timeout` / injected +`http_client` for mocking — pass an `httpx.Client` to the sync surface, +an `httpx.AsyncClient` to the async one). + +## Development + +Inside the arcbox repo (`sdk/python`): + +```sh +uv sync # create .venv from uv.lock +uv run python scripts/gen_proto.py # regenerate src/arcbox/_gen from ../../rpc/arcbox-protocol/proto +uv run python scripts/gen_sync.py # regenerate src/arcbox/_sync from src/arcbox/_async +uv run ruff check . && uv run ruff format --check . +uv run pyright +uv run pytest # includes the sync-tree lockstep + parity checks +``` + +Generated code under `src/arcbox/_gen/` is committed and is **never** +exported from the package — public shapes are hand-written and mapped +at the transport boundary. + +The async tree (`src/arcbox/_async/`) is the source of truth; the sync +tree (`src/arcbox/_sync/`) is generated from it by an unasync token +transform and committed. Edit only the async tree, then rerun +`scripts/gen_sync.py`. Lockstep is CI-enforced twice: the transform is +rerun and diffed (`scripts/gen_sync.py --check`, also wired into +pytest), and a parity test asserts identical public surfaces modulo +async markers. + +Optional pre-commit hooks (scoped to `sdk/python`), via +[prek](https://github.com/j178/prek) or classic pre-commit: + +```sh +prek install -c sdk/python/prek.yaml +``` + +The end-to-end hello-world loop runs only against a live daemon and is +opt-in: + +```sh +ARCBOX_SDK_E2E=1 uv run pytest tests/test_e2e.py +``` + +## Toolchain notes + +- **uv** is the package/project manager (`uv_build` backend, `uv.lock` + committed). Publishing: `uv build && uv publish` (credentials via + `UV_PUBLISH_TOKEN`; TestPyPI first via a `[[tool.uv.index]]` entry if + desired). +- **ruff** is both linter and formatter (`E,F,W,I,UP,B,SIM,RUF`). +- **pyright** (strict) is the authoritative type checker. Evaluated + alternatives (2026-08): **ty** 0.0.65 reports 16 false positives here + (all `unresolved-attribute` on protobuf generated-module members) — + kept in dev-deps for `uv run ty check`, may replace pyright when it + stabilizes; **pyrefly** 1.2.0 passes cleanly (it imports the pyright + config) and serves as an informational second opinion — one + authoritative checker avoids double-suppression drift. +- **msgspec** parses the SDK's one JSON seam — Connect error bodies and + `EndStreamResponse` frames — as typed, validated Structs at the + untrusted-input boundary (chosen for the typed decoding, not speed). +- Message types are upstream **protobuf** runtime code generated by the + protoc bundled with grpcio-tools (dev-dep); the bundled protoc version + matches the pinned runtime. +- A future native-acceleration path (if profiling ever demands one) is a + **maturin**/PyO3 extension crate in this repo's workspace; nothing in + the current SDK needs it. + +TODO(CI): wire the gates above into `.github/workflows` as an +`sdk-python` job (follow-up; workflow changes are intentionally not part +of this branch). + +## Status + +Phase 1 of CORE-58 — the hello-world closed loop: `Sandbox` / +`AsyncSandbox` create/connect/list, `kill`/`pause`/`info` (`pause` and +the paused-sandbox reconnect path are wire-complete but reject with an +unimplemented error until the daemon's CORE-21 lands), `commands.run` +(foreground result + background handle with streamed output, +`wait_for_exit`, `kill`), and whole-file `files` read/write. Deferred: +PTY, `ports`, `wait_for_port`/`wait_for_log`, stdin, filesystem path +verbs (stat/list/mkdir/...), `Template` statics, `events()`, +`set_lifecycle`, the capabilities handshake, and the SDK-side default +idle-reaping policy (design decision 4 — applied once the daemon +enforces the lifecycle knobs). diff --git a/sdk/python/prek.yaml b/sdk/python/prek.yaml new file mode 100644 index 000000000..8771bc225 --- /dev/null +++ b/sdk/python/prek.yaml @@ -0,0 +1,35 @@ +# Hooks for the Python SDK only (run with prek, the Rust pre-commit +# runner — https://github.com/j178/prek — or classic pre-commit). +# +# Install from the repo root: +# prek install -c sdk/python/prek.yaml +# +# Scoped to sdk/python paths; deliberately no Rust-side hooks — the +# repo's cargo fmt/clippy discipline stays where it is. +repos: + - repo: local + hooks: + - id: sdk-python-ruff + name: sdk/python ruff (lint + format) + language: system + files: ^sdk/python/ + pass_filenames: false + entry: bash -c 'cd sdk/python && uv run ruff check . && uv run ruff format --check .' + - id: sdk-python-pyright + name: sdk/python pyright (strict) + language: system + files: ^sdk/python/ + pass_filenames: false + entry: bash -c 'cd sdk/python && uv run pyright' + - id: sdk-python-sync-lockstep + name: sdk/python sync-tree lockstep + language: system + files: ^sdk/python/(src/arcbox/(_async|_sync)/|scripts/gen_sync\.py) + pass_filenames: false + entry: bash -c 'cd sdk/python && uv run python scripts/gen_sync.py --check' + - id: sdk-python-pytest + name: sdk/python pytest + language: system + files: ^sdk/python/ + pass_filenames: false + entry: bash -c 'cd sdk/python && uv run pytest -q' From be43966a49b07a62dc3d25987547581f414820db Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:54:21 +0800 Subject: [PATCH 11/20] =?UTF-8?q?test(e2e):=20sdk=5Fpy=20harness=20?= =?UTF-8?q?=E2=80=94=20Python=20SDK=20hello=20world=20vs=20isolated=20daem?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors sdk_ts: release binaries + staged dev boot assets + isolated data dir, readiness via WatchSetupStatus, then 'uv sync --frozen' + 'uv run pytest tests/test_e2e.py' with ARCBOX_SDK_E2E=1 and ARCBOX_SOCKET at the daemon's socket (ARCBOX_API_URL/ARCBOX_DATA_DIR scrubbed). Wider pytest ceiling than the TS runner: the suite boots two sandboxes (sync + async flavors). fmt and clippy -D warnings clean. --- tests/e2e/src/lib.rs | 1 + tests/e2e/src/sdk_py.rs | 176 ++++++++++++++++++++++++++++++++++++++ tests/e2e/tests/sdk_py.rs | 25 ++++++ 3 files changed, 202 insertions(+) create mode 100644 tests/e2e/src/sdk_py.rs create mode 100644 tests/e2e/tests/sdk_py.rs diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index d100b9952..34e1d03c2 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -7,6 +7,7 @@ pub mod metrics; pub mod net_fixtures; pub mod sandbox; pub mod scenario; +pub mod sdk_py; pub mod sdk_ts; pub mod signing; pub mod virtio_debug; diff --git a/tests/e2e/src/sdk_py.rs b/tests/e2e/src/sdk_py.rs new file mode 100644 index 000000000..9ee2329a9 --- /dev/null +++ b/tests/e2e/src/sdk_py.rs @@ -0,0 +1,176 @@ +//! Runs the Python SDK's gated hello-world e2e +//! (`sdk/python/tests/test_e2e.py`) against an isolated daemon. +//! +//! The daemon side mirrors the sandbox smoke: release host binaries plus +//! musl guest agents, staged dev boot assets, an isolated data dir, and +//! readiness observed via `WatchSetupStatus`. The SDK side is +//! `uv run pytest tests/test_e2e.py` with `ARCBOX_SDK_E2E=1` and +//! `ARCBOX_SOCKET` pointing at the isolated daemon's gRPC socket. + +use std::env; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use tracing::{info, warn}; + +use crate::daemon::{DaemonConfig, DaemonHandle}; +use crate::metrics::RunMetrics; +use crate::{env_flag, repo_root}; + +/// Generous ceiling for daemon startup (asset staging + VM boot + agent). +const READY_TIMEOUT: Duration = Duration::from_secs(180); +/// Ceiling for the whole pytest run: two sandbox boots (sync + async +/// flavors) plus a handful of executions each. This only catches a +/// wedged runner. +const PYTEST_TIMEOUT: Duration = Duration::from_secs(600); + +pub struct SdkPyConfig { + pub skip_build: bool, + pub keep_test_dir: bool, + pub version: Option, +} + +impl SdkPyConfig { + #[must_use] + pub fn from_env() -> Self { + Self { + skip_build: env_flag("SKIP_BUILD"), + keep_test_dir: env_flag("KEEP_TEST_DIR"), + version: env::var("ARCBOX_BOOT_ASSET_VERSION").ok(), + } + } +} + +pub fn run(config: SdkPyConfig) -> Result<()> { + info!("starting Python SDK e2e"); + + if !config.skip_build { + // Same binary set as the sandbox smoke: the SDK drives the same + // sandbox surface, so it needs vm-agent staged just the same. + crate::sandbox::build_binaries()?; + } + + let root = repo_root(); + let version = match config.version { + Some(v) => v, + None => crate::boot_assets::boot_version(&root.join("assets.lock"))?, + }; + + let temp_dir = tempfile::Builder::new() + .prefix("arcbox-sdk-py-e2e-") + .tempdir() + .context("creating SDK e2e test directory")?; + let data_dir = temp_dir.path().to_owned(); + + crate::boot_assets::stage_dev_boot_assets(&root, &data_dir, &version)?; + + let mut metrics = RunMetrics::new("sdk_py", Some("vz")); + let result = run_scenario(&root, &data_dir, &version, &mut metrics); + metrics.passed = result.is_ok(); + match metrics.write(Some(&data_dir)) { + Ok(paths) => { + for path in paths { + info!(path = %path.display(), "run metrics written"); + } + } + Err(error) => warn!("writing run metrics failed: {error:#}"), + } + + if result.is_err() || config.keep_test_dir { + let path = temp_dir.keep(); + warn!(path = %path.display(), "preserving test directory"); + } + result +} + +fn run_scenario( + root: &Path, + data_dir: &Path, + version: &str, + metrics: &mut RunMetrics, +) -> Result<()> { + let sdk_dir = root.join("sdk/python"); + ensure_venv(&sdk_dir)?; + + // The sandbox surface needs nested virtualization, which VZ provides. + let mut daemon = DaemonHandle::spawn(DaemonConfig { + binary: root.join("target/release/arcbox-daemon"), + data_dir: data_dir.to_owned(), + args: vec![], + env: vec![ + ("ARCBOX_BOOT_ASSET_VERSION".to_owned(), version.to_owned()), + ("ARCBOX_VM_BACKEND".to_owned(), "vz".to_owned()), + ], + })?; + + let ready_started = Instant::now(); + daemon.wait_ready_blocking(READY_TIMEOUT)?; + metrics.record("daemon_ready", ready_started.elapsed().as_secs_f64()); + info!( + elapsed_seconds = ready_started.elapsed().as_secs(), + "daemon ready" + ); + + let scenario = metrics.time("sdk_pytest", || pytest(&sdk_dir, &daemon.grpc_socket())); + + // Always shut the daemon down; a teardown failure must not mask the + // scenario result. + match daemon.shutdown() { + Ok(status) => info!(%status, "daemon stopped"), + Err(error) => warn!("daemon shutdown failed: {error:#}"), + } + + scenario +} + +/// Syncs the SDK's virtualenv from the committed lockfile (a no-op when +/// it is already up to date). +fn ensure_venv(sdk_dir: &Path) -> Result<()> { + let status = Command::new("uv") + .args(["sync", "--frozen"]) + .current_dir(sdk_dir) + .status() + .context("running uv sync (is uv installed?)")?; + if !status.success() { + bail!("uv sync failed with {status}"); + } + Ok(()) +} + +/// Runs the gated SDK e2e against the isolated daemon socket, streaming +/// pytest output to the test's stdout/stderr. +fn pytest(sdk_dir: &Path, socket: &Path) -> Result<()> { + info!(socket = %socket.display(), "running uv run pytest tests/test_e2e.py"); + let mut child = Command::new("uv") + .args(["run", "--frozen", "pytest", "tests/test_e2e.py", "-v"]) + .current_dir(sdk_dir) + .env("ARCBOX_SDK_E2E", "1") + .env("ARCBOX_SOCKET", socket) + // The SDK resolves ARCBOX_API_URL over the socket, and + // ARCBOX_DATA_DIR would re-derive a default socket path; neither + // may leak in from the caller's environment. + .env_remove("ARCBOX_API_URL") + .env_remove("ARCBOX_DATA_DIR") + .stdin(Stdio::null()) + .spawn() + .context("spawning uv run pytest (is uv installed?)")?; + + let deadline = Instant::now() + PYTEST_TIMEOUT; + loop { + if let Some(status) = child.try_wait().context("polling pytest")? { + if status.success() { + info!("SDK e2e passed"); + return Ok(()); + } + bail!("pytest failed with {status}"); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + bail!("pytest did not finish within {PYTEST_TIMEOUT:?}"); + } + std::thread::sleep(Duration::from_millis(500)); + } +} diff --git a/tests/e2e/tests/sdk_py.rs b/tests/e2e/tests/sdk_py.rs new file mode 100644 index 000000000..f140002e4 --- /dev/null +++ b/tests/e2e/tests/sdk_py.rs @@ -0,0 +1,25 @@ +//! Python SDK hello-world e2e: `sdk/python/tests/test_e2e.py` +//! against an isolated daemon (CORE-58 phase 1 acceptance). +//! +//! Requires nested virtualization (VZ backend, Apple Silicon M3+ with +//! macOS 15+), staged boot assets, a signable daemon binary, and uv. Run: +//! +//! ```console +//! cargo test -p arcbox-e2e --test sdk_py -- --ignored --nocapture +//! ``` + +use arcbox_e2e::sdk_py::{SdkPyConfig, run}; + +#[test] +#[ignore = "requires nested virtualization (VZ on M3+), boot assets, a signed daemon, and uv"] +fn sdk_python_hello_world() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_test_writer() + .init(); + + run(SdkPyConfig::from_env()).expect("Python SDK e2e failed"); +} From bb4b56d7da006068a6b45271af4831400abd0987 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:55:08 +0800 Subject: [PATCH 12/20] docs(sdk): satisfy ruff's markdown code-block formatting in the README --- sdk/python/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/python/README.md b/sdk/python/README.md index 6d726c138..871448513 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -34,6 +34,7 @@ Async is a first-class mirror (`AsyncSandbox`, `async with`, `async for`): ```python from arcbox import AsyncSandbox + async def main() -> None: sandbox = await AsyncSandbox.create("", ttl=300) async with sandbox: From c5beab357842f7bd167bab915207d3825ee3ac2b Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 01:56:45 +0800 Subject: [PATCH 13/20] chore(sdk): reflow gen_proto for the 100-column format config --- sdk/python/scripts/gen_proto.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sdk/python/scripts/gen_proto.py b/sdk/python/scripts/gen_proto.py index 1cad9df21..dcbe59ec1 100644 --- a/sdk/python/scripts/gen_proto.py +++ b/sdk/python/scripts/gen_proto.py @@ -71,9 +71,7 @@ def main() -> int: (GEN_DIR / "__init__.py").write_text(GEN_INIT) for generated in sorted((Path(tmp) / PROTO_PACKAGE_DIR).iterdir()): - (GEN_DIR / generated.name).write_text( - rewrite_imports(generated.read_text()) - ) + (GEN_DIR / generated.name).write_text(rewrite_imports(generated.read_text())) print(f"regenerated {GEN_DIR}") return 0 From 14d0925197a1e18809609da5b6da1968a9f99fb5 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 02:19:49 +0800 Subject: [PATCH 14/20] fix(sdk): require the terminal EndStreamResponse in client_stream A WriteFile response body that ends after the response-message envelope but before the EndStreamResponse frame (connection cut, proxy truncation) was returned as success, hiding the server's terminal status. Track whether the terminal frame arrived and reject EOF without it, matching the server-streaming path's determinism. --- sdk/python/src/arcbox/_async/_client.py | 7 +++++++ sdk/python/src/arcbox/_sync/_client.py | 7 +++++++ sdk/python/tests/test_files.py | 18 ++++++++++++++---- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/sdk/python/src/arcbox/_async/_client.py b/sdk/python/src/arcbox/_async/_client.py index 5c300f711..88197fa76 100644 --- a/sdk/python/src/arcbox/_async/_client.py +++ b/sdk/python/src/arcbox/_async/_client.py @@ -137,6 +137,7 @@ async def client_stream( raise unary_error(response.status_code, response.content) decoder = EnvelopeDecoder() message: M | None = None + ended = False for flags, payload in decoder.feed(response.content): if flags & FLAG_COMPRESSED: raise ArcBoxError("received a compressed frame without negotiating compression") @@ -144,10 +145,16 @@ async def client_stream( error = end_stream_error(payload) if error is not None: raise error + ended = True else: decoded = response_type() decoded.ParseFromString(payload) message = decoded + if not ended: + # Without the terminal frame a truncated body is + # indistinguishable from success — never report one as + # completed (WriteFile rides this path). + raise ArcBoxError("the stream ended without an EndStreamResponse") if message is None: raise ArcBoxError("the stream ended without a response message") return message diff --git a/sdk/python/src/arcbox/_sync/_client.py b/sdk/python/src/arcbox/_sync/_client.py index 7431f32a0..d9537e9aa 100644 --- a/sdk/python/src/arcbox/_sync/_client.py +++ b/sdk/python/src/arcbox/_sync/_client.py @@ -136,6 +136,7 @@ def client_stream(self, path: str, requests: Iterable[Message], response_type: t raise unary_error(response.status_code, response.content) decoder = EnvelopeDecoder() message: M | None = None + ended = False for flags, payload in decoder.feed(response.content): if flags & FLAG_COMPRESSED: raise ArcBoxError("received a compressed frame without negotiating compression") @@ -143,10 +144,16 @@ def client_stream(self, path: str, requests: Iterable[Message], response_type: t error = end_stream_error(payload) if error is not None: raise error + ended = True else: decoded = response_type() decoded.ParseFromString(payload) message = decoded + if not ended: + # Without the terminal frame a truncated body is + # indistinguishable from success — never report one as + # completed (WriteFile rides this path). + raise ArcBoxError("the stream ended without an EndStreamResponse") if message is None: raise ArcBoxError("the stream ended without a response message") return message diff --git a/sdk/python/tests/test_files.py b/sdk/python/tests/test_files.py index b9566a2fd..9d7e8f759 100644 --- a/sdk/python/tests/test_files.py +++ b/sdk/python/tests/test_files.py @@ -10,7 +10,7 @@ from arcbox._envelope import FLAG_END_STREAM, EnvelopeDecoder, encode_envelope from arcbox._gen import filesystem_pb2 from arcbox._sync._client import ConnectClient -from arcbox.errors import FileTooLargeError +from arcbox.errors import ArcBoxError, FileTooLargeError class MockFilesystem: @@ -18,6 +18,9 @@ def __init__(self) -> None: self.writes: list[list[filesystem_pb2.WriteFileRequest]] = [] #: chunks replayed by ReadFile. self.read_chunks: list[tuple[bytes, bool]] = [(b"hello ", False), (b"world", True)] + #: Drop the terminal EndStream frame from WriteFile responses, + #: simulating a connection cut after the response message. + self.truncate_write_response = False def __call__(self, request: httpx.Request) -> httpx.Response: path = request.url.path @@ -27,9 +30,9 @@ def __call__(self, request: httpx.Request) -> httpx.Response: for _flags, payload in EnvelopeDecoder().feed(request.content) ] self.writes.append(decoded) - body = encode_envelope(0, empty_pb2.Empty().SerializeToString()) + encode_envelope( - FLAG_END_STREAM, b"{}" - ) + body = encode_envelope(0, empty_pb2.Empty().SerializeToString()) + if not self.truncate_write_response: + body += encode_envelope(FLAG_END_STREAM, b"{}") return httpx.Response( 200, content=body, headers={"content-type": "application/connect+proto"} ) @@ -94,6 +97,13 @@ def test_oversized_writes_fail_before_any_request() -> None: assert daemon.writes == [] +def test_write_without_the_terminal_frame_is_an_error_not_success() -> None: + daemon = MockFilesystem() + daemon.truncate_write_response = True + with pytest.raises(ArcBoxError, match="without an EndStreamResponse"): + sandbox_for(daemon).files.write_bytes("/x", b"data") + + def test_read_bytes_assembles_chunks_until_done() -> None: daemon = MockFilesystem() sandbox = sandbox_for(daemon) From 68b287c9b0dd364be0f5ca01b5858bb56df56c8e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 02:21:30 +0800 Subject: [PATCH 15/20] fix(sdk): honor sub-second wait_for_exit deadlines The server slice was rounded up to a 1 s minimum, so wait_for_exit(0.1) blocked ~1 s past its documented bound. The wire's granularity is whole seconds, so floor the slice instead, sleep out any sub-second remainder client-side, and finish with an immediate poll (timeout_seconds=0, the documented poll form). The +5 s HTTP grace stays: it engages only when the daemon is unresponsive, turning a wedged wait into a typed error. --- sdk/python/scripts/gen_sync.py | 4 ++++ sdk/python/src/arcbox/_async/commands.py | 17 ++++++++++---- sdk/python/src/arcbox/_sync/commands.py | 16 +++++++++---- sdk/python/tests/test_commands.py | 29 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/sdk/python/scripts/gen_sync.py b/sdk/python/scripts/gen_sync.py index be46aaefa..2c6756e49 100644 --- a/sdk/python/scripts/gen_sync.py +++ b/sdk/python/scripts/gen_sync.py @@ -49,6 +49,10 @@ "AbstractAsyncContextManager": "AbstractContextManager", "aclosing": "closing", "anext": "next", + # The event-loop sleep becomes a thread sleep (`asyncio.sleep` -> + # `time.sleep`; the duplicate `import time` is deduplicated by the + # ruff pass below). + "asyncio": "time", } diff --git a/sdk/python/src/arcbox/_async/commands.py b/sdk/python/src/arcbox/_async/commands.py index a4b419b5a..5b321530a 100644 --- a/sdk/python/src/arcbox/_async/commands.py +++ b/sdk/python/src/arcbox/_async/commands.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import math import time import uuid @@ -99,11 +100,17 @@ async def wait_for_exit(self, timeout: float | None = None) -> CommandResult: ), context={"command_id": self.command_id}, ) - slice_seconds = ( - _WAIT_SLICE_SECONDS - if remaining is None - else min(_WAIT_SLICE_SECONDS, max(1, math.ceil(remaining))) - ) + if remaining is None: + slice_seconds = _WAIT_SLICE_SECONDS + elif remaining < 1: + # The wire's wait granularity is whole seconds: + # sleep out the sub-second remainder, then ask for + # the current state (0 = return immediately) so the + # documented timeout bound holds. + await asyncio.sleep(remaining) + slice_seconds = 0 + else: + slice_seconds = min(_WAIT_SLICE_SECONDS, int(remaining)) execution = await self._client.unary( _PROCESS + "WaitExecution", process_pb2.WaitExecutionRequest( diff --git a/sdk/python/src/arcbox/_sync/commands.py b/sdk/python/src/arcbox/_sync/commands.py index 2234e375e..4bafaee25 100644 --- a/sdk/python/src/arcbox/_sync/commands.py +++ b/sdk/python/src/arcbox/_sync/commands.py @@ -99,11 +99,17 @@ def wait_for_exit(self, timeout: float | None = None) -> CommandResult: ), context={"command_id": self.command_id}, ) - slice_seconds = ( - _WAIT_SLICE_SECONDS - if remaining is None - else min(_WAIT_SLICE_SECONDS, max(1, math.ceil(remaining))) - ) + if remaining is None: + slice_seconds = _WAIT_SLICE_SECONDS + elif remaining < 1: + # The wire's wait granularity is whole seconds: + # sleep out the sub-second remainder, then ask for + # the current state (0 = return immediately) so the + # documented timeout bound holds. + time.sleep(remaining) + slice_seconds = 0 + else: + slice_seconds = min(_WAIT_SLICE_SECONDS, int(remaining)) execution = self._client.unary( _PROCESS + "WaitExecution", process_pb2.WaitExecutionRequest( diff --git a/sdk/python/tests/test_commands.py b/sdk/python/tests/test_commands.py index bc0b851f0..02a6e274f 100644 --- a/sdk/python/tests/test_commands.py +++ b/sdk/python/tests/test_commands.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from typing import TYPE_CHECKING import httpx @@ -19,6 +20,7 @@ CommandFailedError, InvalidArgumentError, SandboxDiedError, + TimeoutError, ) if TYPE_CHECKING: @@ -87,6 +89,9 @@ def __init__(self, exit_code: int = 0) -> None: self.exit_code = exit_code self.started: list[process_pb2.StartExecutionRequest] = [] self.signals: list[process_pb2.SignalExecutionRequest] = [] + self.waits: list[process_pb2.WaitExecutionRequest] = [] + #: states served by successive WaitExecution calls before EXITED. + self.wait_states: list[process_pb2.ExecutionState] = [] #: (channel, offset, data) triples replayed by AttachExecution. self.chunks: list[tuple[process_pb2.StdioChannel, int, bytes]] = [ (process_pb2.STDIO_CHANNEL_STDOUT, 0, b"hello "), @@ -100,6 +105,9 @@ def __call__(self, request: httpx.Request) -> httpx.Response: self.started.append(process_pb2.StartExecutionRequest.FromString(request.content)) return proto_response(execution(process_pb2.EXECUTION_STATE_RUNNING)) if path.endswith("/WaitExecution"): + self.waits.append(process_pb2.WaitExecutionRequest.FromString(request.content)) + if self.wait_states: + return proto_response(execution(self.wait_states.pop(0))) return proto_response( execution(process_pb2.EXECUTION_STATE_EXITED, exit_code=self.exit_code) ) @@ -179,6 +187,27 @@ def test_background_streams_output_then_waits() -> None: assert daemon.signals[0].signal == process_pb2.SIGNAL_SIGKILL +def test_wait_slices_are_capped_and_floored_to_the_wire_granularity() -> None: + daemon = MockDaemon(exit_code=0) + handle = sync_sandbox(daemon).commands.run("spin", background=True) + handle.wait_for_exit(45) + assert daemon.waits[-1].timeout_seconds == 30 + + +def test_sub_second_wait_honors_its_deadline() -> None: + daemon = MockDaemon(exit_code=0) + daemon.wait_states = [process_pb2.EXECUTION_STATE_RUNNING] + handle = sync_sandbox(daemon).commands.run("spin", background=True) + started = time.monotonic() + with pytest.raises(TimeoutError): + handle.wait_for_exit(0.2) + elapsed = time.monotonic() - started + # One immediate poll (timeout_seconds=0) after sleeping out the + # remainder — not the old 1 s minimum server slice. + assert [w.timeout_seconds for w in daemon.waits] == [0] + assert 0.2 <= elapsed < 1.0 + + def test_retention_gap_reports_truncation() -> None: daemon = MockDaemon(exit_code=0) # First retained stdout byte starts at offset 100: the head was dropped. From b69a8d97fad1700d232a880774dc2dd746574d02 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 02:27:27 +0800 Subject: [PATCH 16/20] fix(sdk): close SDK-owned HTTP clients deterministically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing ever closed an SDK-constructed httpx client, so its pool and Unix-socket descriptors lived until GC. ConnectClient now records whether it built the client and aclose()/close() releases only owned ones — an injected Connection.http_client always belongs to the caller. ArcBox/AsyncArcBox become context managers (plus close/aclose); the Sandbox classmethod sugar transfers ownership of its hidden entry point's client to the handle, which closes it on context exit, and the list() sugar closes when iteration finishes. UP028 is ignored for the generated sync tree only: unasync's mirror of 'async for … yield' has no yield-from form. --- sdk/python/README.md | 8 +++ sdk/python/pyproject.toml | 7 ++ sdk/python/scripts/gen_sync.py | 5 +- sdk/python/src/arcbox/_async/_client.py | 9 +++ sdk/python/src/arcbox/_async/sandbox.py | 93 +++++++++++++++++++------ sdk/python/src/arcbox/_sync/_client.py | 9 +++ sdk/python/src/arcbox/_sync/sandbox.py | 91 ++++++++++++++++++------ sdk/python/tests/test_disposal.py | 74 ++++++++++++++++++++ sdk/python/tests/test_sync_parity.py | 6 +- 9 files changed, 257 insertions(+), 45 deletions(-) create mode 100644 sdk/python/tests/test_disposal.py diff --git a/sdk/python/README.md b/sdk/python/README.md index 871448513..6f5d1c185 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -66,6 +66,14 @@ Every entry point takes a `connection=Connection(...)` slot `http_client` for mocking — pass an `httpx.Client` to the sync surface, an `httpx.AsyncClient` to the async one). +The `Sandbox` / `AsyncSandbox` classmethods resolve a hidden connection +per call, and the returned handle closes its HTTP client on context +exit. Long-lived programs should hold an `ArcBox` / `AsyncArcBox` +instead: it is a context manager (or call `.close()` / `.aclose()`), +and every handle it creates shares its client. An injected +`http_client` always belongs to the caller and is never closed by the +SDK. + ## Development Inside the arcbox repo (`sdk/python`): diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index b857d3c80..ec4067596 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -27,6 +27,13 @@ extend-exclude = ["src/arcbox/_gen"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"] +[tool.ruff.lint.per-file-ignores] +# The sync tree is a mechanical unasync mirror: `async for … yield` has +# no `yield from` form, so its transform cannot satisfy UP028. +# gen_sync.py mirrors this ignore for its scratch directory, which sits +# outside the project root where this pattern cannot anchor. +"src/arcbox/_sync/*" = ["UP028"] + [tool.pyright] include = ["src", "tests", "scripts"] exclude = ["src/arcbox/_gen", ".venv"] diff --git a/sdk/python/scripts/gen_sync.py b/sdk/python/scripts/gen_sync.py index 2c6756e49..2e11e880d 100644 --- a/sdk/python/scripts/gen_sync.py +++ b/sdk/python/scripts/gen_sync.py @@ -73,7 +73,10 @@ def generate(target_dir: Path) -> None: # passes the repo gates verbatim. --config pins the project config # even when generating into a scratch dir (the --check path). config = str(SDK_ROOT / "pyproject.toml") - for args in (["check", "--fix", "--quiet"], ["format", "--quiet"]): + # --ignore UP028 mirrors the pyproject per-file-ignore for the sync + # tree (this invocation lints nothing else): the --check scratch dir + # sits outside the project root, where that pattern cannot anchor. + for args in (["check", "--fix", "--quiet", "--ignore", "UP028"], ["format", "--quiet"]): subprocess.run( ["ruff", *args, "--config", config, "--no-cache", str(target_dir)], check=True, diff --git a/sdk/python/src/arcbox/_async/_client.py b/sdk/python/src/arcbox/_async/_client.py index 88197fa76..f73478316 100644 --- a/sdk/python/src/arcbox/_async/_client.py +++ b/sdk/python/src/arcbox/_async/_client.py @@ -69,6 +69,9 @@ def __init__( #: and long-polls never receive it. self.request_timeout = resolved.request_timeout injected = connection.http_client if connection is not None else None + #: Whether this client constructed (and must eventually close) + #: its HTTP client; an injected one belongs to the caller. + self._owns_http = injected is None if injected is not None: required = httpx.AsyncClient if not isinstance(injected, required): @@ -90,6 +93,12 @@ def __init__( timeout=None, ) + async def aclose(self) -> None: + """Release the HTTP client's pooled connections. A no-op when the + client was injected — it belongs to the caller.""" + if self._owns_http: + await self._http.aclose() + async def unary( self, path: str, diff --git a/sdk/python/src/arcbox/_async/sandbox.py b/sdk/python/src/arcbox/_async/sandbox.py index 32e7c5e0a..24769503f 100644 --- a/sdk/python/src/arcbox/_async/sandbox.py +++ b/sdk/python/src/arcbox/_async/sandbox.py @@ -61,11 +61,30 @@ def _seconds_to_wire(seconds: float | None) -> int: class AsyncArcBox: """Client entry point. Holds one resolved connection; every handle it creates shares it. The :class:`AsyncSandbox` classmethods are sugar - over a throwaway instance resolved from options/environment.""" + over a throwaway instance resolved from options/environment. + + Usable as a context manager: exiting the scope closes the SDK-owned + HTTP client (handles created from this instance stop working); an + injected ``Connection.http_client`` is left to its owner.""" def __init__(self, connection: Connection | None = None) -> None: self._client = AsyncConnectClient(connection) + async def aclose(self) -> None: + """Close the SDK-owned HTTP client (no-op for an injected one).""" + await self._client.aclose() + + async def __aenter__(self) -> AsyncArcBox: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + async def create( self, template: str = "", @@ -321,6 +340,11 @@ class AsyncSandbox: def __init__(self, client: AsyncConnectClient, sandbox_id: str) -> None: self._client = client + # Flipped by the classmethod sugar, whose hidden entry point has + # no other referent: the handle then closes the client on + # context exit. Handles minted by a caller-held entry point + # share its client and never close it. + self._owns_client = False #: Sandbox id. self.id = sandbox_id #: Run processes inside the sandbox. @@ -345,38 +369,59 @@ async def create( wait_until_ready: bool = True, connection: Connection | None = None, ) -> AsyncSandbox: - """Create a sandbox against the default (or given) connection.""" - return await AsyncArcBox(connection).create( - template, - ttl=ttl, - idle_timeout=idle_timeout, - on_idle=on_idle, - vcpus=vcpus, - memory_mib=memory_mib, - cmd=cmd, - env=env, - labels=labels, - network=network, - wait_until_ready=wait_until_ready, - ) + """Create a sandbox against the default (or given) connection. + The handle owns the hidden entry point's HTTP client and closes + it on context exit; without the context manager, hold an + :class:`AsyncArcBox` instead for deterministic cleanup.""" + box = AsyncArcBox(connection) + try: + sandbox = await box.create( + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + wait_until_ready=wait_until_ready, + ) + except BaseException: + await box.aclose() + raise + sandbox._owns_client = True + return sandbox @classmethod async def connect( cls, sandbox_id: str, *, connection: Connection | None = None ) -> AsyncSandbox: - """Attach to an existing sandbox by id.""" - return await AsyncArcBox(connection).connect(sandbox_id) + """Attach to an existing sandbox by id. Client ownership works as + in :meth:`create`.""" + box = AsyncArcBox(connection) + try: + sandbox = await box.connect(sandbox_id) + except BaseException: + await box.aclose() + raise + sandbox._owns_client = True + return sandbox @classmethod - def list( + async def list( cls, *, state: SandboxState | None = None, labels: Mapping[str, str] | None = None, connection: Connection | None = None, ) -> AsyncIterator[SandboxSummary]: - """List sandboxes (auto-paginating).""" - return AsyncArcBox(connection).list(state=state, labels=labels) + """List sandboxes (auto-paginating). The hidden entry point's + HTTP client is closed when iteration finishes.""" + async with AsyncArcBox(connection) as box: + async for summary in box.list(state=state, labels=labels): + yield summary async def info(self) -> SandboxInfo: """Fetch the sandbox's current state — always fresh, never cached.""" @@ -432,5 +477,9 @@ async def __aexit__( # NotFoundError family, because the daemon does not attach # ErrorInfo details yet and a coarse NotFound on this id can only # mean the sandbox. - with suppress(NotFoundError): - await self.kill() + try: + with suppress(NotFoundError): + await self.kill() + finally: + if self._owns_client: + await self._client.aclose() diff --git a/sdk/python/src/arcbox/_sync/_client.py b/sdk/python/src/arcbox/_sync/_client.py index d9537e9aa..495275581 100644 --- a/sdk/python/src/arcbox/_sync/_client.py +++ b/sdk/python/src/arcbox/_sync/_client.py @@ -70,6 +70,9 @@ def __init__( #: and long-polls never receive it. self.request_timeout = resolved.request_timeout injected = connection.http_client if connection is not None else None + #: Whether this client constructed (and must eventually close) + #: its HTTP client; an injected one belongs to the caller. + self._owns_http = injected is None if injected is not None: required = httpx.Client if not isinstance(injected, required): @@ -91,6 +94,12 @@ def __init__( timeout=None, ) + def close(self) -> None: + """Release the HTTP client's pooled connections. A no-op when the + client was injected — it belongs to the caller.""" + if self._owns_http: + self._http.close() + def unary( self, path: str, diff --git a/sdk/python/src/arcbox/_sync/sandbox.py b/sdk/python/src/arcbox/_sync/sandbox.py index 7d6a15857..0c5e13140 100644 --- a/sdk/python/src/arcbox/_sync/sandbox.py +++ b/sdk/python/src/arcbox/_sync/sandbox.py @@ -62,11 +62,30 @@ def _seconds_to_wire(seconds: float | None) -> int: class ArcBox: """Client entry point. Holds one resolved connection; every handle it creates shares it. The :class:`AsyncSandbox` classmethods are sugar - over a throwaway instance resolved from options/environment.""" + over a throwaway instance resolved from options/environment. + + Usable as a context manager: exiting the scope closes the SDK-owned + HTTP client (handles created from this instance stop working); an + injected ``Connection.http_client`` is left to its owner.""" def __init__(self, connection: Connection | None = None) -> None: self._client = ConnectClient(connection) + def close(self) -> None: + """Close the SDK-owned HTTP client (no-op for an injected one).""" + self._client.close() + + def __enter__(self) -> ArcBox: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + def create( self, template: str = "", @@ -322,6 +341,11 @@ class Sandbox: def __init__(self, client: ConnectClient, sandbox_id: str) -> None: self._client = client + # Flipped by the classmethod sugar, whose hidden entry point has + # no other referent: the handle then closes the client on + # context exit. Handles minted by a caller-held entry point + # share its client and never close it. + self._owns_client = False #: Sandbox id. self.id = sandbox_id #: Run processes inside the sandbox. @@ -346,25 +370,43 @@ def create( wait_until_ready: bool = True, connection: Connection | None = None, ) -> Sandbox: - """Create a sandbox against the default (or given) connection.""" - return ArcBox(connection).create( - template, - ttl=ttl, - idle_timeout=idle_timeout, - on_idle=on_idle, - vcpus=vcpus, - memory_mib=memory_mib, - cmd=cmd, - env=env, - labels=labels, - network=network, - wait_until_ready=wait_until_ready, - ) + """Create a sandbox against the default (or given) connection. + The handle owns the hidden entry point's HTTP client and closes + it on context exit; without the context manager, hold an + :class:`AsyncArcBox` instead for deterministic cleanup.""" + box = ArcBox(connection) + try: + sandbox = box.create( + template, + ttl=ttl, + idle_timeout=idle_timeout, + on_idle=on_idle, + vcpus=vcpus, + memory_mib=memory_mib, + cmd=cmd, + env=env, + labels=labels, + network=network, + wait_until_ready=wait_until_ready, + ) + except BaseException: + box.close() + raise + sandbox._owns_client = True + return sandbox @classmethod def connect(cls, sandbox_id: str, *, connection: Connection | None = None) -> Sandbox: - """Attach to an existing sandbox by id.""" - return ArcBox(connection).connect(sandbox_id) + """Attach to an existing sandbox by id. Client ownership works as + in :meth:`create`.""" + box = ArcBox(connection) + try: + sandbox = box.connect(sandbox_id) + except BaseException: + box.close() + raise + sandbox._owns_client = True + return sandbox @classmethod def list( @@ -374,8 +416,11 @@ def list( labels: Mapping[str, str] | None = None, connection: Connection | None = None, ) -> Iterator[SandboxSummary]: - """List sandboxes (auto-paginating).""" - return ArcBox(connection).list(state=state, labels=labels) + """List sandboxes (auto-paginating). The hidden entry point's + HTTP client is closed when iteration finishes.""" + with ArcBox(connection) as box: + for summary in box.list(state=state, labels=labels): + yield summary def info(self) -> SandboxInfo: """Fetch the sandbox's current state — always fresh, never cached.""" @@ -431,5 +476,9 @@ def __exit__( # NotFoundError family, because the daemon does not attach # ErrorInfo details yet and a coarse NotFound on this id can only # mean the sandbox. - with suppress(NotFoundError): - self.kill() + try: + with suppress(NotFoundError): + self.kill() + finally: + if self._owns_client: + self._client.close() diff --git a/sdk/python/tests/test_disposal.py b/sdk/python/tests/test_disposal.py new file mode 100644 index 000000000..046d3a1de --- /dev/null +++ b/sdk/python/tests/test_disposal.py @@ -0,0 +1,74 @@ +"""HTTP-client ownership: SDK-owned clients close deterministically, +injected ones belong to the caller and are never touched. + +The ownership flags under test are deliberately private, so this file +asserts them directly instead of replaying full daemon flows. +""" +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import httpx +import pytest +from google.protobuf import empty_pb2 + +from arcbox import ArcBox, ConnectClient, Connection, Sandbox +from arcbox._gen import sandbox_pb2 + + +class MockSandboxService: + """Answers the two RPCs the disposal paths issue.""" + + def __init__(self) -> None: + self.removed: list[str] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/Create"): + body = sandbox_pb2.CreateSandboxResponse().SerializeToString() + elif path.endswith("/Remove"): + self.removed.append(sandbox_pb2.RemoveSandboxRequest.FromString(request.content).id) + body = empty_pb2.Empty().SerializeToString() + else: + return httpx.Response(404, content=b"unhandled: " + path.encode()) + return httpx.Response(200, content=body, headers={"content-type": "application/proto"}) + + +def injected_client(daemon: MockSandboxService) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + + +def test_arcbox_context_exit_closes_the_sdk_owned_client() -> None: + with ArcBox(Connection(socket_path="/tmp/never-dialed.sock")) as box: + pass + # httpx refuses requests on a closed client — the pool is gone. + with pytest.raises(RuntimeError, match="closed"): + box.connect("sb-1") + + +def test_an_injected_client_belongs_to_the_caller_and_stays_open() -> None: + http = injected_client(MockSandboxService()) + with ArcBox(Connection(http_client=http)): + pass + assert not http.is_closed + + +def test_classmethod_sugar_handles_own_their_hidden_client() -> None: + conn = Connection(http_client=injected_client(MockSandboxService())) + sugar = Sandbox.create("", wait_until_ready=False, connection=conn) + shared = ArcBox(conn).create("", wait_until_ready=False) + assert sugar._owns_client + assert not shared._owns_client + + +def test_sandbox_context_exit_closes_an_owned_client_after_kill() -> None: + daemon = MockSandboxService() + http = injected_client(daemon) + client = ConnectClient(Connection(http_client=http)) + client._owns_http = True # what the SDK-owned construction sets + sandbox = Sandbox(client, "sb-1") + sandbox._owns_client = True # what the classmethod sugar sets + with sandbox: + pass + assert daemon.removed == ["sb-1"] + assert http.is_closed diff --git a/sdk/python/tests/test_sync_parity.py b/sdk/python/tests/test_sync_parity.py index 0efad0ff0..6c3d6ca17 100644 --- a/sdk/python/tests/test_sync_parity.py +++ b/sdk/python/tests/test_sync_parity.py @@ -68,7 +68,11 @@ def _normalize(signature: inspect.Signature) -> str: def _normalize_name(name: str) -> str: - return name.replace("__aenter__", "__enter__").replace("__aexit__", "__exit__") + return ( + name.replace("__aenter__", "__enter__") + .replace("__aexit__", "__exit__") + .replace("aclose", "close") + ) @pytest.mark.parametrize(("async_cls", "sync_cls"), PAIRS, ids=lambda cls: cls.__name__) From a7e5056758cb139b2f4546b35e7e67b70f1ef469 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 02:33:21 +0800 Subject: [PATCH 17/20] fix(sdk): route non-Connect JSON error bodies to the HTTP fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every ConnectWireError field was defaulted, so any well-formed JSON object decoded as an empty Connect error and the HTTP-status fallback table was reachable only for invalid JSON — a gateway's 401 {"detail": ...} produced a bare unknown-code error instead of AuthenticationError. The spec always sends code, so make it required: a body without it fails typed validation and falls back to the status table, keeping one decoder at the untrusted boundary. --- sdk/python/src/arcbox/_envelope.py | 11 +++++++++-- sdk/python/tests/test_errors.py | 12 ++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/sdk/python/src/arcbox/_envelope.py b/sdk/python/src/arcbox/_envelope.py index 975622e0d..244e924bb 100644 --- a/sdk/python/src/arcbox/_envelope.py +++ b/sdk/python/src/arcbox/_envelope.py @@ -87,9 +87,16 @@ class ConnectErrorDetail(msgspec.Struct): class ConnectWireError(msgspec.Struct): - """The JSON `error` object of the Connect protocol.""" + """The JSON `error` object of the Connect protocol. - code: str = "unknown" + ``code`` is deliberately required (the spec always sends it): its + presence is what distinguishes Connect error JSON from an arbitrary + JSON body something in front of the daemon may return — a reverse + proxy's ``{"error": "Bad Gateway"}`` must route to the HTTP-status + fallback, not decode as an empty Connect error. + """ + + code: str message: str = "" details: list[ConnectErrorDetail] = msgspec.field(default_factory=list[ConnectErrorDetail]) diff --git a/sdk/python/tests/test_errors.py b/sdk/python/tests/test_errors.py index 464c26e18..c19b18097 100644 --- a/sdk/python/tests/test_errors.py +++ b/sdk/python/tests/test_errors.py @@ -14,6 +14,7 @@ from arcbox._gen import errors_pb2 from arcbox.errors import ( ArcBoxError, + AuthenticationError, CapabilityError, CommandTimeoutError, ConnectionFailedError, @@ -118,6 +119,17 @@ def test_non_json_bodies_fall_back_to_the_http_status_table() -> None: assert unknown.code == "unknown" +def test_valid_json_without_a_connect_code_also_falls_back() -> None: + # JSON, but not Connect error JSON: something in front of the daemon + # answered. The HTTP status is the only signal there is. + gateway = unary_error(401, b'{"detail": "token expired"}') + assert isinstance(gateway, AuthenticationError) + assert gateway.code == "unauthenticated" + proxy = unary_error(502, b'{"error": "Bad Gateway"}') + assert isinstance(proxy, ConnectionFailedError) + assert proxy.code == "unavailable" + + def test_wrap_errors_maps_connection_failures_with_a_suggestion() -> None: with pytest.raises(ConnectionFailedError) as exc_info, wrap_errors("sandbox.create"): raise httpx.ConnectError("[Errno 2] No such file or directory") From 78e049159970be3ca4d13ef56af821acc2d7af2a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 02:34:54 +0800 Subject: [PATCH 18/20] fix(sdk): stop connect() from waiting on READY for a PAUSING sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PAUSING sandbox's next stop is PAUSED — never READY — and no lifecycle event marks that edge, so routing PAUSING into the readiness wait parked connect() forever on a keepalive-fed Events stream. Poll the checkpoint out (0.5 s Inspect cadence), then route on the settled state: PAUSED resumes as before, READY returns, terminal states raise. Inherited from the TS SDK, which has the same routing (reported on the review thread; to be fixed there separately). --- sdk/python/src/arcbox/_async/sandbox.py | 24 ++++++---- sdk/python/src/arcbox/_sync/sandbox.py | 24 ++++++---- sdk/python/tests/test_connect.py | 58 +++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 sdk/python/tests/test_connect.py diff --git a/sdk/python/src/arcbox/_async/sandbox.py b/sdk/python/src/arcbox/_async/sandbox.py index 24769503f..dadc365ce 100644 --- a/sdk/python/src/arcbox/_async/sandbox.py +++ b/sdk/python/src/arcbox/_async/sandbox.py @@ -6,6 +6,7 @@ from __future__ import annotations +import asyncio import math import uuid from contextlib import suppress @@ -53,6 +54,10 @@ sandbox_pb2.SANDBOX_EVENT_KIND_REMOVED, ) +#: How often ``connect`` re-inspects a PAUSING sandbox. No lifecycle +#: event marks the PAUSING→PAUSED edge, so it is polled out. +_PAUSE_SETTLE_POLL_SECONDS = 0.5 + def _seconds_to_wire(seconds: float | None) -> int: return 0 if seconds is None else math.ceil(seconds) @@ -165,12 +170,18 @@ async def create( async def connect(self, sandbox_id: str) -> AsyncSandbox: """Attach to an existing sandbox. A PAUSED sandbox is resumed - (resume completes once it is READY again); a STARTING one is - waited for. A terminal state is a typed error carrying the - observed state. Connecting never touches the sandbox's lifecycle - deadlines.""" + (resume completes once it is READY again); a PAUSING one settles + to PAUSED first, then resumes; a STARTING one is waited for. A + terminal state is a typed error carrying the observed state. + Connecting never touches the sandbox's lifecycle deadlines.""" with wrap_errors("sandbox.connect"): info = await self._inspect(sandbox_id) + # A pausing sandbox's next stop is PAUSED — never READY — so + # waiting on readiness events would park forever. Poll the + # checkpoint out, then route on whatever state it settled in. + while info.state == sandbox_pb2.SANDBOX_STATE_PAUSING: + await asyncio.sleep(_PAUSE_SETTLE_POLL_SECONDS) + info = await self._inspect(sandbox_id) if info.state in _READY_STATES: return AsyncSandbox(self._client, sandbox_id) if info.state == sandbox_pb2.SANDBOX_STATE_PAUSED: @@ -184,10 +195,7 @@ async def connect(self, sandbox_id: str) -> AsyncSandbox: timeout=None, ) return AsyncSandbox(self._client, sandbox_id) - if info.state in ( - sandbox_pb2.SANDBOX_STATE_STARTING, - sandbox_pb2.SANDBOX_STATE_PAUSING, - ): + if info.state == sandbox_pb2.SANDBOX_STATE_STARTING: async with self._client.stream( _SANDBOX + "Events", sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), diff --git a/sdk/python/src/arcbox/_sync/sandbox.py b/sdk/python/src/arcbox/_sync/sandbox.py index 0c5e13140..2a13e4230 100644 --- a/sdk/python/src/arcbox/_sync/sandbox.py +++ b/sdk/python/src/arcbox/_sync/sandbox.py @@ -8,6 +8,7 @@ from __future__ import annotations import math +import time import uuid from contextlib import suppress from typing import TYPE_CHECKING @@ -54,6 +55,10 @@ sandbox_pb2.SANDBOX_EVENT_KIND_REMOVED, ) +#: How often ``connect`` re-inspects a PAUSING sandbox. No lifecycle +#: event marks the PAUSING→PAUSED edge, so it is polled out. +_PAUSE_SETTLE_POLL_SECONDS = 0.5 + def _seconds_to_wire(seconds: float | None) -> int: return 0 if seconds is None else math.ceil(seconds) @@ -166,12 +171,18 @@ def create( def connect(self, sandbox_id: str) -> Sandbox: """Attach to an existing sandbox. A PAUSED sandbox is resumed - (resume completes once it is READY again); a STARTING one is - waited for. A terminal state is a typed error carrying the - observed state. Connecting never touches the sandbox's lifecycle - deadlines.""" + (resume completes once it is READY again); a PAUSING one settles + to PAUSED first, then resumes; a STARTING one is waited for. A + terminal state is a typed error carrying the observed state. + Connecting never touches the sandbox's lifecycle deadlines.""" with wrap_errors("sandbox.connect"): info = self._inspect(sandbox_id) + # A pausing sandbox's next stop is PAUSED — never READY — so + # waiting on readiness events would park forever. Poll the + # checkpoint out, then route on whatever state it settled in. + while info.state == sandbox_pb2.SANDBOX_STATE_PAUSING: + time.sleep(_PAUSE_SETTLE_POLL_SECONDS) + info = self._inspect(sandbox_id) if info.state in _READY_STATES: return Sandbox(self._client, sandbox_id) if info.state == sandbox_pb2.SANDBOX_STATE_PAUSED: @@ -185,10 +196,7 @@ def connect(self, sandbox_id: str) -> Sandbox: timeout=None, ) return Sandbox(self._client, sandbox_id) - if info.state in ( - sandbox_pb2.SANDBOX_STATE_STARTING, - sandbox_pb2.SANDBOX_STATE_PAUSING, - ): + if info.state == sandbox_pb2.SANDBOX_STATE_STARTING: with self._client.stream( _SANDBOX + "Events", sandbox_pb2.SandboxEventsRequest(sandbox_id=sandbox_id), diff --git a/sdk/python/tests/test_connect.py b/sdk/python/tests/test_connect.py new file mode 100644 index 000000000..8e27fd10e --- /dev/null +++ b/sdk/python/tests/test_connect.py @@ -0,0 +1,58 @@ +"""connect() lifecycle routing against a mock daemon. + +The PAUSING case is the regression that matters: a pausing sandbox's +next stop is PAUSED — never READY — so connect must poll the checkpoint +out and then resume, not wait on readiness events that cannot arrive. +""" + +from __future__ import annotations + +import httpx +from google.protobuf import empty_pb2 + +from arcbox import ArcBox, Connection +from arcbox._gen import sandbox_pb2 + + +class MockLifecycle: + """Serves Inspect from a state script and records Resume calls.""" + + def __init__(self, states: list[sandbox_pb2.SandboxState]) -> None: + #: Consumed one per Inspect; the last entry repeats. + self.states = states + self.resumes = 0 + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/Inspect"): + state = self.states.pop(0) if len(self.states) > 1 else self.states[0] + body = sandbox_pb2.SandboxInfo(id="sb-1", state=state).SerializeToString() + elif path.endswith("/Resume"): + self.resumes += 1 + self.states = [sandbox_pb2.SANDBOX_STATE_READY] + body = empty_pb2.Empty().SerializeToString() + else: + return httpx.Response(404, content=b"unhandled: " + path.encode()) + return httpx.Response(200, content=body, headers={"content-type": "application/proto"}) + + +def connect_against(daemon: MockLifecycle) -> ArcBox: + http = httpx.Client(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + return ArcBox(Connection(http_client=http)) + + +def test_connect_resumes_a_paused_sandbox() -> None: + daemon = MockLifecycle([sandbox_pb2.SANDBOX_STATE_PAUSED]) + sandbox = connect_against(daemon).connect("sb-1") + assert sandbox.id == "sb-1" + assert daemon.resumes == 1 + + +def test_connect_settles_a_pausing_sandbox_then_resumes() -> None: + daemon = MockLifecycle([sandbox_pb2.SANDBOX_STATE_PAUSING, sandbox_pb2.SANDBOX_STATE_PAUSED]) + sandbox = connect_against(daemon).connect("sb-1") + assert sandbox.id == "sb-1" + # The checkpoint was polled out (a second Inspect ran) and exactly + # one Resume followed — not a readiness-event wait that never ends. + assert daemon.states == [sandbox_pb2.SANDBOX_STATE_READY] + assert daemon.resumes == 1 From e96f889d437d4a5c3e6946cf34a15c3394aa2741 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 02:38:01 +0800 Subject: [PATCH 19/20] fix(sdk): make early exit from commands.output releasable at the break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking out of the output iteration left the async generator — and its open HTTP stream — suspended until the event loop's asyncgen finalizer got around to it. commands.output now returns AsyncOutputStream / OutputStream: still directly iterable (full consumption closes as before), and an async context manager whose scope exit closes the underlying stream at the break instead of at finalization. Exported, paired in the parity test, and documented on the property. --- sdk/python/scripts/gen_sync.py | 1 + sdk/python/src/arcbox/__init__.py | 6 ++-- sdk/python/src/arcbox/_async/commands.py | 43 +++++++++++++++++++++--- sdk/python/src/arcbox/_sync/commands.py | 43 +++++++++++++++++++++--- sdk/python/tests/test_commands.py | 25 ++++++++++++++ sdk/python/tests/test_sync_parity.py | 3 ++ 6 files changed, 109 insertions(+), 12 deletions(-) diff --git a/sdk/python/scripts/gen_sync.py b/sdk/python/scripts/gen_sync.py index 2e11e880d..befe26db2 100644 --- a/sdk/python/scripts/gen_sync.py +++ b/sdk/python/scripts/gen_sync.py @@ -36,6 +36,7 @@ "AsyncSandbox": "Sandbox", "AsyncCommands": "Commands", "AsyncCommandHandle": "CommandHandle", + "AsyncOutputStream": "OutputStream", "AsyncFiles": "Files", "AsyncConnectClient": "ConnectClient", "AsyncServerStream": "ServerStream", diff --git a/sdk/python/src/arcbox/__init__.py b/sdk/python/src/arcbox/__init__.py index 0a149ce2e..cb2f2e942 100644 --- a/sdk/python/src/arcbox/__init__.py +++ b/sdk/python/src/arcbox/__init__.py @@ -8,12 +8,12 @@ """ from arcbox._async._client import AsyncConnectClient -from arcbox._async.commands import AsyncCommandHandle, AsyncCommands +from arcbox._async.commands import AsyncCommandHandle, AsyncCommands, AsyncOutputStream from arcbox._async.files import AsyncFiles from arcbox._async.sandbox import AsyncArcBox, AsyncSandbox from arcbox._connection import Connection from arcbox._sync._client import ConnectClient -from arcbox._sync.commands import CommandHandle, Commands +from arcbox._sync.commands import CommandHandle, Commands, OutputStream from arcbox._sync.files import Files from arcbox._sync.sandbox import ArcBox, Sandbox from arcbox._types import ( @@ -36,6 +36,7 @@ "AsyncCommands", "AsyncConnectClient", "AsyncFiles", + "AsyncOutputStream", "AsyncSandbox", "CommandHandle", "CommandResult", @@ -46,6 +47,7 @@ "IdlePolicy", "OutputChannel", "OutputChunk", + "OutputStream", "Sandbox", "SandboxInfo", "SandboxState", diff --git a/sdk/python/src/arcbox/_async/commands.py b/sdk/python/src/arcbox/_async/commands.py index 5b321530a..fc269448d 100644 --- a/sdk/python/src/arcbox/_async/commands.py +++ b/sdk/python/src/arcbox/_async/commands.py @@ -21,7 +21,8 @@ from arcbox.errors import InvalidArgumentError, TimeoutError if TYPE_CHECKING: - from collections.abc import AsyncIterator, Mapping, Sequence + from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence + from types import TracebackType from arcbox._types import OutputChannel, SignalName @@ -52,6 +53,37 @@ def _decode_output(chunks: list[bytes]) -> str: return b"".join(chunks).decode("utf-8", errors="replace") +class AsyncOutputStream: + """A command's output, iterable chunk by chunk. + + Consuming it to the end releases the underlying HTTP stream + deterministically. When exiting early (``break``), iterate inside + the context-manager form — ``async with handle.output as stream`` in + the async flavor, ``with`` in the sync one — so the stream closes at + the break instead of whenever the generator finalizer runs.""" + + def __init__(self, chunks: AsyncGenerator[OutputChunk]) -> None: + self._chunks = chunks + + def __aiter__(self) -> AsyncIterator[OutputChunk]: + return self._chunks + + async def aclose(self) -> None: + """Release the underlying HTTP stream without consuming the rest.""" + await self._chunks.aclose() + + async def __aenter__(self) -> AsyncOutputStream: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + class AsyncCommandHandle: """A handle to a running (or finished) command. The process is decoupled from this object: dropping the handle never kills the @@ -64,15 +96,16 @@ def __init__(self, client: AsyncConnectClient, sandbox_id: str, command_id: str) self.command_id = command_id @property - def output(self) -> AsyncIterator[OutputChunk]: + def output(self) -> AsyncOutputStream: """Stream the command's output from the beginning — or from the earliest byte the daemon still retains (8 MiB per channel); replayed buffered output comes first, then live output follows; the stream ends when the process exits (deterministic - termination — never silence).""" - return self._stream_output() + termination — never silence). When breaking out early, iterate + via the context-manager form (see :class:`AsyncOutputStream`).""" + return AsyncOutputStream(self._stream_output()) - async def _stream_output(self) -> AsyncIterator[OutputChunk]: + async def _stream_output(self) -> AsyncGenerator[OutputChunk]: with wrap_errors("commands.output"): async with self._attach() as stream: async for event in stream: diff --git a/sdk/python/src/arcbox/_sync/commands.py b/sdk/python/src/arcbox/_sync/commands.py index 4bafaee25..50eb6bc9a 100644 --- a/sdk/python/src/arcbox/_sync/commands.py +++ b/sdk/python/src/arcbox/_sync/commands.py @@ -21,7 +21,8 @@ from arcbox.errors import InvalidArgumentError, TimeoutError if TYPE_CHECKING: - from collections.abc import Iterator, Mapping, Sequence + from collections.abc import Generator, Iterator, Mapping, Sequence + from types import TracebackType from arcbox._types import OutputChannel, SignalName @@ -52,6 +53,37 @@ def _decode_output(chunks: list[bytes]) -> str: return b"".join(chunks).decode("utf-8", errors="replace") +class OutputStream: + """A command's output, iterable chunk by chunk. + + Consuming it to the end releases the underlying HTTP stream + deterministically. When exiting early (``break``), iterate inside + the context-manager form — ``async with handle.output as stream`` in + the async flavor, ``with`` in the sync one — so the stream closes at + the break instead of whenever the generator finalizer runs.""" + + def __init__(self, chunks: Generator[OutputChunk]) -> None: + self._chunks = chunks + + def __iter__(self) -> Iterator[OutputChunk]: + return self._chunks + + def close(self) -> None: + """Release the underlying HTTP stream without consuming the rest.""" + self._chunks.close() + + def __enter__(self) -> OutputStream: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + class CommandHandle: """A handle to a running (or finished) command. The process is decoupled from this object: dropping the handle never kills the @@ -64,15 +96,16 @@ def __init__(self, client: ConnectClient, sandbox_id: str, command_id: str) -> N self.command_id = command_id @property - def output(self) -> Iterator[OutputChunk]: + def output(self) -> OutputStream: """Stream the command's output from the beginning — or from the earliest byte the daemon still retains (8 MiB per channel); replayed buffered output comes first, then live output follows; the stream ends when the process exits (deterministic - termination — never silence).""" - return self._stream_output() + termination — never silence). When breaking out early, iterate + via the context-manager form (see :class:`AsyncOutputStream`).""" + return OutputStream(self._stream_output()) - def _stream_output(self) -> Iterator[OutputChunk]: + def _stream_output(self) -> Generator[OutputChunk]: with wrap_errors("commands.output"), self._attach() as stream: for event in stream: kind = event.WhichOneof("event") diff --git a/sdk/python/tests/test_commands.py b/sdk/python/tests/test_commands.py index 02a6e274f..8a33d63a9 100644 --- a/sdk/python/tests/test_commands.py +++ b/sdk/python/tests/test_commands.py @@ -217,6 +217,31 @@ def test_retention_gap_reports_truncation() -> None: assert result.stdout == "tail" +def test_output_context_closes_the_stream_on_early_exit() -> None: + daemon = MockDaemon(exit_code=0) + handle = sync_sandbox(daemon).commands.run("emit", background=True) + with handle.output as stream: + for _chunk in stream: + break + # The underlying generator was closed at scope exit, releasing the + # HTTP stream; resuming it yields nothing. + with pytest.raises(StopIteration): + next(iter(stream)) + + +@pytest.mark.anyio +async def test_async_output_context_closes_the_stream_on_early_exit() -> None: + daemon = MockDaemon(exit_code=0) + http = httpx.AsyncClient(transport=httpx.MockTransport(daemon), base_url="http://arcbox") + sandbox = AsyncSandbox(AsyncConnectClient(Connection(http_client=http)), "sb-1") + handle = await sandbox.commands.run("emit", background=True) + async with handle.output as stream: + async for _chunk in stream: + break + with pytest.raises(StopAsyncIteration): + await anext(aiter(stream)) + + @pytest.mark.anyio async def test_async_tree_runs_the_same_loop() -> None: daemon = MockDaemon(exit_code=0) diff --git a/sdk/python/tests/test_sync_parity.py b/sdk/python/tests/test_sync_parity.py index 6c3d6ca17..429091124 100644 --- a/sdk/python/tests/test_sync_parity.py +++ b/sdk/python/tests/test_sync_parity.py @@ -25,11 +25,13 @@ AsyncCommands, AsyncConnectClient, AsyncFiles, + AsyncOutputStream, AsyncSandbox, CommandHandle, Commands, ConnectClient, Files, + OutputStream, Sandbox, ) @@ -40,6 +42,7 @@ (AsyncSandbox, Sandbox), (AsyncCommands, Commands), (AsyncCommandHandle, CommandHandle), + (AsyncOutputStream, OutputStream), (AsyncFiles, Files), (AsyncConnectClient, ConnectClient), ] From b3fb8bca024daa280b4c87f6e5e3ee1b71059396 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 02:50:13 +0800 Subject: [PATCH 20/20] fix(sdk): poll before sleeping in the sub-second wait tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An already-exited command paid the full sub-second remainder before its state was ever read. The tail is now poll–sleep–poll: an immediate poll returns a finished command at once; only a still-running one sleeps out the remainder for one final poll, so an exit inside the budget still yields a result instead of a false TimeoutError. The bound is exceeded only by that final immediate poll's round trip, which exit-as-data semantics make irreducible. Also states plainly in the list() docstring that abandoning the iterator defers client closure to generator finalization, with the ArcBox/aclosing guidance. --- sdk/python/src/arcbox/_async/commands.py | 50 +++++++++++++----------- sdk/python/src/arcbox/_async/sandbox.py | 6 ++- sdk/python/src/arcbox/_sync/commands.py | 50 +++++++++++++----------- sdk/python/src/arcbox/_sync/sandbox.py | 6 ++- sdk/python/tests/test_commands.py | 28 +++++++++++-- 5 files changed, 91 insertions(+), 49 deletions(-) diff --git a/sdk/python/src/arcbox/_async/commands.py b/sdk/python/src/arcbox/_async/commands.py index fc269448d..68f620108 100644 --- a/sdk/python/src/arcbox/_async/commands.py +++ b/sdk/python/src/arcbox/_async/commands.py @@ -134,33 +134,39 @@ async def wait_for_exit(self, timeout: float | None = None) -> CommandResult: context={"command_id": self.command_id}, ) if remaining is None: - slice_seconds = _WAIT_SLICE_SECONDS - elif remaining < 1: - # The wire's wait granularity is whole seconds: - # sleep out the sub-second remainder, then ask for - # the current state (0 = return immediately) so the - # documented timeout bound holds. - await asyncio.sleep(remaining) - slice_seconds = 0 + execution = await self._wait_slice(_WAIT_SLICE_SECONDS) + elif remaining >= 1: + execution = await self._wait_slice(min(_WAIT_SLICE_SECONDS, int(remaining))) else: - slice_seconds = min(_WAIT_SLICE_SECONDS, int(remaining)) - execution = await self._client.unary( - _PROCESS + "WaitExecution", - process_pb2.WaitExecutionRequest( - sandbox_id=self._sandbox_id, - execution_id=self.command_id, - timeout_seconds=slice_seconds, - ), - process_pb2.Execution, - # Exempt from request_timeout: this unary deliberately - # parks server-side for the slice; grant it that long - # plus grace. - timeout=float(slice_seconds + 5), - ) + # The wire's wait granularity is whole seconds. Poll + # immediately (an already-exited command returns at + # once); only if it is still running, sleep out the + # remainder and take one final poll so an exit inside + # the budget is still caught — past it, the deadline + # check above raises. + execution = await self._wait_slice(0) + if execution.state != process_pb2.EXECUTION_STATE_EXITED: + await asyncio.sleep(remaining) + execution = await self._wait_slice(0) if execution.state == process_pb2.EXECUTION_STATE_EXITED: break return await self._collect_result(execution) + async def _wait_slice(self, slice_seconds: int) -> process_pb2.Execution: + """One WaitExecution long-poll (0 = an immediate state poll).""" + return await self._client.unary( + _PROCESS + "WaitExecution", + process_pb2.WaitExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + timeout_seconds=slice_seconds, + ), + process_pb2.Execution, + # Exempt from request_timeout: this unary deliberately parks + # server-side for the slice; grant it that long plus grace. + timeout=float(slice_seconds + 5), + ) + async def kill(self, signal: SignalName = "SIGTERM") -> None: """Deliver a signal to the whole process group (default SIGTERM).""" with wrap_errors("commands.kill"): diff --git a/sdk/python/src/arcbox/_async/sandbox.py b/sdk/python/src/arcbox/_async/sandbox.py index dadc365ce..c0d01f524 100644 --- a/sdk/python/src/arcbox/_async/sandbox.py +++ b/sdk/python/src/arcbox/_async/sandbox.py @@ -426,7 +426,11 @@ async def list( connection: Connection | None = None, ) -> AsyncIterator[SandboxSummary]: """List sandboxes (auto-paginating). The hidden entry point's - HTTP client is closed when iteration finishes.""" + HTTP client closes when iteration completes or the iterator is + closed; abandoning it mid-iteration defers that to generator + finalization, so callers that may stop early should hold an + ArcBox entry point instead (or close the iterator explicitly, + e.g. ``contextlib.aclosing`` in the async flavor).""" async with AsyncArcBox(connection) as box: async for summary in box.list(state=state, labels=labels): yield summary diff --git a/sdk/python/src/arcbox/_sync/commands.py b/sdk/python/src/arcbox/_sync/commands.py index 50eb6bc9a..5020cd5cc 100644 --- a/sdk/python/src/arcbox/_sync/commands.py +++ b/sdk/python/src/arcbox/_sync/commands.py @@ -133,33 +133,39 @@ def wait_for_exit(self, timeout: float | None = None) -> CommandResult: context={"command_id": self.command_id}, ) if remaining is None: - slice_seconds = _WAIT_SLICE_SECONDS - elif remaining < 1: - # The wire's wait granularity is whole seconds: - # sleep out the sub-second remainder, then ask for - # the current state (0 = return immediately) so the - # documented timeout bound holds. - time.sleep(remaining) - slice_seconds = 0 + execution = self._wait_slice(_WAIT_SLICE_SECONDS) + elif remaining >= 1: + execution = self._wait_slice(min(_WAIT_SLICE_SECONDS, int(remaining))) else: - slice_seconds = min(_WAIT_SLICE_SECONDS, int(remaining)) - execution = self._client.unary( - _PROCESS + "WaitExecution", - process_pb2.WaitExecutionRequest( - sandbox_id=self._sandbox_id, - execution_id=self.command_id, - timeout_seconds=slice_seconds, - ), - process_pb2.Execution, - # Exempt from request_timeout: this unary deliberately - # parks server-side for the slice; grant it that long - # plus grace. - timeout=float(slice_seconds + 5), - ) + # The wire's wait granularity is whole seconds. Poll + # immediately (an already-exited command returns at + # once); only if it is still running, sleep out the + # remainder and take one final poll so an exit inside + # the budget is still caught — past it, the deadline + # check above raises. + execution = self._wait_slice(0) + if execution.state != process_pb2.EXECUTION_STATE_EXITED: + time.sleep(remaining) + execution = self._wait_slice(0) if execution.state == process_pb2.EXECUTION_STATE_EXITED: break return self._collect_result(execution) + def _wait_slice(self, slice_seconds: int) -> process_pb2.Execution: + """One WaitExecution long-poll (0 = an immediate state poll).""" + return self._client.unary( + _PROCESS + "WaitExecution", + process_pb2.WaitExecutionRequest( + sandbox_id=self._sandbox_id, + execution_id=self.command_id, + timeout_seconds=slice_seconds, + ), + process_pb2.Execution, + # Exempt from request_timeout: this unary deliberately parks + # server-side for the slice; grant it that long plus grace. + timeout=float(slice_seconds + 5), + ) + def kill(self, signal: SignalName = "SIGTERM") -> None: """Deliver a signal to the whole process group (default SIGTERM).""" with wrap_errors("commands.kill"): diff --git a/sdk/python/src/arcbox/_sync/sandbox.py b/sdk/python/src/arcbox/_sync/sandbox.py index 2a13e4230..b17704fc4 100644 --- a/sdk/python/src/arcbox/_sync/sandbox.py +++ b/sdk/python/src/arcbox/_sync/sandbox.py @@ -425,7 +425,11 @@ def list( connection: Connection | None = None, ) -> Iterator[SandboxSummary]: """List sandboxes (auto-paginating). The hidden entry point's - HTTP client is closed when iteration finishes.""" + HTTP client closes when iteration completes or the iterator is + closed; abandoning it mid-iteration defers that to generator + finalization, so callers that may stop early should hold an + ArcBox entry point instead (or close the iterator explicitly, + e.g. ``contextlib.aclosing`` in the async flavor).""" with ArcBox(connection) as box: for summary in box.list(state=state, labels=labels): yield summary diff --git a/sdk/python/tests/test_commands.py b/sdk/python/tests/test_commands.py index 8a33d63a9..1b6b3355b 100644 --- a/sdk/python/tests/test_commands.py +++ b/sdk/python/tests/test_commands.py @@ -196,18 +196,40 @@ def test_wait_slices_are_capped_and_floored_to_the_wire_granularity() -> None: def test_sub_second_wait_honors_its_deadline() -> None: daemon = MockDaemon(exit_code=0) - daemon.wait_states = [process_pb2.EXECUTION_STATE_RUNNING] + daemon.wait_states = [process_pb2.EXECUTION_STATE_RUNNING] * 2 handle = sync_sandbox(daemon).commands.run("spin", background=True) started = time.monotonic() with pytest.raises(TimeoutError): handle.wait_for_exit(0.2) elapsed = time.monotonic() - started - # One immediate poll (timeout_seconds=0) after sleeping out the + # Two immediate polls (timeout_seconds=0) bracketing the slept-out # remainder — not the old 1 s minimum server slice. - assert [w.timeout_seconds for w in daemon.waits] == [0] + assert [w.timeout_seconds for w in daemon.waits] == [0, 0] assert 0.2 <= elapsed < 1.0 +def test_sub_second_wait_returns_a_finished_command_without_sleeping() -> None: + daemon = MockDaemon(exit_code=0) + handle = sync_sandbox(daemon).commands.run("spin", background=True) + started = time.monotonic() + result = handle.wait_for_exit(0.5) + # The immediate poll came back EXITED: no sleep, no second poll. + assert result.exit_code == 0 + assert [w.timeout_seconds for w in daemon.waits] == [0] + assert time.monotonic() - started < 0.2 + + +def test_sub_second_wait_catches_an_exit_inside_the_budget() -> None: + daemon = MockDaemon(exit_code=0) + daemon.wait_states = [process_pb2.EXECUTION_STATE_RUNNING] + handle = sync_sandbox(daemon).commands.run("spin", background=True) + # First poll sees RUNNING; the exit lands during the slept-out + # remainder and the final poll reports it — a result, not a timeout. + result = handle.wait_for_exit(0.2) + assert result.exit_code == 0 + assert [w.timeout_seconds for w in daemon.waits] == [0, 0] + + def test_retention_gap_reports_truncation() -> None: daemon = MockDaemon(exit_code=0) # First retained stdout byte starts at offset 100: the head was dropped.