chore: import upstream snapshot with attribution
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:33 +08:00
commit e0e362d700
1949 changed files with 375388 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
.PHONY: sync sync-dev test test-k8s test-sandbox test-manager test-code test-pool test-credential-vault lint fmt
sync:
uv sync
sync-dev:
uv sync --group dev
test:
uv run pytest
test-kubernetes-mini:
uv run pytest \
tests/test_sandbox_e2e_sync.py \
tests/test_sandbox_manager_e2e_sync.py
test-sandbox:
uv run pytest tests/test_sandbox_e2e.py
test-manager:
uv run pytest tests/test_sandbox_manager_e2e.py
test-code:
uv run pytest tests/test_code_interpreter_e2e.py
test-pool:
uv run pytest tests/test_sandbox_pool_e2e_sync.py tests/test_sandbox_pool_e2e_async.py
test-credential-vault:
uv run pytest tests/test_credential_vault_e2e.py
lint:
uv run ruff check tests
fmt:
uv run ruff format tests
+53
View File
@@ -0,0 +1,53 @@
## OpenSandbox Python SDK E2E Tests (uv)
This folder is a standalone e2e test project managed by **uv**.
### Setup
```bash
cd tests/python
uv sync
```
### Run tests
```bash
uv run pytest
```
Run a specific suite:
```bash
uv run pytest tests/test_sandbox_e2e.py
uv run pytest tests/test_sandbox_pool_e2e_sync.py tests/test_sandbox_pool_e2e_async.py
uv run pytest tests/test_credential_vault_e2e.py
```
Redis-backed pool E2E tests are skipped unless `OPENSANDBOX_TEST_REDIS_URL` is set,
for example `redis://127.0.0.1:6379/0`.
Credential Vault E2E tests require a reachable target service and
`OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP`. The repository E2E scripts start
the target service and run the Vault tests as part of each language's normal
E2E suite:
```bash
../../scripts/python-e2e.sh
```
### Notes about asyncio + shared Sandbox
These tests may reuse a single Sandbox instance across multiple test cases for speed.
To avoid `RuntimeError: Event loop is closed`, pytest-asyncio is configured to use a
**session-scoped event loop** in `pyproject.toml`.
### Handy shortcuts
```bash
make sync
make test
make test-sandbox
make test-pool
make lint
make fmt
```
+102
View File
@@ -0,0 +1,102 @@
# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[project]
name = "opensandbox-e2e-tests"
version = "0.1.0"
description = "E2E tests for OpenSandbox Python SDK"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
{ name = "OpenSandbox Team", email = "ninan.nn@alibaba-inc.com" }
]
dependencies = [
"pytest>=9.0.3",
"pytest-asyncio>=0.21.0",
"pytest-timeout>=2.1.0",
"pytest-order>=1.2.0",
"pydantic>=2.0.0",
"opensandbox",
"opensandbox-code-interpreter",
"redis>=5.0,<6.0",
]
[dependency-groups]
dev = [
"ruff>=0.14.8",
"pyright>=1.1.407",
]
[tool.uv]
# This is a test runner project (no importable package); don't try to build/install it.
package = false
constraint-dependencies = [
"pyjwt>=2.13.0",
]
[tool.uv.sources]
opensandbox = { path = "../../sdks/sandbox/python", editable = true }
opensandbox-code-interpreter = { path = "../../sdks/code-interpreter/python", editable = true }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"-s",
"--tb=short",
"--strict-markers",
"--asyncio-mode=auto",
"--order-scope=class",
]
markers = [
"e2e: marks tests as end-to-end tests",
"slow: marks tests as slow running",
"order: run tests in specific order",
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
timeout = 300
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)s] %(name)s - %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long, handled by formatter
"B008", # do not perform function calls in argument defaults
"C901", # too complex
"B017", # pytest.raises(Exception) is too broad
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
+15
View File
@@ -0,0 +1,15 @@
#
# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
+161
View File
@@ -0,0 +1,161 @@
#
# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Base class for E2E tests providing common setup and configuration.
"""
import os
from datetime import timedelta
import httpx
from opensandbox.config import ConnectionConfig, ConnectionConfigSync
DEFAULT_DOMAIN = "localhost:8080"
DEFAULT_PROTOCOL = "http"
DEFAULT_API_KEY = "e2e-test"
DEFAULT_IMAGE = "opensandbox/code-interpreter:latest"
DEFAULT_RUNTIME = "docker"
DEFAULT_USE_SERVER_PROXY = "false"
DEFAULT_PVC_NAME = "opensandbox-e2e-pvc-test"
DEFAULT_HOST_VOLUME_DIR = "/tmp/opensandbox-e2e/host-volume-test"
TEST_DOMAIN = os.getenv("OPENSANDBOX_TEST_DOMAIN", DEFAULT_DOMAIN)
TEST_PROTOCOL = os.getenv("OPENSANDBOX_TEST_PROTOCOL", DEFAULT_PROTOCOL)
TEST_API_KEY = os.getenv("OPENSANDBOX_TEST_API_KEY", DEFAULT_API_KEY)
TEST_IMAGE = os.getenv("OPENSANDBOX_SANDBOX_DEFAULT_IMAGE", DEFAULT_IMAGE)
TEST_RUNTIME = os.getenv("OPENSANDBOX_E2E_RUNTIME", DEFAULT_RUNTIME).lower()
TEST_USE_SERVER_PROXY = os.getenv(
"OPENSANDBOX_TEST_USE_SERVER_PROXY", DEFAULT_USE_SERVER_PROXY
).lower() in {"1", "true", "yes", "on"}
TEST_PVC_NAME = os.getenv("OPENSANDBOX_TEST_PVC_NAME", DEFAULT_PVC_NAME)
TEST_HOST_VOLUME_DIR = os.getenv(
"OPENSANDBOX_TEST_HOST_VOLUME_DIR", DEFAULT_HOST_VOLUME_DIR
)
# When ``true`` (case-insensitive, per Java ``Boolean.parseBoolean``), the shared
# E2E sandbox is created with ``secure_access=True`` (gateway/ingress can enforce
# ``OpenSandbox-Secure-Access``). Default off for local docker-style runs.
OPENSANDBOX_TEST_SECURE_ACCESS_VERIFIABLE = "OPENSANDBOX_TEST_SECURE_ACCESS_VERIFIABLE"
def get_sandbox_image() -> str:
"""Get the default sandbox image for E2E tests."""
return TEST_IMAGE
def get_e2e_sandbox_resource() -> dict[str, str]:
"""
CPU/memory for ``Sandbox.create`` / ``SandboxSync.create`` in E2E tests.
Read from ``OPENSANDBOX_E2E_SANDBOX_CPU`` and ``OPENSANDBOX_E2E_SANDBOX_MEMORY``.
When unset, matches the SDK default (1 CPU, 2Gi memory).
Kubernetes E2E entry scripts (``scripts/python-k8s-e2e*.sh``) export smaller
defaults for Kind; override via the same env vars.
"""
return {
"cpu": os.getenv("OPENSANDBOX_E2E_SANDBOX_CPU", "1"),
"memory": os.getenv("OPENSANDBOX_E2E_SANDBOX_MEMORY", "2Gi"),
}
def is_kubernetes_runtime() -> bool:
"""Whether the current E2E run targets the Kubernetes backend."""
return TEST_RUNTIME == "kubernetes"
def is_secure_access_verifiable() -> bool:
"""
True when ``OPENSANDBOX_TEST_SECURE_ACCESS_VERIFIABLE`` is ``"true"`` after
strip, case-insensitive (same as Java E2E ``Boolean.parseBoolean``).
"""
v = os.getenv(OPENSANDBOX_TEST_SECURE_ACCESS_VERIFIABLE)
if v is None:
return False
return v.strip().lower() == "true"
def should_use_server_proxy() -> bool:
"""Whether SDK calls should proxy execd traffic through the server."""
return TEST_USE_SERVER_PROXY
def get_test_pvc_name() -> str:
"""Get the PVC name used by runtime E2E tests."""
return TEST_PVC_NAME
def get_test_host_volume_dir() -> str:
"""Get the host directory used by host-volume E2E tests."""
return TEST_HOST_VOLUME_DIR
def create_connection_config() -> ConnectionConfig:
"""Create async ConnectionConfig for E2E tests."""
return ConnectionConfig(
domain=TEST_DOMAIN,
api_key=TEST_API_KEY,
request_timeout=timedelta(minutes=3),
protocol=TEST_PROTOCOL,
use_server_proxy=should_use_server_proxy(),
)
def create_connection_config_server_proxy() -> ConnectionConfig:
"""Create async ConnectionConfig for E2E tests using server-proxied endpoints."""
return ConnectionConfig(
domain=TEST_DOMAIN,
api_key=TEST_API_KEY,
request_timeout=timedelta(minutes=3),
protocol=TEST_PROTOCOL,
use_server_proxy=True,
)
def create_connection_config_sync() -> ConnectionConfigSync:
"""Create sync ConnectionConfig for E2E tests."""
return ConnectionConfigSync(
domain=TEST_DOMAIN,
api_key=TEST_API_KEY,
request_timeout=timedelta(minutes=3),
transport=httpx.HTTPTransport(
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=15,
)
),
protocol=TEST_PROTOCOL,
use_server_proxy=should_use_server_proxy(),
)
def create_connection_config_sync_server_proxy() -> ConnectionConfigSync:
"""Create sync ConnectionConfig for E2E tests using server-proxied endpoints."""
return ConnectionConfigSync(
domain=TEST_DOMAIN,
api_key=TEST_API_KEY,
request_timeout=timedelta(minutes=3),
transport=httpx.HTTPTransport(
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=15,
)
),
protocol=TEST_PROTOCOL,
use_server_proxy=True,
)
@@ -0,0 +1,152 @@
#
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Small HTTP target used by Credential Vault E2E tests."""
from __future__ import annotations
import json
import os
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
EXPECTED_HEADERS: dict[str, dict[str, str]] = {
"/bearer": {
"authorization": "Bearer vault-bearer-token",
},
"/basic": {
"authorization": "Basic dXNlcjpwYXNz",
},
"/api-key": {
"x-api-key": "vault-api-key-token",
},
"/custom-headers": {
"x-client-id": "vault-client-id",
"x-client-secret": "vault-client-secret",
},
"/runtime-added": {
"x-runtime-token": "vault-runtime-token",
},
"/runtime-replaced": {
"x-runtime-token": "vault-runtime-token-replaced",
},
}
class CredentialVaultEchoHandler(BaseHTTPRequestHandler):
server_version = "OpenSandboxCredentialVaultE2E/1.0"
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
parsed = urlsplit(self.path)
route = parsed.path
if route == "/healthz":
self._write_json(HTTPStatus.OK, {"status": "ok"})
return
if route == "/query-substitution":
query = parse_qs(parsed.query, keep_blank_values=True)
ok = query.get("api_key") == ["vault-query-secret"]
self._write_json(
HTTPStatus.OK if ok else HTTPStatus.UNAUTHORIZED,
{
"ok": ok,
"case": "query-substitution",
"query": query,
"missingOrInvalid": [] if ok else ["api_key"],
},
)
return
if route == "/tenant/vault-path-secret/resource":
query = parse_qs(parsed.query, keep_blank_values=True)
ok = query.get("tenant") == ["__path_secret__"]
self._write_json(
HTTPStatus.OK if ok else HTTPStatus.UNAUTHORIZED,
{
"ok": ok,
"case": "path-substitution",
"queryStillPlaceholder": ok,
"missingOrInvalid": [] if ok else ["tenant"],
},
)
return
expected = EXPECTED_HEADERS.get(route)
if expected is None:
self._write_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "unknown path"})
return
received = {name.lower(): value for name, value in self.headers.items()}
mismatches = [
name
for name, expected_value in expected.items()
if received.get(name) != expected_value
]
self._write_json(
HTTPStatus.UNAUTHORIZED if mismatches else HTTPStatus.OK,
{
"ok": not mismatches,
"case": route.lstrip("/"),
"validatedHeaders": sorted(expected),
"missingOrInvalid": mismatches,
},
)
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
parsed = urlsplit(self.path)
if parsed.path != "/body-substitution":
self._write_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "unknown path"})
return
length = int(self.headers.get("content-length", "0") or "0")
raw_body = self.rfile.read(length)
try:
body = json.loads(raw_body.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
self._write_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "invalid json"})
return
ok = body.get("client_secret") == "vault-body-secret"
self._write_json(
HTTPStatus.OK if ok else HTTPStatus.UNAUTHORIZED,
{
"ok": ok,
"case": "body-substitution",
"missingOrInvalid": [] if ok else ["client_secret"],
"body": body,
},
)
def log_message(self, _format: str, *_args: object) -> None:
return
def _write_json(self, status: HTTPStatus, body: dict[str, object]) -> None:
payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
self.send_response(status)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def main() -> None:
host = os.getenv("OPENSANDBOX_CREDENTIAL_VAULT_ECHO_HOST", "0.0.0.0")
port = int(os.getenv("OPENSANDBOX_CREDENTIAL_VAULT_ECHO_PORT", "80"))
ThreadingHTTPServer((host, port), CredentialVaultEchoHandler).serve_forever()
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,433 @@
#
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Credential Vault E2E tests against a local OpenSandbox server."""
from __future__ import annotations
import json
import os
from datetime import timedelta
import pytest
from opensandbox import SandboxSync
from opensandbox.models.sandboxes import (
Credential,
CredentialBinding,
CredentialProxyConfig,
NetworkPolicy,
NetworkRule,
SandboxImageSpec,
)
from tests.base_e2e_test import (
create_connection_config_sync,
get_e2e_sandbox_resource,
get_sandbox_image,
)
TARGET_HOST = os.getenv(
"OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_HOST",
"credential-vault-e2e.opensandbox.test",
)
TARGET_IP = os.getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP", "")
E2E_LABEL_KEY = os.getenv(
"OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_KEY", "opensandbox.e2e"
)
E2E_LABEL_VALUE = os.getenv(
"OPENSANDBOX_CREDENTIAL_VAULT_E2E_LABEL_VALUE", "credential-vault"
)
SECRET_VALUES = {
"bearer-token": "vault-bearer-token",
"basic-token": "dXNlcjpwYXNz",
"api-key-token": "vault-api-key-token",
"client-id": "vault-client-id",
"client-secret": "vault-client-secret",
"query-secret": "vault-query-secret",
"path-secret": "vault-path-secret",
"body-secret": "vault-body-secret",
"runtime-token": "vault-runtime-token",
"runtime-token-replaced": "vault-runtime-token-replaced",
}
@pytest.fixture(scope="module")
def credential_vault_target_ip() -> str:
if not TARGET_IP:
pytest.skip("Set OPENSANDBOX_CREDENTIAL_VAULT_E2E_TARGET_IP to run this E2E")
return TARGET_IP
def test_credential_vault_injects_all_auth_types(
credential_vault_target_ip: str,
) -> None:
cfg, sandbox = _create_credential_proxy_sandbox(credential_vault_target_ip)
try:
state = sandbox.credential_vault.create(
credentials=[
Credential(name=name, source={"value": value})
for name, value in SECRET_VALUES.items()
],
bindings=[
_binding(
"bearer",
"/bearer",
{"type": "bearer", "credential": "bearer-token"},
),
_binding(
"basic",
"/basic",
{"type": "basic", "credential": "basic-token"},
),
_binding(
"api-key",
"/api-key",
{
"type": "apiKey",
"name": "X-Api-Key",
"credential": "api-key-token",
},
),
_binding(
"custom-headers",
"/custom-headers",
{
"type": "customHeaders",
"headers": [
{"name": "X-Client-Id", "credential": "client-id"},
{"name": "X-Client-Secret", "credential": "client-secret"},
],
},
),
],
)
state_payload = state.model_dump_json(by_alias=True)
for secret in SECRET_VALUES.values():
assert secret not in state_payload
assert {binding.auth.type for binding in state.bindings if binding.auth} == {
"bearer",
"basic",
"apiKey",
"customHeaders",
}
for path in ["/bearer", "/basic", "/api-key", "/custom-headers"]:
response = _curl_json(sandbox, credential_vault_target_ip, path)
assert response["ok"] is True
assert response["case"] == path.lstrip("/")
assert response["missingOrInvalid"] == []
finally:
_close_sandbox(cfg, sandbox)
def test_credential_vault_substitutes_placeholders_in_query_path_and_body(
credential_vault_target_ip: str,
) -> None:
cfg, sandbox = _create_credential_proxy_sandbox(credential_vault_target_ip)
try:
state = sandbox.credential_vault.create(
credentials=[
Credential(name=name, source={"value": SECRET_VALUES[name]})
for name in ["query-secret", "path-secret", "body-secret"]
],
bindings=[
_binding(
"query-substitution",
"/query-substitution",
{
"type": "passthrough",
"substitutions": [
{
"credential": "query-secret",
"placeholder": "__query_secret__",
"in": ["query"],
}
],
},
),
_binding(
"path-substitution",
"/tenant/*",
{
"type": "passthrough",
"substitutions": [
{
"credential": "path-secret",
"placeholder": "__path_secret__",
"in": ["path"],
}
],
},
),
_binding(
"body-substitution",
"/body-substitution",
{
"type": "passthrough",
"substitutions": [
{
"credential": "body-secret",
"placeholder": "__body_secret__",
"in": ["body"],
}
],
},
methods=["POST"],
),
],
)
state_payload = state.model_dump_json(by_alias=True)
for secret in SECRET_VALUES.values():
assert secret not in state_payload
for placeholder in ["__query_secret__", "__path_secret__", "__body_secret__"]:
assert placeholder not in state_payload
response = _curl_json(
sandbox,
credential_vault_target_ip,
"/query-substitution?api_key=__query_secret__",
)
assert response["ok"] is True
assert response["case"] == "query-substitution"
assert response["missingOrInvalid"] == []
response = _curl_json(
sandbox,
credential_vault_target_ip,
"/tenant/__path_secret__/resource?tenant=__path_secret__",
)
assert response["ok"] is True
assert response["case"] == "path-substitution"
assert response["queryStillPlaceholder"] is True
response = _curl_json(
sandbox,
credential_vault_target_ip,
"/body-substitution",
method="POST",
headers=["content-type: application/json"],
data='{"client_secret":"__body_secret__"}',
)
assert response["ok"] is True
assert response["case"] == "body-substitution"
assert response["missingOrInvalid"] == []
finally:
_close_sandbox(cfg, sandbox)
def test_credential_vault_runtime_mutation_adds_replaces_and_deletes_binding(
credential_vault_target_ip: str,
) -> None:
cfg, sandbox = _create_credential_proxy_sandbox(credential_vault_target_ip)
try:
state = sandbox.credential_vault.create(credentials=[], bindings=[])
assert state.revision == 1
assert state.credentials == []
assert state.bindings == []
state = sandbox.credential_vault.patch(
expected_revision=state.revision,
credentials={
"add": [
{
"name": "runtime-token",
"source": {"value": SECRET_VALUES["runtime-token"]},
}
]
},
bindings={
"add": [
_binding(
"runtime-added",
"/runtime-added",
{
"type": "apiKey",
"name": "X-Runtime-Token",
"credential": "runtime-token",
},
)
]
},
)
assert state.revision == 2
assert [credential.name for credential in state.credentials] == ["runtime-token"]
assert [binding.name for binding in state.bindings] == ["runtime-added"]
assert SECRET_VALUES["runtime-token"] not in state.model_dump_json(by_alias=True)
response = _curl_json(sandbox, credential_vault_target_ip, "/runtime-added")
assert response["ok"] is True
assert response["case"] == "runtime-added"
assert response["missingOrInvalid"] == []
state = sandbox.credential_vault.patch(
expected_revision=state.revision,
bindings={"delete": ["runtime-added"]},
)
assert state.revision == 3
assert state.bindings == []
state = sandbox.credential_vault.patch(
expected_revision=state.revision,
credentials={
"replace": [
{
"name": "runtime-token",
"source": {"value": SECRET_VALUES["runtime-token-replaced"]},
}
]
},
bindings={
"add": [
_binding(
"runtime-replaced",
"/runtime-replaced",
{
"type": "apiKey",
"name": "X-Runtime-Token",
"credential": "runtime-token",
},
)
]
},
)
assert state.revision == 4
assert [credential.name for credential in state.credentials] == ["runtime-token"]
assert [binding.name for binding in state.bindings] == ["runtime-replaced"]
state_payload = state.model_dump_json(by_alias=True)
assert SECRET_VALUES["runtime-token"] not in state_payload
assert SECRET_VALUES["runtime-token-replaced"] not in state_payload
response = _curl_json(sandbox, credential_vault_target_ip, "/runtime-replaced")
assert response["ok"] is True
assert response["case"] == "runtime-replaced"
assert response["missingOrInvalid"] == []
response = _curl_json(
sandbox,
credential_vault_target_ip,
"/runtime-added",
fail_on_http_error=False,
)
assert response["ok"] is False
assert response["case"] == "runtime-added"
assert response["missingOrInvalid"] == ["x-runtime-token"]
state = sandbox.credential_vault.patch(
expected_revision=state.revision,
bindings={"delete": ["runtime-replaced"]},
)
assert state.revision == 5
assert state.bindings == []
state = sandbox.credential_vault.patch(
expected_revision=state.revision,
credentials={"delete": ["runtime-token"]},
)
assert state.revision == 6
assert state.credentials == []
finally:
_close_sandbox(cfg, sandbox)
def _create_credential_proxy_sandbox(target_ip: str) -> tuple[object, SandboxSync]:
cfg = create_connection_config_sync()
sandbox = SandboxSync.create(
image=SandboxImageSpec(
os.getenv("OPENSANDBOX_CREDENTIAL_VAULT_E2E_SANDBOX_IMAGE", get_sandbox_image())
),
resource=get_e2e_sandbox_resource(),
connection_config=cfg,
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
network_policy=NetworkPolicy(
defaultAction="deny",
egress=[
NetworkRule(action="allow", target=TARGET_HOST),
NetworkRule(action="allow", target=target_ip),
],
),
credential_proxy=CredentialProxyConfig(enabled=True),
metadata={E2E_LABEL_KEY: E2E_LABEL_VALUE},
)
return cfg, sandbox
def _close_sandbox(cfg: object, sandbox: SandboxSync) -> None:
try:
sandbox.kill()
finally:
sandbox.close()
try:
cfg.transport.close()
except Exception:
# Best-effort teardown: do not fail the test if transport is already closed
# or cannot be closed during cleanup.
pass
def _binding(
name: str,
path: str,
auth: dict[str, object],
*,
methods: list[str] | None = None,
) -> CredentialBinding:
return CredentialBinding(
name=name,
match={
"schemes": ["http"],
"hosts": [TARGET_HOST],
"methods": methods or ["GET"],
"paths": [path],
},
auth=auth,
)
def _curl_json(
sandbox: SandboxSync,
target_ip: str,
path: str,
*,
fail_on_http_error: bool = True,
method: str | None = None,
headers: list[str] | None = None,
data: str | None = None,
) -> dict[str, object]:
fail_flag = "--fail " if fail_on_http_error else ""
method_flag = f"--request {method} " if method else ""
header_flags = "".join(f"--header {_shell_quote(header)} " for header in headers or [])
data_flag = f"--data {_shell_quote(data)} " if data is not None else ""
command = (
f"curl {fail_flag}--silent --show-error "
f"--connect-timeout 5 --max-time 20 {method_flag}{header_flags}{data_flag}"
f"--resolve {TARGET_HOST}:80:{target_ip} "
f"http://{TARGET_HOST}{path}"
)
for secret in SECRET_VALUES.values():
assert secret not in command
result = sandbox.commands.run(command)
assert result.error is None, result.error
stdout = "".join(part.text for part in result.logs.stdout)
assert stdout
return json.loads(stdout)
def _shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\\''") + "'"
@@ -0,0 +1,718 @@
#
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
E2E tests for isolated session (OSEP-0013 bwrap namespace isolation).
"""
import logging
import time
import pytest
from opensandbox import Sandbox
from opensandbox.exceptions import SandboxApiException
from opensandbox.models.execd import ExecutionHandlers, OutputMessage
from opensandbox.models.filesystem import (
ContentReplaceEntry,
DirectoryListEntry,
MoveEntry,
SearchEntry,
SetPermissionEntry,
WriteEntry,
)
from opensandbox.models.isolated import (
BindMount,
CreateIsolatedSessionRequest,
IsolatedRunOpts,
IsolatedWorkspaceSpec,
)
from tests.base_e2e_test import (
create_connection_config,
get_e2e_sandbox_resource,
get_sandbox_image,
)
logger = logging.getLogger(__name__)
@pytest.mark.asyncio
class TestIsolatedSessionE2E:
"""E2E tests for /v1/isolated/* via Python SDK."""
sandbox: Sandbox | None = None
overlay_supported: bool = False
@pytest.fixture(scope="class", autouse=True)
async def _sandbox_lifecycle(self, request):
cls = request.cls
config = create_connection_config()
sandbox = await Sandbox.create(
get_sandbox_image(),
connection_config=config,
resource=get_e2e_sandbox_resource(),
extensions={"bootstrap.execd.isolation": "enable"},
)
cls.sandbox = sandbox
caps = await sandbox.isolation.capabilities()
logger.info(
"Isolation capabilities: available=%s isolator=%s version=%s "
"commit_supported=%s diff_supported=%s message=%s",
caps.available, caps.isolator, caps.version,
caps.commit_supported, caps.diff_supported, caps.message,
)
if not caps.available:
pytest.fail(f"Isolation NOT available: {caps.message or 'unknown reason'}")
cls.overlay_supported = caps.commit_supported or caps.diff_supported
yield
await sandbox.kill()
await sandbox.close()
# ── Helpers ────────────────────────────────────────────────────────
async def _create_session(self, mode: str = "rw", path: str = "/tmp"):
return await self.sandbox.isolation.create(
CreateIsolatedSessionRequest(
workspace=IsolatedWorkspaceSpec(path=path, mode=mode),
)
)
def _require_overlay(self):
if not self.overlay_supported:
pytest.skip("overlay mode not available in this environment")
# ── Core session tests ────────────────────────────────────────────
async def test_capabilities(self):
caps = await self.sandbox.isolation.capabilities()
assert isinstance(caps.available, bool)
async def test_session_lifecycle(self):
session = await self._create_session()
assert session.session_id
assert session.info.created_at is not None
state = await session.get()
assert state.status == "active"
await session.delete()
async def test_list_sessions(self):
session_a = await self._create_session()
session_b = await self._create_session()
session_b_deleted = False
try:
sessions = await self.sandbox.isolation.list()
by_id = {s.session_id: s for s in sessions}
assert session_a.session_id in by_id, "session_a should appear in list"
assert session_b.session_id in by_id, "session_b should appear in list"
assert by_id[session_a.session_id].status == "active"
assert by_id[session_a.session_id].created_at is not None
# After deleting a session it should no longer be listed.
await session_b.delete()
session_b_deleted = True
remaining = await self.sandbox.isolation.list()
remaining_ids = {s.session_id for s in remaining}
assert session_b.session_id not in remaining_ids
finally:
await session_a.delete()
if not session_b_deleted:
await session_b.delete()
async def test_run_echo(self):
session = await self._create_session()
try:
result = await session.run("echo hello-isolation")
assert "hello-isolation" in result.text
finally:
await session.delete()
async def test_pid_isolation(self):
session = await self._create_session()
try:
result = await session.run("echo $$")
pid = int(result.text.strip())
assert pid <= 2, f"expected PID 1 or 2 in namespace, got {pid}"
finally:
await session.delete()
async def test_run_with_envs(self):
session = await self._create_session()
try:
result = await session.run(
"echo $MY_VAR",
opts=IsolatedRunOpts(envs={"MY_VAR": "test-value-42"}),
)
assert "test-value-42" in result.text
finally:
await session.delete()
async def test_session_state_persists(self):
session = await self._create_session()
try:
await session.run("export PERSIST_VAR=abc123")
result = await session.run("echo $PERSIST_VAR")
assert "abc123" in result.text
finally:
await session.delete()
async def test_tmp_isolation(self):
await self.sandbox.commands.run("mkdir -p /workspace")
session_a = await self._create_session(path="/workspace")
session_b = await self._create_session(path="/workspace")
try:
await session_a.run("echo secret > /tmp/isolated_test_file.txt")
result = await session_b.run(
"cat /tmp/isolated_test_file.txt 2>&1 || echo NOT_FOUND"
)
assert "NOT_FOUND" in result.text or "No such file" in result.text
finally:
await session_a.delete()
await session_b.delete()
async def test_run_with_handlers(self):
collected: list[str] = []
async def on_stdout(msg: OutputMessage):
collected.append(msg.text)
session = await self._create_session()
try:
await session.run(
"echo handler-test",
handlers=ExecutionHandlers(on_stdout=on_stdout),
)
combined = "".join(collected)
assert "handler-test" in combined
finally:
await session.delete()
async def test_delete_nonexistent_session(self):
with pytest.raises(SandboxApiException):
fake_session = await self._create_session()
await fake_session.delete()
await fake_session.delete() # second delete should fail
# ── RW mode: run-based file tests ─────────────────────────────────
async def test_rw_files_write_via_run(self):
session = await self._create_session(mode="rw")
try:
await session.run("echo 'hello from sdk' > /tmp/hello.txt")
result = await session.run("cat /tmp/hello.txt")
assert "hello from sdk" in result.text
finally:
await session.delete()
async def test_rw_files_persistence_across_runs(self):
session = await self._create_session(mode="rw")
try:
await session.run("echo run1-data > /tmp/persist.txt")
await session.run("mkdir -p /tmp/subdir && echo nested > /tmp/subdir/file.txt")
result = await session.run("cat /tmp/persist.txt && cat /tmp/subdir/file.txt")
assert "run1-data" in result.text
assert "nested" in result.text
finally:
await session.delete()
async def test_rw_host_visible(self):
"""RW mode: writes inside session are visible on host."""
marker = f"rw_visible_{int(time.time() * 1000)}.txt"
session = await self._create_session(mode="rw")
try:
await session.run(f"echo rw-data > /tmp/{marker}")
host_check = await self.sandbox.commands.run(f"cat /tmp/{marker}")
assert "rw-data" in host_check.text
finally:
await session.run(f"rm -f /tmp/{marker}")
await session.delete()
# ── RW mode: filesystem API tests ─────────────────────────────────
async def test_rw_files_upload_download(self):
session = await self._create_session(mode="rw")
try:
path = f"/tmp/upload_rw_{int(time.time() * 1000)}.txt"
await session.files.write_files([WriteEntry(path=path, data="rw upload", mode=644)])
content = await session.files.read_file(path, encoding="utf-8")
assert content == "rw upload"
finally:
await session.delete()
async def test_rw_files_read_bytes(self):
session = await self._create_session(mode="rw")
try:
path = "/tmp/bytes_rw.bin"
data = b"\x00\x01\x02\xff"
await session.files.write_files([WriteEntry(path=path, data=data, mode=644)])
assert await session.files.read_bytes(path) == data
finally:
await session.delete()
async def test_rw_files_info(self):
session = await self._create_session(mode="rw")
try:
path = "/tmp/info_rw.txt"
await session.files.write_files([WriteEntry(path=path, data="info", mode=644)])
info_map = await session.files.get_file_info([path])
assert path in info_map
assert info_map[path].size == 4
assert info_map[path].mode == 644
finally:
await session.delete()
async def test_rw_files_search(self):
session = await self._create_session(mode="rw")
try:
prefix = f"/tmp/search_rw_{int(time.time() * 1000)}"
await session.run(f"mkdir -p {prefix}")
await session.files.write_files([
WriteEntry(path=f"{prefix}/a.txt", data="a", mode=644),
WriteEntry(path=f"{prefix}/b.txt", data="b", mode=644),
WriteEntry(path=f"{prefix}/c.log", data="c", mode=644),
])
results = await session.files.search(SearchEntry(path=prefix, pattern="*.txt"))
assert len(results) == 2
paths = [r.path for r in results]
assert any("a.txt" in p for p in paths)
assert any("b.txt" in p for p in paths)
finally:
await session.delete()
async def test_rw_files_mkdir(self):
session = await self._create_session(mode="rw")
try:
d = f"/tmp/mkdir_rw_{int(time.time() * 1000)}"
await session.files.create_directories([WriteEntry(path=d, mode=755)])
info_map = await session.files.get_file_info([d])
assert d in info_map
finally:
await session.delete()
async def test_rw_files_delete(self):
session = await self._create_session(mode="rw")
try:
path = "/tmp/delete_rw.txt"
await session.files.write_files([WriteEntry(path=path, data="del", mode=644)])
await session.files.delete_files([path])
with pytest.raises(SandboxApiException):
await session.files.get_file_info([path])
finally:
await session.delete()
async def test_rw_files_move(self):
session = await self._create_session(mode="rw")
try:
src, dst = "/tmp/mv_rw_src.txt", "/tmp/mv_rw_dst.txt"
await session.files.write_files([WriteEntry(path=src, data="move", mode=644)])
await session.files.move_files([MoveEntry(source=src, destination=dst)])
assert await session.files.read_file(dst, encoding="utf-8") == "move"
finally:
await session.delete()
async def test_rw_files_chmod(self):
session = await self._create_session(mode="rw")
try:
path = "/tmp/chmod_rw.txt"
await session.files.write_files([WriteEntry(path=path, data="ch", mode=644)])
await session.files.set_permissions([SetPermissionEntry(path=path, mode=755)])
info_map = await session.files.get_file_info([path])
assert info_map[path].mode == 755
finally:
await session.delete()
async def test_rw_files_replace(self):
session = await self._create_session(mode="rw")
try:
path = "/tmp/replace_rw.txt"
await session.files.write_files([WriteEntry(path=path, data="hello old world", mode=644)])
await session.files.replace_contents([
ContentReplaceEntry(path=path, old_content="old", new_content="new")
])
content = await session.files.read_file(path, encoding="utf-8")
assert "new" in content and "old" not in content
finally:
await session.delete()
async def test_rw_files_list_directory(self):
session = await self._create_session(mode="rw")
try:
prefix = f"/tmp/listdir_rw_{int(time.time() * 1000)}"
await session.run(f"mkdir -p {prefix}/sub")
await session.files.write_files([
WriteEntry(path=f"{prefix}/f1.txt", data="f1", mode=644),
WriteEntry(path=f"{prefix}/sub/f2.txt", data="f2", mode=644),
])
entries = await session.files.list_directory(DirectoryListEntry(path=prefix, depth=1))
names = [e.path for e in entries]
assert any("f1.txt" in n for n in names)
assert any("sub" in n for n in names)
finally:
await session.delete()
# ── RO mode tests ─────────────────────────────────────────────────
async def test_ro_can_read_existing_files(self):
"""RO mode: session can read files that exist on host."""
marker = f"ro_read_{int(time.time() * 1000)}.txt"
await self.sandbox.commands.run(f"echo ro-data > /tmp/{marker}")
session = await self._create_session(mode="ro")
try:
result = await session.run(f"cat /tmp/{marker}")
assert "ro-data" in result.text
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -f /tmp/{marker}")
async def test_ro_cannot_write(self):
"""RO mode: writes to workspace are denied."""
session = await self._create_session(mode="ro")
try:
result = await session.run(
"echo fail > /tmp/ro_write_test.txt 2>&1; echo EXIT=$?"
)
assert "EXIT=1" in result.text or "Read-only" in result.text or "Permission denied" in result.text
finally:
await session.delete()
async def test_ro_files_api_read(self):
"""RO mode: filesystem API can read existing files."""
marker = f"ro_api_{int(time.time() * 1000)}.txt"
await self.sandbox.commands.run(f"echo ro-api-data > /tmp/{marker}")
session = await self._create_session(mode="ro")
try:
content = await session.files.read_file(f"/tmp/{marker}", encoding="utf-8")
assert "ro-api-data" in content
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -f /tmp/{marker}")
async def test_ro_files_api_search(self):
"""RO mode: search works on read-only workspace."""
prefix = f"/tmp/ro_search_{int(time.time() * 1000)}"
await self.sandbox.commands.run(f"mkdir -p {prefix} && echo x > {prefix}/file.txt")
session = await self._create_session(mode="ro")
try:
results = await session.files.search(SearchEntry(path=prefix, pattern="*.txt"))
assert len(results) >= 1
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -rf {prefix}")
async def test_ro_files_api_list_directory(self):
"""RO mode: list directory works."""
prefix = f"/tmp/ro_listdir_{int(time.time() * 1000)}"
await self.sandbox.commands.run(f"mkdir -p {prefix} && echo x > {prefix}/f.txt")
session = await self._create_session(mode="ro")
try:
entries = await session.files.list_directory(DirectoryListEntry(path=prefix, depth=1))
assert len(entries) >= 1
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -rf {prefix}")
# ── Overlay mode tests ────────────────────────────────────────────
async def test_overlay_writes_not_visible_on_host(self):
"""Overlay mode: writes inside session are NOT visible on host."""
self._require_overlay()
marker = f"overlay_invis_{int(time.time() * 1000)}.txt"
session = await self._create_session(mode="overlay")
try:
await session.run(f"echo overlay-data > /tmp/{marker}")
host_check = await self.sandbox.commands.run(
f"cat /tmp/{marker} 2>&1 || echo NOT_FOUND"
)
assert "NOT_FOUND" in host_check.text or "No such file" in host_check.text
finally:
await session.delete()
async def test_overlay_can_read_host_files(self):
"""Overlay mode: session can read pre-existing host files (lower layer)."""
self._require_overlay()
marker = f"overlay_lower_{int(time.time() * 1000)}.txt"
await self.sandbox.commands.run(f"echo lower-data > /tmp/{marker}")
session = await self._create_session(mode="overlay")
try:
result = await session.run(f"cat /tmp/{marker}")
assert "lower-data" in result.text
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -f /tmp/{marker}")
async def test_overlay_cow_does_not_mutate_host(self):
"""Overlay mode: modifying a host file does not change the original."""
self._require_overlay()
marker = f"overlay_cow_{int(time.time() * 1000)}.txt"
await self.sandbox.commands.run(f"echo original > /tmp/{marker}")
session = await self._create_session(mode="overlay")
try:
await session.run(f"echo modified > /tmp/{marker}")
in_session = await session.run(f"cat /tmp/{marker}")
assert "modified" in in_session.text
host_check = await self.sandbox.commands.run(f"cat /tmp/{marker}")
assert "original" in host_check.text
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -f /tmp/{marker}")
async def test_overlay_files_api_upload_download(self):
"""Overlay mode: filesystem API write/read through upper layer."""
self._require_overlay()
session = await self._create_session(mode="overlay")
try:
path = f"/tmp/ov_upload_{int(time.time() * 1000)}.txt"
await session.files.write_files([WriteEntry(path=path, data="overlay file", mode=644)])
content = await session.files.read_file(path, encoding="utf-8")
assert content == "overlay file"
# Host should NOT see it
host_check = await self.sandbox.commands.run(f"cat {path} 2>&1 || echo NOT_FOUND")
assert "NOT_FOUND" in host_check.text or "No such file" in host_check.text
finally:
await session.delete()
async def test_overlay_files_api_search(self):
"""Overlay mode: search merges upper and lower."""
self._require_overlay()
prefix = f"/tmp/ov_search_{int(time.time() * 1000)}"
await self.sandbox.commands.run(f"mkdir -p {prefix} && echo lower > {prefix}/lower.txt")
session = await self._create_session(mode="overlay")
try:
await session.files.write_files([
WriteEntry(path=f"{prefix}/upper.txt", data="upper", mode=644),
])
results = await session.files.search(SearchEntry(path=prefix, pattern="*.txt"))
paths = [r.path for r in results]
assert any("lower.txt" in p for p in paths)
assert any("upper.txt" in p for p in paths)
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -rf {prefix}")
async def test_overlay_files_api_delete(self):
"""Overlay mode: deleting a file makes it invisible via API."""
self._require_overlay()
prefix = f"/tmp/ov_del_{int(time.time() * 1000)}"
await self.sandbox.commands.run(f"mkdir -p {prefix} && echo x > {prefix}/target.txt")
session = await self._create_session(mode="overlay")
try:
await session.files.delete_files([f"{prefix}/target.txt"])
with pytest.raises(SandboxApiException):
await session.files.get_file_info([f"{prefix}/target.txt"])
# Host file should be untouched
host_check = await self.sandbox.commands.run(f"cat {prefix}/target.txt")
assert "x" in host_check.text
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -rf {prefix}")
async def test_overlay_files_api_move(self):
"""Overlay mode: move works and creates whiteout for source."""
self._require_overlay()
session = await self._create_session(mode="overlay")
try:
src, dst = "/tmp/ov_mv_src.txt", "/tmp/ov_mv_dst.txt"
await session.files.write_files([WriteEntry(path=src, data="moveme", mode=644)])
await session.files.move_files([MoveEntry(source=src, destination=dst)])
content = await session.files.read_file(dst, encoding="utf-8")
assert content == "moveme"
finally:
await session.delete()
async def test_overlay_files_api_chmod(self):
"""Overlay mode: chmod copies up from lower before modifying."""
self._require_overlay()
marker = f"ov_chmod_{int(time.time() * 1000)}.txt"
await self.sandbox.commands.run(f"echo ch > /tmp/{marker} && chmod 644 /tmp/{marker}")
session = await self._create_session(mode="overlay")
try:
await session.files.set_permissions([
SetPermissionEntry(path=f"/tmp/{marker}", mode=755)
])
info_map = await session.files.get_file_info([f"/tmp/{marker}"])
assert info_map[f"/tmp/{marker}"].mode == 755
# Host should still be 644
host_check = await self.sandbox.commands.run(f"stat -c %a /tmp/{marker}")
assert "644" in host_check.text
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -f /tmp/{marker}")
async def test_overlay_files_api_replace(self):
"""Overlay mode: replace content copies up and modifies in upper."""
self._require_overlay()
marker = f"ov_repl_{int(time.time() * 1000)}.txt"
await self.sandbox.commands.run(f"echo 'hello old world' > /tmp/{marker}")
session = await self._create_session(mode="overlay")
try:
await session.files.replace_contents([
ContentReplaceEntry(path=f"/tmp/{marker}", old_content="old", new_content="new")
])
content = await session.files.read_file(f"/tmp/{marker}", encoding="utf-8")
assert "new" in content and "old" not in content
# Host unchanged
host_check = await self.sandbox.commands.run(f"cat /tmp/{marker}")
assert "old" in host_check.text
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -f /tmp/{marker}")
async def test_overlay_files_api_list_directory(self):
"""Overlay mode: list directory merges upper and lower entries."""
self._require_overlay()
prefix = f"/tmp/ov_ls_{int(time.time() * 1000)}"
await self.sandbox.commands.run(f"mkdir -p {prefix} && echo l > {prefix}/lower.txt")
session = await self._create_session(mode="overlay")
try:
await session.files.write_files([
WriteEntry(path=f"{prefix}/upper.txt", data="u", mode=644),
])
entries = await session.files.list_directory(DirectoryListEntry(path=prefix, depth=1))
names = [e.path for e in entries]
assert any("lower.txt" in n for n in names)
assert any("upper.txt" in n for n in names)
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -rf {prefix}")
# ── run_once / session convenience method tests ──────────────────
async def test_run_once(self):
result = await self.sandbox.isolation.run_once(
"echo run-once-works",
workspace="/tmp",
workspace_mode="rw",
)
assert "run-once-works" in result.text
async def test_run_once_with_envs(self):
result = await self.sandbox.isolation.run_once(
"echo $RUN_ONCE_VAR",
workspace="/tmp",
workspace_mode="rw",
opts=IsolatedRunOpts(envs={"RUN_ONCE_VAR": "e2e-value"}),
)
assert "e2e-value" in result.text
async def test_run_once_session_cleaned_up(self):
result = await self.sandbox.isolation.run_once(
"echo $$",
workspace="/tmp",
workspace_mode="rw",
)
assert result.text.strip().isdigit()
async def test_session_context_manager(self):
request = CreateIsolatedSessionRequest(
workspace=IsolatedWorkspaceSpec(path="/tmp", mode="rw"),
)
async with self.sandbox.isolation.session(request) as session:
r1 = await session.run("export CTX_VAR=hello123")
r2 = await session.run("echo $CTX_VAR")
assert "hello123" in r2.text
async def test_session_context_manager_multi_run(self):
request = CreateIsolatedSessionRequest(
workspace=IsolatedWorkspaceSpec(path="/tmp", mode="rw"),
)
async with self.sandbox.isolation.session(request) as session:
await session.run("echo step1 > /tmp/ctx_test.txt")
result = await session.run("cat /tmp/ctx_test.txt")
assert "step1" in result.text
# ── Bind mount tests (explicit source->dest binds) ─────────────────
async def test_bind_read_write_host_visible(self):
"""Legal RW bind: sandbox writes at dest are visible on host at source."""
ts = int(time.time() * 1000)
# Source must be within the execd writable allowlist (e.g. /data).
src_dir = f"/data/bind_rw_{ts}"
dest = "/mnt/bind_rw"
file_name = "from_sandbox.txt"
content = "bind-rw-visible-on-host"
# Create the source dir and the destination mount point (bwrap binds
# onto an existing dir; it cannot create one under the read-only root).
await self.sandbox.commands.run(f"mkdir -p {src_dir} {dest}")
session = await self.sandbox.isolation.create(
CreateIsolatedSessionRequest(
workspace=IsolatedWorkspaceSpec(path="/tmp", mode="rw"),
binds=[BindMount(source=src_dir, dest=dest)],
)
)
try:
result = await session.run(
f"echo -n {content} > {dest}/{file_name} && cat {dest}/{file_name}"
)
assert content in result.text
host_check = await self.sandbox.commands.run(f"cat {src_dir}/{file_name}")
assert content in host_check.text
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -rf {src_dir}")
async def test_bind_illegal_rejected(self):
"""Bind whose source is outside the allowlist is rejected on create."""
with pytest.raises(SandboxApiException):
await self.sandbox.isolation.create(
CreateIsolatedSessionRequest(
workspace=IsolatedWorkspaceSpec(path="/tmp", mode="rw"),
# /etc is not in the writable allowlist.
binds=[BindMount(source="/etc", dest="/mnt/etc")],
)
)
async def test_bind_read_only_readable(self):
"""Read-only bind: host-created file is readable inside the sandbox."""
ts = int(time.time() * 1000)
src_dir = f"/data/bind_ro_{ts}"
dest = "/mnt/bind_ro"
file_name = "host_created.txt"
content = "bind-ro-host-content"
await self.sandbox.commands.run(
f"mkdir -p {src_dir} {dest} && echo -n {content} > {src_dir}/{file_name}"
)
session = await self.sandbox.isolation.create(
CreateIsolatedSessionRequest(
workspace=IsolatedWorkspaceSpec(path="/tmp", mode="rw"),
binds=[BindMount(source=src_dir, dest=dest, readonly=True)],
)
)
try:
result = await session.run(f"cat {dest}/{file_name}")
assert content in result.text
write = await session.run(
f"echo x > {dest}/newfile.txt 2>&1; echo EXIT=$?"
)
assert (
"EXIT=1" in write.text
or "Read-only" in write.text
or "read-only" in write.text
or "Permission denied" in write.text
)
finally:
await session.delete()
await self.sandbox.commands.run(f"rm -rf {src_dir}")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,301 @@
#
# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Comprehensive E2E tests for SandboxManager functionality.
Focus: Validate `list_sandbox_infos` filter semantics precisely:
- `states` filter is OR logic
- `metadata` filter is AND logic
We create 3 dedicated sandboxes per run to keep assertions deterministic.
"""
import asyncio
import logging
import time
from datetime import timedelta
from uuid import uuid4
import pytest
from opensandbox import Sandbox, SandboxManager
from opensandbox.config import ConnectionConfig
from opensandbox.exceptions import SandboxApiException
from opensandbox.models.sandboxes import (
SandboxFilter,
SandboxImageSpec,
)
from tests.base_e2e_test import (
create_connection_config,
get_sandbox_image,
is_kubernetes_runtime,
)
logger = logging.getLogger(__name__)
# Kubernetes may use Pending / Allocated during lifecycle; narrow filters omit them and list E2E flakes.
_STATES_OR_BROAD = ["Pending", "Allocated", "Running", "Paused"]
_STATES_NOT_PAUSED = ["Pending", "Allocated", "Running"]
async def _create_sandbox(
*,
connection_config: ConnectionConfig,
image: str,
metadata: dict[str, str],
env: dict[str, str],
timeout: timedelta,
ready_timeout: timedelta,
) -> Sandbox:
return await Sandbox.create(
image=SandboxImageSpec(image),
connection_config=connection_config,
resource={"cpu": "100m", "memory": "64Mi"},
timeout=timeout,
ready_timeout=ready_timeout,
metadata=metadata,
env=env,
health_check_polling_interval=timedelta(milliseconds=500),
)
async def _wait_for_state(
*,
manager: SandboxManager,
sandbox_id,
expected_state: str,
timeout: timedelta = timedelta(minutes=3),
) -> None:
deadline = time.time() + timeout.total_seconds()
last_state = None
while time.time() < deadline:
info = await manager.get_sandbox_info(sandbox_id)
last_state = info.status.state
if last_state == expected_state:
return
await asyncio.sleep(1)
raise AssertionError(f"Timed out waiting for state={expected_state}, last_state={last_state}")
@pytest.mark.asyncio
class TestSandboxManagerE2E:
"""E2E tests for SandboxManager list/filter semantics."""
connection_config: ConnectionConfig | None = None
manager: SandboxManager | None = None
tag: str | None = None
s1: Sandbox | None = None
s2: Sandbox | None = None
s3: Sandbox | None = None
#: True if s3 was paused successfully; False when pause is unsupported or intentionally skipped.
s3_paused: bool = False
@pytest.fixture(scope="class", autouse=True)
async def _manager_setup(self, request):
cls = request.cls
# Create connection config (user-owned transport; we close it explicitly).
cls.connection_config = create_connection_config()
cls.manager = await SandboxManager.create(connection_config=cls.connection_config)
cls.tag = f"e2e-sandbox-manager-{uuid4().hex[:8]}"
# Create 3 sandboxes with controlled metadata.
# s1: tag + team=t1 + env=prod
# s2: tag + team=t1 + env=dev
# s3: tag + env=prod (no team). Docker pauses it to cover Paused filters;
# Kubernetes mini keeps it active because the suite does not provision snapshot infra.
cls.s1 = await _create_sandbox(
connection_config=cls.connection_config,
image=get_sandbox_image(),
metadata={"tag": cls.tag, "team": "t1", "env": "prod"},
env={"E2E_TEST": "true", "CASE": "mgr-s1", "EXECD_API_GRACE_SHUTDOWN": "3s",
"EXECD_JUPYTER_IDLE_POLL_INTERVAL": "200ms"},
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
)
cls.s2 = await _create_sandbox(
connection_config=cls.connection_config,
image=get_sandbox_image(),
metadata={"tag": cls.tag, "team": "t1", "env": "dev"},
env={"E2E_TEST": "true", "CASE": "mgr-s2", "EXECD_API_GRACE_SHUTDOWN": "3s",
"EXECD_JUPYTER_IDLE_POLL_INTERVAL": "200ms"},
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
)
cls.s3 = await _create_sandbox(
connection_config=cls.connection_config,
image=get_sandbox_image(),
metadata={"tag": cls.tag, "env": "prod"},
env={"E2E_TEST": "true", "CASE": "mgr-s3", "EXECD_API_GRACE_SHUTDOWN": "3s",
"EXECD_JUPYTER_IDLE_POLL_INTERVAL": "200ms"},
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
)
assert await cls.s1.is_healthy() is True
assert await cls.s2.is_healthy() is True
assert await cls.s3.is_healthy() is True
cls.s3_paused = False
if is_kubernetes_runtime():
logger.warning(
"Skipping pause in Kubernetes manager E2E; mini suite does not provision snapshot infra"
)
else:
try:
await cls.manager.pause_sandbox(cls.s3.id)
await _wait_for_state(
manager=cls.manager, sandbox_id=cls.s3.id, expected_state="Paused"
)
cls.s3_paused = True
except SandboxApiException as exc:
# Some runtimes may not enable pause. Keep all sandboxes Running and relax state-filter asserts.
if exc.status_code == 400:
logger.warning(
"pause_sandbox not configured (HTTP %s); manager state-filter E2E uses all-Running sandboxes",
exc.status_code,
)
else:
raise
try:
yield
finally:
# Best-effort cleanup: kill sandboxes (remote) and close local resources.
for s in [cls.s1, cls.s2, cls.s3]:
if s is None:
continue
try:
await s.kill()
except Exception:
pass
try:
await s.close()
except Exception:
pass
if cls.manager is not None:
try:
await cls.manager.close()
except Exception:
pass
if cls.connection_config is not None:
try:
await cls.connection_config.transport.aclose()
except Exception:
pass
@pytest.mark.timeout(600)
async def test_01_states_filter_or_logic(self):
manager = TestSandboxManagerE2E.manager
assert manager is not None
assert TestSandboxManagerE2E.tag is not None
assert TestSandboxManagerE2E.s1 is not None and TestSandboxManagerE2E.s2 is not None and TestSandboxManagerE2E.s3 is not None
# states filter is OR: should return sandboxes in ANY of the requested states.
result = await manager.list_sandbox_infos(
SandboxFilter(
states=_STATES_OR_BROAD,
metadata={"tag": TestSandboxManagerE2E.tag},
page_size=50,
)
)
ids = {info.id for info in result.sandbox_infos}
assert {TestSandboxManagerE2E.s1.id, TestSandboxManagerE2E.s2.id, TestSandboxManagerE2E.s3.id}.issubset(ids)
paused_only = await manager.list_sandbox_infos(
SandboxFilter(states=["Paused"], metadata={"tag": TestSandboxManagerE2E.tag}, page_size=50)
)
paused_ids = {info.id for info in paused_only.sandbox_infos}
running_only = await manager.list_sandbox_infos(
SandboxFilter(
states=_STATES_NOT_PAUSED,
metadata={"tag": TestSandboxManagerE2E.tag},
page_size=50,
)
)
running_ids = {info.id for info in running_only.sandbox_infos}
if TestSandboxManagerE2E.s3_paused:
assert TestSandboxManagerE2E.s3.id in paused_ids
assert TestSandboxManagerE2E.s1.id not in paused_ids
assert TestSandboxManagerE2E.s2.id not in paused_ids
assert TestSandboxManagerE2E.s1.id in running_ids
assert TestSandboxManagerE2E.s2.id in running_ids
assert TestSandboxManagerE2E.s3.id not in running_ids
else:
assert TestSandboxManagerE2E.s3.id not in paused_ids
assert TestSandboxManagerE2E.s1.id not in paused_ids
assert TestSandboxManagerE2E.s2.id not in paused_ids
assert TestSandboxManagerE2E.s1.id in running_ids
assert TestSandboxManagerE2E.s2.id in running_ids
assert TestSandboxManagerE2E.s3.id in running_ids
@pytest.mark.timeout(600)
async def test_02_metadata_filter_and_logic(self):
manager = TestSandboxManagerE2E.manager
assert manager is not None
assert TestSandboxManagerE2E.tag is not None
assert TestSandboxManagerE2E.s1 is not None and TestSandboxManagerE2E.s2 is not None and TestSandboxManagerE2E.s3 is not None
# metadata filter is AND across all key-value pairs.
# tag+team=t1 should match s1 and s2 (both have team=t1), not s3.
tag_and_team = await manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": TestSandboxManagerE2E.tag, "team": "t1"}, page_size=50)
)
ids = {info.id for info in tag_and_team.sandbox_infos}
assert TestSandboxManagerE2E.s1.id in ids
assert TestSandboxManagerE2E.s2.id in ids
assert TestSandboxManagerE2E.s3.id not in ids
# tag+team=t1+env=prod should match only s1 (AND narrows results).
tag_team_env = await manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": TestSandboxManagerE2E.tag, "team": "t1", "env": "prod"}, page_size=50)
)
ids = {info.id for info in tag_team_env.sandbox_infos}
assert TestSandboxManagerE2E.s1.id in ids
assert TestSandboxManagerE2E.s2.id not in ids
assert TestSandboxManagerE2E.s3.id not in ids
# tag+env=prod should match s1 and s3.
tag_env = await manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": TestSandboxManagerE2E.tag, "env": "prod"}, page_size=50)
)
ids = {info.id for info in tag_env.sandbox_infos}
assert TestSandboxManagerE2E.s1.id in ids
assert TestSandboxManagerE2E.s3.id in ids
assert TestSandboxManagerE2E.s2.id not in ids
# Negative: tag+team=t2 should match none.
none_match = await manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": TestSandboxManagerE2E.tag, "team": "t2"}, page_size=50)
)
assert all(
info.id not in {TestSandboxManagerE2E.s1.id, TestSandboxManagerE2E.s2.id, TestSandboxManagerE2E.s3.id}
for info in none_match.sandbox_infos
)
patched = await manager.patch_sandbox_metadata(
TestSandboxManagerE2E.s2.id,
{"env": "stage", "team": None},
)
assert patched.metadata["env"] == "stage"
assert "team" not in patched.metadata
refreshed = await manager.get_sandbox_info(TestSandboxManagerE2E.s2.id)
assert refreshed.metadata["env"] == "stage"
assert "team" not in refreshed.metadata
@@ -0,0 +1,260 @@
#
# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Comprehensive Sync E2E tests for SandboxManagerSync functionality.
Focus: Validate `list_sandbox_infos` filter semantics precisely:
- `states` filter is OR logic
- `metadata` filter is AND logic
We create 3 dedicated sandboxes per run to keep assertions deterministic.
"""
import logging
import time
from datetime import timedelta
from uuid import uuid4
import pytest
from opensandbox import SandboxManagerSync, SandboxSync
from opensandbox.exceptions import SandboxApiException
from opensandbox.models.sandboxes import (
SandboxFilter,
SandboxImageSpec,
)
from tests.base_e2e_test import (
create_connection_config_sync,
get_sandbox_image,
is_kubernetes_runtime,
)
logger = logging.getLogger(__name__)
# Kubernetes may use Pending / Allocated during lifecycle; narrow filters omit them and list E2E flakes.
_STATES_OR_BROAD = ["Pending", "Allocated", "Running", "Paused"]
_STATES_NOT_PAUSED = ["Pending", "Allocated", "Running"]
class TestSandboxManagerE2ESync:
@pytest.mark.timeout(600)
def test_01_states_filter_or_logic(self):
cfg = create_connection_config_sync()
manager = SandboxManagerSync.create(connection_config=cfg)
tag = f"e2e-sandbox-manager-{uuid4().hex[:8]}"
s1 = s2 = s3 = None
try:
s1 = SandboxSync.create(
image=SandboxImageSpec(get_sandbox_image()),
connection_config=cfg,
resource={"cpu": "100m", "memory": "64Mi"},
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
metadata={"tag": tag, "team": "t1", "env": "prod"},
env={"E2E_TEST": "true", "EXECD_API_GRACE_SHUTDOWN": "3s", "EXECD_JUPYTER_IDLE_POLL_INTERVAL": "200ms", "CASE": "mgr-s1"},
health_check_polling_interval=timedelta(milliseconds=500),
)
s2 = SandboxSync.create(
image=SandboxImageSpec(get_sandbox_image()),
connection_config=cfg,
resource={"cpu": "100m", "memory": "64Mi"},
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
metadata={"tag": tag, "team": "t1", "env": "dev"},
env={"E2E_TEST": "true", "EXECD_API_GRACE_SHUTDOWN": "3s", "EXECD_JUPYTER_IDLE_POLL_INTERVAL": "200ms", "CASE": "mgr-s2"},
health_check_polling_interval=timedelta(milliseconds=500),
)
s3 = SandboxSync.create(
image=SandboxImageSpec(get_sandbox_image()),
connection_config=cfg,
resource={"cpu": "100m", "memory": "64Mi"},
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
metadata={"tag": tag, "env": "prod"},
env={"E2E_TEST": "true", "EXECD_API_GRACE_SHUTDOWN": "3s", "EXECD_JUPYTER_IDLE_POLL_INTERVAL": "200ms", "CASE": "mgr-s3"},
health_check_polling_interval=timedelta(milliseconds=500),
)
assert s1.is_healthy() is True
assert s2.is_healthy() is True
assert s3.is_healthy() is True
s3_paused = False
if is_kubernetes_runtime():
logger.warning(
"Skipping pause in Kubernetes manager E2E; mini suite does not provision snapshot infra"
)
else:
try:
manager.pause_sandbox(s3.id)
deadline = time.time() + 180
while time.time() < deadline:
info = manager.get_sandbox_info(s3.id)
if info.status.state == "Paused":
break
time.sleep(1)
assert manager.get_sandbox_info(s3.id).status.state == "Paused"
s3_paused = True
except SandboxApiException as exc:
# Some runtimes may not enable pause. Keep all sandboxes Running and relax state-filter asserts.
if exc.status_code == 400:
logger.warning(
"pause_sandbox not configured (HTTP %s); manager state-filter E2E uses all-Running sandboxes",
exc.status_code,
)
else:
raise
# OR states (broad: K8s lifecycle is not only Running/Paused)
both = manager.list_sandbox_infos(
SandboxFilter(states=_STATES_OR_BROAD, metadata={"tag": tag}, page_size=50)
)
ids = {info.id for info in both.sandbox_infos}
assert {s1.id, s2.id, s3.id}.issubset(ids)
paused_only = manager.list_sandbox_infos(
SandboxFilter(states=["Paused"], metadata={"tag": tag}, page_size=50)
)
paused_ids = {info.id for info in paused_only.sandbox_infos}
running_only = manager.list_sandbox_infos(
SandboxFilter(states=_STATES_NOT_PAUSED, metadata={"tag": tag}, page_size=50)
)
running_ids = {info.id for info in running_only.sandbox_infos}
if s3_paused:
assert s3.id in paused_ids
assert s1.id not in paused_ids
assert s2.id not in paused_ids
assert s1.id in running_ids
assert s2.id in running_ids
assert s3.id not in running_ids
else:
assert s3.id not in paused_ids
assert s1.id not in paused_ids
assert s2.id not in paused_ids
assert s1.id in running_ids
assert s2.id in running_ids
assert s3.id in running_ids
finally:
for s in [s1, s2, s3]:
if s is None:
continue
try:
s.kill()
except Exception:
pass
try:
s.close()
except Exception:
pass
manager.close()
@pytest.mark.timeout(600)
def test_02_metadata_filter_and_logic(self):
cfg = create_connection_config_sync()
manager = SandboxManagerSync.create(connection_config=cfg)
tag = f"e2e-sandbox-manager-{uuid4().hex[:8]}"
s1 = s2 = s3 = None
try:
s1 = SandboxSync.create(
image=SandboxImageSpec(get_sandbox_image()),
connection_config=cfg,
resource={"cpu": "100m", "memory": "64Mi"},
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
metadata={"tag": tag, "team": "t1", "env": "prod"},
env={"E2E_TEST": "true", "EXECD_API_GRACE_SHUTDOWN": "3s", "EXECD_JUPYTER_IDLE_POLL_INTERVAL": "200ms", "CASE": "mgr-s1"},
health_check_polling_interval=timedelta(milliseconds=500),
)
s2 = SandboxSync.create(
image=SandboxImageSpec(get_sandbox_image()),
connection_config=cfg,
resource={"cpu": "100m", "memory": "64Mi"},
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
metadata={"tag": tag, "team": "t1", "env": "dev"},
env={"E2E_TEST": "true", "EXECD_API_GRACE_SHUTDOWN": "3s", "EXECD_JUPYTER_IDLE_POLL_INTERVAL": "200ms", "CASE": "mgr-s2"},
health_check_polling_interval=timedelta(milliseconds=500),
)
s3 = SandboxSync.create(
image=SandboxImageSpec(get_sandbox_image()),
connection_config=cfg,
resource={"cpu": "100m", "memory": "64Mi"},
timeout=timedelta(minutes=5),
ready_timeout=timedelta(seconds=60),
metadata={"tag": tag, "env": "prod"},
env={"E2E_TEST": "true", "EXECD_API_GRACE_SHUTDOWN": "3s", "EXECD_JUPYTER_IDLE_POLL_INTERVAL": "200ms", "CASE": "mgr-s3"},
health_check_polling_interval=timedelta(milliseconds=500),
)
assert s1.is_healthy() is True
assert s2.is_healthy() is True
assert s3.is_healthy() is True
# AND metadata
tag_and_team = manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": tag, "team": "t1"}, page_size=50)
)
ids = {info.id for info in tag_and_team.sandbox_infos}
assert s1.id in ids
assert s2.id in ids
assert s3.id not in ids
tag_team_env = manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": tag, "team": "t1", "env": "prod"}, page_size=50)
)
ids = {info.id for info in tag_team_env.sandbox_infos}
assert s1.id in ids
assert s2.id not in ids
assert s3.id not in ids
tag_env = manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": tag, "env": "prod"}, page_size=50)
)
ids = {info.id for info in tag_env.sandbox_infos}
assert s1.id in ids
assert s3.id in ids
assert s2.id not in ids
none_match = manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": tag, "team": "t2"}, page_size=50)
)
assert all(info.id not in {s1.id, s2.id, s3.id} for info in none_match.sandbox_infos)
patched = manager.patch_sandbox_metadata(s2.id, {"env": "stage", "team": None})
assert patched.metadata["env"] == "stage"
assert "team" not in patched.metadata
refreshed = manager.get_sandbox_info(s2.id)
assert refreshed.metadata["env"] == "stage"
assert "team" not in refreshed.metadata
finally:
for s in [s1, s2, s3]:
if s is None:
continue
try:
s.kill()
except Exception:
pass
try:
s.close()
except Exception:
pass
manager.close()
@@ -0,0 +1,909 @@
#
# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""E2E coverage for the asyncio Python sandbox pool."""
from __future__ import annotations
import asyncio
import os
import uuid
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from opensandbox import Sandbox, SandboxManager
from opensandbox.config import ConnectionConfig
from opensandbox.exceptions import (
PoolAcquireFailedException,
PoolDestroyedException,
PoolEmptyException,
PoolNotRunningException,
)
from opensandbox.models.sandboxes import SandboxFilter
from opensandbox.pool import (
AcquirePolicy,
AsyncPoolStateStore,
InMemoryAsyncPoolStateStore,
PoolCreationSpec,
PoolDestroyOptions,
PoolDestroyState,
PoolSnapshot,
PoolState,
SandboxPoolManagerAsync,
SandboxPoolAsync,
)
from opensandbox.pool_redis import AsyncRedisPoolStateStore
from tests.base_e2e_test import (
create_connection_config,
get_e2e_sandbox_resource,
get_sandbox_image,
)
MAX_IDLE = 2
RECONCILE_INTERVAL = timedelta(seconds=1)
PRIMARY_LOCK_TTL = timedelta(seconds=4)
DRAIN_TIMEOUT = timedelta(milliseconds=300)
AWAIT_TIMEOUT = timedelta(minutes=2)
@pytest.mark.e2e
class TestSandboxPoolSingleNodeE2EAsync:
"""Single-event-loop async in-memory pool E2E scenarios."""
@pytest.fixture(autouse=True)
async def _pool_lifecycle(self):
self.tag = _tag("py-async-pool")
self.pool_name = f"pool-{self.tag}"
self.store = InMemoryAsyncPoolStateStore()
self.manager = await SandboxManager.create(create_connection_config())
self.borrowed: list[Sandbox] = []
self.pool = _create_pool(
pool_name=self.pool_name,
owner_id=f"owner-{self.tag}",
state_store=self.store,
tag=self.tag,
max_idle=MAX_IDLE,
)
await self.pool.start()
try:
yield
finally:
await _cleanup_borrowed(self.borrowed)
await _cleanup_pool(self.pool)
await _cleanup_tagged_sandboxes(self.manager, self.tag)
await self.manager.close()
@pytest.mark.timeout(240)
async def test_async_warmup_acquire_command_resize_and_shutdown(self) -> None:
await _eventually(
"async pool becomes healthy with warm idle",
lambda: _snapshot_matches(
self.pool,
lambda snap: snap.state == PoolState.HEALTHY and snap.idle_count >= 1,
),
)
sandbox = await self.pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
assert await sandbox.is_healthy()
result = await sandbox.commands.run("echo py-async-pool-ok")
assert result.error is None
assert result.logs.stdout[0].text == "py-async-pool-ok"
await self.pool.resize(0)
released = await self.pool.release_all_idle()
assert released >= 0
await _eventually(
"async idle drains after resize zero",
lambda: _snapshot_matches(self.pool, lambda snap: snap.idle_count == 0),
)
with pytest.raises(PoolEmptyException):
await self.pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
direct = await self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(direct)
assert await direct.is_healthy()
await self.pool.shutdown(graceful=True)
with pytest.raises(PoolNotRunningException):
await self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
@pytest.mark.timeout(240)
async def test_async_destroy_drains_idle_writes_tombstone_and_blocks_acquire(self) -> None:
await _eventually(
"async pool has warm idle before destroy",
lambda: _snapshot_matches(self.pool, lambda snap: snap.idle_count >= 1),
)
manager = SandboxPoolManagerAsync(
state_store=self.store,
connection_config=create_connection_config(),
owner_id=f"destroyer-{self.tag}",
)
result = await manager.destroy(
self.pool_name,
PoolDestroyOptions(drain_timeout=timedelta(seconds=30)),
)
assert result.state == PoolDestroyState.DESTROYED
assert result.drained_idle_count >= 1
assert result.persistent_state_cleared
assert await self.store.get_destroy_state(self.pool_name) == PoolDestroyState.DESTROYED
with pytest.raises(PoolDestroyedException):
await self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
@pytest.mark.timeout(300)
async def test_async_lifecycle_idempotency_release_remote_and_rewarm(self) -> None:
await self.pool.start()
await _eventually(
"async pool warms before lifecycle checks",
lambda: _snapshot_matches(self.pool, lambda snap: snap.idle_count >= 1),
)
await self.pool.shutdown(False)
await self.pool.shutdown(False)
assert (await self.pool.snapshot()).state == PoolState.STOPPED
with pytest.raises(PoolNotRunningException):
await self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
await self.pool.release_all_idle()
assert (await self.pool.snapshot()).idle_count == 0
await self.store.put_idle(self.pool_name, f"injected-a-{uuid.uuid4().hex}")
await self.store.put_idle(self.pool_name, f"injected-b-{uuid.uuid4().hex}")
assert await self.pool.release_all_idle() == 2
assert (await self.pool.snapshot()).idle_count == 0
await self.pool.start()
await _eventually(
"async pool rewarms after restart",
lambda: _snapshot_matches(self.pool, lambda snap: snap.idle_count >= 1),
)
await self.pool.resize(0)
assert await self.pool.release_all_idle() >= 0
await _eventually(
"async releaseAllIdle reduces remote tagged sandboxes",
lambda: _async_release_drained(self.pool, self.manager, self.tag),
timeout=timedelta(seconds=60),
)
await self.pool.resize(1)
await _eventually(
"async resize from zero to positive rewarms idle",
lambda: _snapshot_matches(
self.pool,
lambda snap: snap.state == PoolState.HEALTHY and snap.idle_count >= 1,
),
)
@pytest.mark.timeout(240)
async def test_async_stale_idle_preparer_snapshot_and_context_manager(self) -> None:
await self.store.put_idle(self.pool_name, f"missing-{uuid.uuid4().hex}")
fallback = await self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(fallback)
assert await fallback.is_healthy()
await _cleanup_pool(self.pool)
marker_path = f"/tmp/{self.tag}-prepared.txt"
async def preparer(sandbox: Sandbox) -> None:
result = await sandbox.commands.run(f"printf async-prepared > {marker_path}")
assert result.error is None
prepared_pool = _create_pool(
pool_name=f"prepared-{self.pool_name}",
owner_id=f"prepared-owner-{self.tag}",
state_store=InMemoryAsyncPoolStateStore(),
tag=self.tag,
max_idle=1,
warmup_sandbox_preparer=preparer,
)
async with prepared_pool:
await _eventually(
"async prepared pool warms",
lambda: _snapshot_matches(prepared_pool, lambda snap: snap.idle_count >= 1),
)
entries = await prepared_pool.snapshot_idle_entries()
assert entries
assert all(entry.expires_at > datetime.now(timezone.utc) for entry in entries)
sandbox = await prepared_pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
result = await sandbox.commands.run(f"cat {marker_path}")
assert result.error is None
assert result.logs.stdout[0].text == "async-prepared"
@pytest.mark.timeout(300)
async def test_async_concurrent_shutdown_and_acquire_does_not_deadlock(self) -> None:
await _eventually(
"async pool has warm idle before shutdown race",
lambda: _snapshot_matches(self.pool, lambda snap: snap.idle_count >= 1),
)
start = asyncio.Event()
errors: list[BaseException] = []
async def acquire_during_shutdown() -> None:
try:
await start.wait()
sandbox = await self.pool.acquire(
timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE
)
self.borrowed.append(sandbox)
except PoolNotRunningException:
return
except BaseException as exc:
errors.append(exc)
raise
async def shutdown_during_acquire() -> None:
await start.wait()
await self.pool.shutdown(graceful=True)
tasks = [asyncio.create_task(acquire_during_shutdown()) for _ in range(4)]
tasks.append(asyncio.create_task(shutdown_during_acquire()))
start.set()
await asyncio.wait_for(asyncio.gather(*tasks), timeout=180)
assert not errors
@pytest.mark.timeout(300)
async def test_async_warmup_concurrency_above_one_reaches_target_and_stays_bounded(self) -> None:
await _cleanup_pool(self.pool)
concurrent_tag = _tag("py-async-pool-warmup-concurrency")
concurrent_pool = _create_pool(
pool_name=f"concurrent-{self.pool_name}",
owner_id=f"concurrent-owner-{self.tag}",
state_store=InMemoryAsyncPoolStateStore(),
tag=concurrent_tag,
max_idle=3,
warmup_concurrency=2,
)
try:
await concurrent_pool.start()
await _eventually(
"async concurrent warmup fills configured idle target",
lambda: _snapshot_and_remote_count_match(
concurrent_pool,
self.manager,
concurrent_tag,
lambda snap, count: snap.idle_count >= 3 and count <= 3,
),
timeout=timedelta(seconds=90),
)
finally:
await _cleanup_pool(concurrent_pool)
await _cleanup_tagged_sandboxes(self.manager, concurrent_tag)
@pytest.mark.timeout(240)
async def test_async_broken_connection_degrades_and_healthy_pool_still_works(self) -> None:
await _cleanup_pool(self.pool)
bad_tag = _tag("py-async-pool-bad")
bad_pool = _create_pool(
pool_name=f"bad-{self.pool_name}",
owner_id=f"bad-owner-{self.tag}",
state_store=InMemoryAsyncPoolStateStore(),
tag=bad_tag,
max_idle=1,
connection_config=_broken_connection_config(),
degraded_threshold=1,
warmup_ready_timeout=timedelta(seconds=1),
acquire_ready_timeout=timedelta(seconds=1),
)
try:
await bad_pool.start()
await _eventually(
"async bad pool enters degraded state",
lambda: _snapshot_matches(
bad_pool, lambda snap: snap.state == PoolState.DEGRADED
),
timeout=timedelta(seconds=60),
)
snapshot = await bad_pool.snapshot()
assert snapshot.last_error
assert snapshot.idle_count == 0
with pytest.raises(PoolEmptyException):
await bad_pool.acquire(timedelta(minutes=1), AcquirePolicy.FAIL_FAST)
with pytest.raises(Exception):
await bad_pool.acquire(timedelta(minutes=1), AcquirePolicy.DIRECT_CREATE)
finally:
await _cleanup_pool(bad_pool)
await _cleanup_tagged_sandboxes(self.manager, bad_tag)
healthy_tag = _tag("py-async-pool-good")
healthy_pool = _create_pool(
pool_name=f"healthy-{self.pool_name}",
owner_id=f"healthy-owner-{self.tag}",
state_store=InMemoryAsyncPoolStateStore(),
tag=healthy_tag,
max_idle=1,
)
try:
await healthy_pool.start()
await _eventually(
"async healthy pool still works after broken pool path",
lambda: _snapshot_matches(healthy_pool, lambda snap: snap.idle_count >= 1),
)
sandbox = await healthy_pool.acquire(
timedelta(minutes=5), AcquirePolicy.FAIL_FAST
)
self.borrowed.append(sandbox)
assert await sandbox.is_healthy()
finally:
await _cleanup_pool(healthy_pool)
await _cleanup_tagged_sandboxes(self.manager, healthy_tag)
@pytest.mark.e2e
class TestSandboxPoolRedisDistributedE2EAsync:
"""Redis-backed async pool E2E scenarios."""
@pytest.fixture(autouse=True)
async def _redis_lifecycle(self):
redis_url = os.getenv("OPENSANDBOX_TEST_REDIS_URL")
if not redis_url:
pytest.skip("Set OPENSANDBOX_TEST_REDIS_URL to run Redis-backed pool E2E tests")
redis_module = pytest.importorskip("redis.asyncio")
self.redis = redis_module.Redis.from_url(redis_url, decode_responses=True)
self.key_prefix = f"opensandbox:e2e:{uuid.uuid4()}"
self.manager = await SandboxManager.create(create_connection_config())
self.borrowed: list[Sandbox] = []
self.pools: list[SandboxPoolAsync] = []
self.tag = _tag("py-async-redis-pool")
try:
yield
finally:
await _cleanup_borrowed(self.borrowed)
for pool in self.pools:
await _cleanup_pool(pool)
await _cleanup_tagged_sandboxes(self.manager, self.tag)
await self.manager.close()
async for key in self.redis.scan_iter(f"{self.key_prefix}:*"):
await self.redis.delete(key)
await self.redis.aclose()
@pytest.mark.timeout(360)
async def test_async_redis_cross_node_acquire_resize_and_concurrent_uniqueness(self) -> None:
pool_name = f"async-redis-pool-{self.tag}"
pool_a = _create_pool(
pool_name,
f"owner-a-{self.tag}",
AsyncRedisPoolStateStore(self.redis, self.key_prefix),
self.tag,
2,
)
pool_b = _create_pool(
pool_name,
f"owner-b-{self.tag}",
AsyncRedisPoolStateStore(self.redis, self.key_prefix),
self.tag,
2,
)
self.pools.extend([pool_a, pool_b])
await pool_a.start()
await pool_b.start()
await _eventually(
"async Redis pool warms two idle",
lambda: _snapshot_matches(pool_a, lambda snap: snap.idle_count >= 2),
)
acquired = await asyncio.gather(
pool_a.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST),
pool_b.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST),
)
self.borrowed.extend(acquired)
assert len({sandbox.id for sandbox in acquired}) == 2
assert all([await sandbox.is_healthy() for sandbox in acquired])
result = await acquired[0].commands.run("echo py-async-redis-ok")
assert result.error is None
await pool_b.resize(0)
await _eventually(
"async Redis idle drains after shared resize",
lambda: _snapshot_matches(pool_a, lambda snap: snap.idle_count == 0),
)
await asyncio.sleep(RECONCILE_INTERVAL.total_seconds() * 2)
assert (await pool_a.snapshot()).idle_count == 0
with pytest.raises(PoolEmptyException):
await pool_a.acquire(timedelta(minutes=2), AcquirePolicy.FAIL_FAST)
direct = await pool_a.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(direct)
assert await direct.is_healthy()
result = await direct.commands.run("echo py-async-redis-direct-create-ok")
assert result.error is None
assert (await pool_a.snapshot()).idle_count == 0
@pytest.mark.timeout(420)
async def test_async_redis_primary_failover_and_restart_stay_bounded(self) -> None:
pool_name = f"async-redis-failover-{self.tag}"
owner_a = f"owner-a-{self.tag}"
owner_b = f"owner-b-{self.tag}"
store_a = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
store_b = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, owner_a, store_a, self.tag, 1)
pool_b = _create_pool(pool_name, owner_b, store_b, self.tag, 1)
self.pools.extend([pool_a, pool_b])
lock_key = store_a._primary_lock_key(pool_name)
await pool_a.start()
await _eventually(
"async first Redis node owns primary lock and warms",
lambda: _redis_lock_and_snapshot_match(
self.redis,
lock_key,
owner_a,
pool_a,
lambda snap: snap.idle_count >= 1,
),
)
await pool_b.start()
await pool_a.shutdown(False)
await pool_b.resize(1)
await _eventually(
"async Redis primary lock fails over",
lambda: _redis_lock_and_snapshot_match(
self.redis,
lock_key,
owner_b,
pool_b,
lambda snap: snap.idle_count >= 1,
),
timeout=timedelta(seconds=60),
)
await pool_a.start()
await pool_b.resize(1)
await _eventually(
"async Redis restart stays bounded",
lambda: _snapshot_and_remote_count_match(
pool_a,
self.manager,
self.tag,
lambda snap, count: snap.idle_count <= 1 and count <= 2,
),
timeout=timedelta(seconds=60),
)
@pytest.mark.timeout(420)
async def test_async_redis_start_overwrites_stale_shared_max_idle_after_restart(self) -> None:
pool_name = f"async-redis-restart-config-{self.tag}"
store_a = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 1)
self.pools.append(pool_a)
await pool_a.start()
await _eventually(
"async initial Redis pool warms",
lambda: _snapshot_matches(pool_a, lambda snap: snap.idle_count >= 1),
)
await pool_a.resize(0)
await _eventually(
"async initial Redis pool drains to zero",
lambda: _snapshot_matches(pool_a, lambda snap: snap.idle_count == 0),
)
await pool_a.shutdown(False)
store_b = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.append(pool_b)
await pool_b.start()
await _eventually(
"async restart with same Redis namespace uses new configured max_idle",
lambda: _snapshot_matches(
pool_b,
lambda snap: snap.max_idle == 2 and snap.idle_count >= 2,
),
)
@pytest.mark.timeout(420)
async def test_async_redis_secondary_resize_is_applied_by_primary_periodic_reconcile(self) -> None:
pool_name = f"async-redis-secondary-resize-{self.tag}"
owner_a = f"owner-a-{self.tag}"
owner_b = f"owner-b-{self.tag}"
store_a = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
store_b = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, owner_a, store_a, self.tag, 2)
pool_b = _create_pool(pool_name, owner_b, store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
lock_key = store_a._primary_lock_key(pool_name)
await pool_a.start()
await _eventually(
"async primary Redis node owns lock and warms",
lambda: _redis_lock_and_snapshot_match(
self.redis,
lock_key,
owner_a,
pool_a,
lambda snap: snap.idle_count >= 2,
),
)
await pool_b.start()
await pool_b.resize(0)
await _eventually(
"async secondary resize to zero is applied by primary",
lambda: _redis_lock_and_snapshot_match(
self.redis,
lock_key,
owner_a,
pool_a,
lambda snap: snap.idle_count == 0,
),
)
await pool_b.resize(2)
await _eventually(
"async secondary resize up is applied by primary",
lambda: _redis_lock_and_snapshot_match(
self.redis,
lock_key,
owner_a,
pool_a,
lambda snap: snap.idle_count >= 2,
),
)
@pytest.mark.timeout(420)
async def test_async_redis_concurrent_acquire_and_resize_jitter_remain_bounded(self) -> None:
pool_name = f"async-redis-acquire-resize-jitter-{self.tag}"
store_a = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
store_b = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 2)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
await pool_a.start()
await pool_b.start()
await _eventually(
"async Redis jitter pool warms two idle",
lambda: _snapshot_matches(pool_a, lambda snap: snap.idle_count >= 2),
)
async def acquire_once(index: int) -> Sandbox:
pool = pool_a if index % 2 == 0 else pool_b
sandbox = await pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
result = await sandbox.commands.run(f"echo py-async-redis-jitter-{index}")
assert result.error is None
return sandbox
async def resize_jitter() -> None:
for index in range(8):
await (pool_a if index % 2 == 0 else pool_b).resize(index % 3)
await asyncio.sleep(0.2)
await pool_b.resize(2)
acquired, _ = await asyncio.gather(
asyncio.gather(*(acquire_once(i) for i in range(4))),
resize_jitter(),
)
self.borrowed.extend(acquired)
assert len({sandbox.id for sandbox in acquired}) == 4
await _eventually(
"async Redis acquire plus resize jitter converges and stays bounded",
lambda: _snapshot_and_remote_count_match(
pool_a,
self.manager,
self.tag,
lambda snap, count: snap.idle_count <= 2 and count <= 8,
),
timeout=timedelta(seconds=90),
)
@pytest.mark.timeout(360)
async def test_async_redis_stale_idle_is_removed_and_direct_create_fallback_works(self) -> None:
pool_name = f"async-redis-stale-{self.tag}"
store_a = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
store_b = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 0)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 0)
self.pools.extend([pool_a, pool_b])
await pool_a.start()
await pool_b.start()
await store_a.put_idle(pool_name, f"missing-{uuid.uuid4().hex}")
with pytest.raises(PoolAcquireFailedException):
await pool_b.acquire(timedelta(seconds=2), AcquirePolicy.FAIL_FAST)
assert (await store_a.snapshot_counters(pool_name)).idle_count == 0
sandbox = await pool_b.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(sandbox)
assert await sandbox.is_healthy()
@pytest.mark.timeout(60)
async def test_async_redis_expired_idle_is_not_removed_by_snapshot_but_take_reaps_it(self) -> None:
store = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
pool_name = f"async-redis-expired-idle-{self.tag}"
await store.set_idle_entry_ttl(pool_name, timedelta(milliseconds=50))
await store.put_idle(pool_name, f"expired-{uuid.uuid4().hex}")
await asyncio.sleep(0.1)
assert (await store.snapshot_counters(pool_name)).idle_count == 1
assert await store.try_take_idle(pool_name) is None
assert (await store.snapshot_counters(pool_name)).idle_count == 0
@pytest.mark.timeout(420)
async def test_async_redis_lost_lock_window_discards_orphan_and_recovers(self) -> None:
pool_name = f"async-redis-renew-window-{self.tag}"
owner = f"owner-a-{self.tag}"
store = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
lock_key = store._primary_lock_key(pool_name)
dropped_once = asyncio.Event()
async def drop_lock_once(_: Sandbox) -> None:
if not dropped_once.is_set():
dropped_once.set()
await self.redis.delete(lock_key)
pool = _create_pool(
pool_name,
owner,
store,
self.tag,
1,
warmup_sandbox_preparer=drop_lock_once,
)
self.pools.append(pool)
await pool.start()
await _eventually(
"async Redis pool recovers after losing primary lock during warmup",
lambda: _snapshot_matches(pool, lambda snap: snap.idle_count == 1),
timeout=timedelta(seconds=90),
interval=timedelta(milliseconds=500),
)
await _eventually(
"async lost-lock orphan cleanup keeps remote count bounded",
lambda: _remote_count_matches(self.manager, self.tag, lambda count: count == 1),
timeout=timedelta(seconds=60),
)
@pytest.mark.timeout(420)
async def test_async_redis_destroy_tombstone_blocks_all_nodes_and_direct_create(self) -> None:
pool_name = f"async-redis-destroy-{self.tag}"
store_a = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
store_b = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 1)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 1)
self.pools.extend([pool_a, pool_b])
await pool_a.start()
await pool_b.start()
await _eventually(
"async Redis destroy pool warms",
lambda: _snapshot_matches(pool_a, lambda snap: snap.idle_count >= 1),
)
pool_manager = SandboxPoolManagerAsync(
state_store=store_b,
connection_config=create_connection_config(),
owner_id=f"destroyer-{self.tag}",
)
result = await pool_manager.destroy(
pool_name,
PoolDestroyOptions(drain_timeout=timedelta(seconds=60)),
)
assert result.state == PoolDestroyState.DESTROYED
assert await store_a.get_destroy_state(pool_name) == PoolDestroyState.DESTROYED
with pytest.raises(PoolDestroyedException):
await pool_a.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
with pytest.raises(PoolDestroyedException):
await pool_b.resize(1)
@pytest.mark.timeout(60)
async def test_async_redis_begin_destroy_fence_blocks_start_and_direct_create(self) -> None:
pool_name = f"async-redis-destroying-fence-{self.tag}"
store = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
await store.begin_destroy(pool_name, f"destroyer-{self.tag}")
assert await store.get_destroy_state(pool_name) == PoolDestroyState.DESTROYING
pool = _create_pool(pool_name, f"owner-{self.tag}", store, self.tag, 0)
self.pools.append(pool)
with pytest.raises(PoolDestroyedException):
await pool.start()
running_pool_name = f"async-redis-destroying-running-{self.tag}"
running_store = AsyncRedisPoolStateStore(self.redis, self.key_prefix)
running_pool = _create_pool(
running_pool_name,
f"owner-running-{self.tag}",
running_store,
self.tag,
0,
)
self.pools.append(running_pool)
await running_pool.start()
await running_store.begin_destroy(running_pool_name, f"destroyer-{self.tag}")
with pytest.raises(PoolDestroyedException):
await running_pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
def _create_pool(
pool_name: str,
owner_id: str,
state_store: AsyncPoolStateStore,
tag: str,
max_idle: int,
warmup_sandbox_preparer: Callable[[Sandbox], Awaitable[None]] | None = None,
connection_config: ConnectionConfig | None = None,
degraded_threshold: int = 3,
warmup_ready_timeout: timedelta = timedelta(seconds=30),
acquire_ready_timeout: timedelta = timedelta(seconds=30),
warmup_concurrency: int = 1,
) -> SandboxPoolAsync:
return SandboxPoolAsync(
pool_name=pool_name,
owner_id=owner_id,
max_idle=max_idle,
warmup_concurrency=warmup_concurrency,
state_store=state_store,
connection_config=connection_config or create_connection_config(),
creation_spec=PoolCreationSpec(
image=get_sandbox_image(),
entrypoint=["tail", "-f", "/dev/null"],
metadata={"tag": tag, "suite": "sandbox-pool-python-async-e2e"},
env={
"E2E_TEST": "true",
"EXECD_API_GRACE_SHUTDOWN": "3s",
"EXECD_JUPYTER_IDLE_POLL_INTERVAL": "1s",
},
resource=get_e2e_sandbox_resource(),
),
reconcile_interval=RECONCILE_INTERVAL,
primary_lock_ttl=PRIMARY_LOCK_TTL,
drain_timeout=DRAIN_TIMEOUT,
warmup_sandbox_preparer=warmup_sandbox_preparer,
degraded_threshold=degraded_threshold,
warmup_ready_timeout=warmup_ready_timeout,
acquire_ready_timeout=acquire_ready_timeout,
)
def _broken_connection_config() -> ConnectionConfig:
return ConnectionConfig(
domain="127.0.0.1:9",
api_key="broken-e2e-test",
request_timeout=timedelta(seconds=1),
transport=httpx.AsyncHTTPTransport(
limits=httpx.Limits(max_connections=2, max_keepalive_connections=0)
),
)
async def _eventually(
description: str,
condition: Callable[[], Awaitable[bool]],
timeout: timedelta = AWAIT_TIMEOUT,
interval: timedelta = timedelta(seconds=1),
) -> None:
deadline = asyncio.get_running_loop().time() + timeout.total_seconds()
last_error: BaseException | None = None
while asyncio.get_running_loop().time() < deadline:
try:
if await condition():
return
except BaseException as exc:
last_error = exc
await asyncio.sleep(interval.total_seconds())
if last_error is not None:
raise AssertionError(f"Timed out waiting for {description}") from last_error
raise AssertionError(f"Timed out waiting for {description}")
async def _snapshot_matches(
pool: SandboxPoolAsync,
predicate: Callable[[PoolSnapshot], bool],
) -> bool:
return predicate(await pool.snapshot())
async def _cleanup_pool(pool: SandboxPoolAsync) -> None:
try:
await pool.resize(0)
except Exception:
pass
try:
await pool.release_all_idle()
except Exception:
pass
try:
await pool.shutdown(False)
except Exception:
pass
async def _cleanup_borrowed(sandboxes: list[Sandbox]) -> None:
for sandbox in sandboxes:
try:
await sandbox.kill()
except Exception:
pass
try:
await sandbox.close()
except Exception:
pass
sandboxes.clear()
async def _cleanup_tagged_sandboxes(manager: SandboxManager, tag: str) -> None:
for _ in range(5):
try:
infos = await manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": tag}, page_size=50)
)
if not infos.sandbox_infos:
return
for info in infos.sandbox_infos:
try:
await manager.kill_sandbox(info.id)
except Exception:
pass
except Exception:
return
async def _count_tagged_sandboxes(manager: SandboxManager, tag: str) -> int:
infos = await manager.list_sandbox_infos(SandboxFilter(metadata={"tag": tag}, page_size=50))
return len(infos.sandbox_infos)
async def _async_release_drained(
pool: SandboxPoolAsync,
manager: SandboxManager,
tag: str,
) -> bool:
snapshot = await pool.snapshot()
return snapshot.idle_count == 0 and await _count_tagged_sandboxes(manager, tag) == 0
async def _redis_lock_and_snapshot_match(
redis: object,
lock_key: str,
owner_id: str,
pool: SandboxPoolAsync,
predicate: Callable[[PoolSnapshot], bool],
) -> bool:
owner = await redis.get(lock_key) # type: ignore[attr-defined]
return owner == owner_id and predicate(await pool.snapshot())
async def _snapshot_and_remote_count_match(
pool: SandboxPoolAsync,
manager: SandboxManager,
tag: str,
predicate: Callable[[PoolSnapshot, int], bool],
) -> bool:
return predicate(await pool.snapshot(), await _count_tagged_sandboxes(manager, tag))
async def _remote_count_matches(
manager: SandboxManager,
tag: str,
predicate: Callable[[int], bool],
) -> bool:
return predicate(await _count_tagged_sandboxes(manager, tag))
def _tag(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
@@ -0,0 +1,928 @@
#
# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""E2E coverage for the synchronous Python sandbox pool."""
from __future__ import annotations
import os
import threading
import time
import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from opensandbox import SandboxManagerSync, SandboxSync
from opensandbox.config import ConnectionConfigSync
from opensandbox.exceptions import (
PoolAcquireFailedException,
PoolDestroyedException,
PoolEmptyException,
PoolNotRunningException,
)
from opensandbox.models.sandboxes import SandboxFilter
from opensandbox.pool import (
AcquirePolicy,
InMemoryPoolStateStore,
PoolCreationSpec,
PoolDestroyOptions,
PoolDestroyState,
PoolState,
PoolStateStore,
SandboxPoolManagerSync,
SandboxPoolSync,
)
from opensandbox.pool_redis import RedisPoolStateStore
from tests.base_e2e_test import (
create_connection_config_sync,
get_e2e_sandbox_resource,
get_sandbox_image,
)
MAX_IDLE = 2
RECONCILE_INTERVAL = timedelta(seconds=1)
PRIMARY_LOCK_TTL = timedelta(seconds=4)
DRAIN_TIMEOUT = timedelta(milliseconds=300)
AWAIT_TIMEOUT = timedelta(minutes=2)
@pytest.mark.e2e
class TestSandboxPoolSingleNodeE2ESync:
"""Single-process in-memory pool E2E scenarios."""
def setup_method(self) -> None:
self.tag = _tag("py-pool")
self.pool_name = f"pool-{self.tag}"
self.store = InMemoryPoolStateStore()
self.manager = SandboxManagerSync.create(create_connection_config_sync())
self.borrowed: list[SandboxSync] = []
self.pool = _create_pool(
pool_name=self.pool_name,
owner_id=f"owner-{self.tag}",
state_store=self.store,
tag=self.tag,
max_idle=MAX_IDLE,
)
self.pool.start()
def teardown_method(self) -> None:
_cleanup_borrowed(self.borrowed)
_cleanup_pool(self.pool)
_cleanup_tagged_sandboxes(self.manager, self.tag)
self.manager.close()
@pytest.mark.timeout(240)
def test_warmup_acquire_fail_fast_and_command(self) -> None:
_eventually(
"pool becomes healthy with warm idle",
lambda: self.pool.snapshot().state == PoolState.HEALTHY
and self.pool.snapshot().idle_count >= 1,
)
sandbox = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
assert sandbox.is_healthy()
result = sandbox.commands.run("echo py-pool-basic-ok")
assert result.error is None
assert result.logs.stdout[0].text == "py-pool-basic-ok"
@pytest.mark.timeout(240)
def test_resize_release_fail_fast_and_direct_create_fallback(self) -> None:
_eventually("pool has warm idle", lambda: self.pool.snapshot().idle_count >= 1)
self.pool.resize(0)
released = self.pool.release_all_idle()
assert released >= 0
_eventually("idle drains after resize zero", lambda: self.pool.snapshot().idle_count == 0)
with pytest.raises(PoolEmptyException):
self.pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
direct = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(direct)
assert direct.is_healthy()
@pytest.mark.timeout(240)
def test_destroy_drains_idle_writes_tombstone_and_blocks_acquire(self) -> None:
_eventually("pool has warm idle before destroy", lambda: self.pool.snapshot().idle_count >= 1)
manager = SandboxPoolManagerSync(
state_store=self.store,
connection_config=create_connection_config_sync(),
owner_id=f"destroyer-{self.tag}",
)
result = manager.destroy(
self.pool_name,
PoolDestroyOptions(drain_timeout=timedelta(seconds=30)),
)
assert result.state == PoolDestroyState.DESTROYED
assert result.drained_idle_count >= 1
assert result.persistent_state_cleared
assert self.store.get_destroy_state(self.pool_name) == PoolDestroyState.DESTROYED
with pytest.raises(PoolDestroyedException):
self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
@pytest.mark.timeout(240)
def test_stale_idle_fallback_shutdown_restart_and_snapshot(self) -> None:
self.store.put_idle(self.pool_name, f"missing-{time.monotonic_ns()}")
fallback = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(fallback)
assert fallback.is_healthy()
self.pool.shutdown(graceful=True)
with pytest.raises(PoolNotRunningException):
self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
stopped = self.pool.snapshot()
assert stopped.state == PoolState.STOPPED
assert stopped.lifecycle_state.value == "STOPPED"
self.pool.start()
_eventually(
"pool restarts and warms idle",
lambda: self.pool.snapshot().state == PoolState.HEALTHY
and self.pool.snapshot().idle_count >= 1,
)
entries = self.pool.snapshot_idle_entries()
assert entries
assert all(entry.sandbox_id for entry in entries)
assert all(entry.expires_at > datetime.now(timezone.utc) for entry in entries)
@pytest.mark.timeout(360)
def test_lifecycle_idempotency_resize_rewarm_and_release_remote(self) -> None:
self.pool.start()
_eventually("pool warms before lifecycle checks", lambda: self.pool.snapshot().idle_count >= 1)
self.pool.shutdown(False)
self.pool.shutdown(False)
assert self.pool.snapshot().state == PoolState.STOPPED
with pytest.raises(PoolNotRunningException):
self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.pool.release_all_idle()
assert self.pool.snapshot().idle_count == 0
self.store.put_idle(self.pool_name, f"injected-a-{uuid.uuid4().hex}")
self.store.put_idle(self.pool_name, f"injected-b-{uuid.uuid4().hex}")
assert self.pool.release_all_idle() == 2
assert self.pool.snapshot().idle_count == 0
self.pool.start()
_eventually("pool rewarms after restart", lambda: self.pool.snapshot().idle_count >= 1)
self.pool.resize(0)
assert self.pool.release_all_idle() >= 0
_eventually(
"releaseAllIdle reduces remote tagged sandboxes",
lambda: self.pool.snapshot().idle_count == 0
and _count_tagged_sandboxes(self.manager, self.tag) == 0,
timeout=timedelta(seconds=60),
)
self.pool.resize(1)
_eventually(
"resize from zero to positive rewarms idle",
lambda: self.pool.snapshot().state == PoolState.HEALTHY
and self.pool.snapshot().idle_count >= 1,
)
@pytest.mark.timeout(360)
def test_concurrent_acquire_resize_and_shutdown_do_not_duplicate_or_deadlock(self) -> None:
_eventually("pool reaches target idle", lambda: self.pool.snapshot().idle_count >= MAX_IDLE)
start = threading.Event()
acquired_ids: set[str] = set()
acquired_lock = threading.Lock()
errors: list[BaseException] = []
def worker(index: int) -> None:
try:
start.wait()
sandbox = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(sandbox)
with acquired_lock:
assert sandbox.id not in acquired_ids
acquired_ids.add(sandbox.id)
result = sandbox.commands.run(f"echo py-pool-concurrent-{index}")
assert result.error is None
except BaseException as exc:
errors.append(exc)
raise
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(worker, i) for i in range(4)]
start.set()
for future in as_completed(futures, timeout=180):
future.result()
assert not errors
assert len(acquired_ids) == 4
assert _count_tagged_sandboxes(self.manager, self.tag) <= 8
# Race acquire and graceful shutdown. POOL_NOT_RUNNING is the expected rejected path.
self.pool.resize(1)
self.pool.start()
_eventually("pool rewarmed before shutdown race", lambda: self.pool.snapshot().idle_count >= 1)
race_errors: list[BaseException] = []
start.clear()
def acquire_during_shutdown() -> None:
try:
start.wait()
sandbox = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(sandbox)
except PoolNotRunningException:
return
except BaseException as exc:
race_errors.append(exc)
raise
def shutdown_during_acquire() -> None:
start.wait()
self.pool.shutdown(True)
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(acquire_during_shutdown) for _ in range(4)]
futures.append(executor.submit(shutdown_during_acquire))
start.set()
for future in as_completed(futures, timeout=180):
future.result()
assert not race_errors
@pytest.mark.timeout(360)
def test_concurrent_start_shutdown_stress_single_node(self) -> None:
errors: list[BaseException] = []
start = threading.Event()
def worker(index: int) -> None:
try:
start.wait()
for _ in range(3):
if index % 2 == 0:
self.pool.start()
else:
self.pool.shutdown(index % 3 == 0)
time.sleep(0.05)
except BaseException as exc:
errors.append(exc)
raise
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(worker, i) for i in range(4)]
start.set()
for future in as_completed(futures, timeout=180):
future.result()
assert not errors
self.pool.start()
_eventually("pool remains usable after lifecycle stress", lambda: self.pool.snapshot().idle_count >= 1)
@pytest.mark.timeout(300)
def test_warmup_preparer_and_pool_isolation(self) -> None:
_cleanup_pool(self.pool)
marker_path = f"/tmp/{self.tag}-prepared.txt"
def preparer(sandbox: SandboxSync) -> None:
result = sandbox.commands.run(f"printf prepared > {marker_path}")
assert result.error is None
prepared_pool = _create_pool(
pool_name=f"prepared-{self.pool_name}",
owner_id=f"prepared-owner-{self.tag}",
state_store=InMemoryPoolStateStore(),
tag=self.tag,
max_idle=1,
warmup_sandbox_preparer=preparer,
)
other_tag = _tag("py-pool-other")
other_pool = _create_pool(
pool_name=f"pool-{other_tag}",
owner_id=f"owner-{other_tag}",
state_store=InMemoryPoolStateStore(),
tag=other_tag,
max_idle=1,
)
other_manager = SandboxManagerSync.create(create_connection_config_sync())
try:
prepared_pool.start()
_eventually("prepared pool warms", lambda: prepared_pool.snapshot().idle_count >= 1)
sandbox = prepared_pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
result = sandbox.commands.run(f"cat {marker_path}")
assert result.error is None
assert result.logs.stdout[0].text == "prepared"
other_pool.start()
_eventually("other pool warms", lambda: other_pool.snapshot().idle_count >= 1)
assert _count_tagged_sandboxes(self.manager, self.tag) >= 1
assert _count_tagged_sandboxes(other_manager, other_tag) >= 1
prepared_pool.resize(0)
prepared_pool.release_all_idle()
_eventually("prepared pool drains", lambda: prepared_pool.snapshot().idle_count == 0)
assert other_pool.snapshot().idle_count >= 1
finally:
_cleanup_pool(prepared_pool)
_cleanup_pool(other_pool)
_cleanup_tagged_sandboxes(other_manager, other_tag)
other_manager.close()
@pytest.mark.timeout(300)
def test_warmup_concurrency_above_one_reaches_target_and_stays_bounded(self) -> None:
_cleanup_pool(self.pool)
concurrent_tag = _tag("py-pool-warmup-concurrency")
concurrent_pool = _create_pool(
pool_name=f"concurrent-{self.pool_name}",
owner_id=f"concurrent-owner-{self.tag}",
state_store=InMemoryPoolStateStore(),
tag=concurrent_tag,
max_idle=3,
warmup_concurrency=2,
)
try:
concurrent_pool.start()
_eventually(
"concurrent warmup fills configured idle target",
lambda: concurrent_pool.snapshot().idle_count >= 3
and _count_tagged_sandboxes(self.manager, concurrent_tag) <= 3,
timeout=timedelta(seconds=90),
)
finally:
_cleanup_pool(concurrent_pool)
_cleanup_tagged_sandboxes(self.manager, concurrent_tag)
@pytest.mark.timeout(240)
def test_broken_connection_degrades_and_healthy_pool_still_works(self) -> None:
_cleanup_pool(self.pool)
bad_tag = _tag("py-pool-bad")
bad_pool = _create_pool(
pool_name=f"bad-{self.pool_name}",
owner_id=f"bad-owner-{self.tag}",
state_store=InMemoryPoolStateStore(),
tag=bad_tag,
max_idle=1,
connection_config=_broken_connection_config(),
degraded_threshold=1,
warmup_ready_timeout=timedelta(seconds=1),
acquire_ready_timeout=timedelta(seconds=1),
)
try:
bad_pool.start()
_eventually(
"bad pool enters degraded state",
lambda: bad_pool.snapshot().state == PoolState.DEGRADED,
timeout=timedelta(seconds=60),
interval=timedelta(seconds=1),
)
snapshot = bad_pool.snapshot()
assert snapshot.last_error
assert snapshot.idle_count == 0
with pytest.raises(PoolEmptyException):
bad_pool.acquire(timedelta(minutes=1), AcquirePolicy.FAIL_FAST)
with pytest.raises(Exception):
bad_pool.acquire(timedelta(minutes=1), AcquirePolicy.DIRECT_CREATE)
finally:
_cleanup_pool(bad_pool)
_cleanup_tagged_sandboxes(self.manager, bad_tag)
healthy_tag = _tag("py-pool-good")
healthy_pool = _create_pool(
pool_name=f"healthy-{self.pool_name}",
owner_id=f"healthy-owner-{self.tag}",
state_store=InMemoryPoolStateStore(),
tag=healthy_tag,
max_idle=1,
)
try:
healthy_pool.start()
_eventually(
"healthy pool still warms after broken pool path",
lambda: healthy_pool.snapshot().idle_count >= 1,
)
sandbox = healthy_pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
assert sandbox.is_healthy()
finally:
_cleanup_pool(healthy_pool)
_cleanup_tagged_sandboxes(self.manager, healthy_tag)
@pytest.mark.e2e
class TestSandboxPoolRedisDistributedE2ESync:
"""Redis-backed multi-instance pool E2E scenarios."""
def setup_method(self) -> None:
redis_url = os.getenv("OPENSANDBOX_TEST_REDIS_URL")
if not redis_url:
pytest.skip("Set OPENSANDBOX_TEST_REDIS_URL to run Redis-backed pool E2E tests")
redis_module = pytest.importorskip("redis")
self.redis = redis_module.Redis.from_url(redis_url, decode_responses=True)
self.key_prefix = f"opensandbox:e2e:{uuid.uuid4()}"
self.manager = SandboxManagerSync.create(create_connection_config_sync())
self.borrowed: list[SandboxSync] = []
self.pools: list[SandboxPoolSync] = []
self.tag = _tag("py-pool-redis")
def teardown_method(self) -> None:
_cleanup_borrowed(self.borrowed)
for pool in self.pools:
_cleanup_pool(pool)
_cleanup_tagged_sandboxes(self.manager, self.tag)
self.manager.close()
for key in self.redis.scan_iter(f"{self.key_prefix}:*"):
self.redis.delete(key)
self.redis.close()
@pytest.mark.timeout(360)
def test_redis_cross_node_acquire_shared_resize_and_direct_create(self) -> None:
pool_name = f"redis-pool-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 2)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
_eventually("Redis pool warms", lambda: pool_a.snapshot().idle_count >= 1)
sandbox = pool_b.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
assert sandbox.is_healthy()
result = sandbox.commands.run("echo py-redis-dist-ok")
assert result.error is None
pool_b.resize(0)
_eventually("Redis idle drains after shared resize", lambda: pool_a.snapshot().idle_count == 0)
time.sleep(RECONCILE_INTERVAL.total_seconds() * 2)
assert pool_a.snapshot().idle_count == 0
with pytest.raises(PoolEmptyException):
pool_a.acquire(timedelta(minutes=2), AcquirePolicy.FAIL_FAST)
direct = pool_a.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(direct)
assert direct.is_healthy()
direct_result = direct.commands.run("echo py-redis-direct-create-ok")
assert direct_result.error is None
assert pool_a.snapshot().idle_count == 0
@pytest.mark.timeout(420)
def test_redis_primary_failover_restart_and_resize_jitter_stay_bounded(self) -> None:
pool_name = f"redis-failover-{self.tag}"
owner_a = f"owner-a-{self.tag}"
owner_b = f"owner-b-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, owner_a, store_a, self.tag, 1)
pool_b = _create_pool(pool_name, owner_b, store_b, self.tag, 1)
self.pools.extend([pool_a, pool_b])
lock_key = store_a._primary_lock_key(pool_name)
pool_a.start()
_eventually(
"first Redis node owns primary lock and warms",
lambda: self.redis.get(lock_key) == owner_a and pool_a.snapshot().idle_count >= 1,
)
pool_b.start()
pool_a.shutdown(False)
pool_b.resize(1)
_eventually(
"primary lock fails over to remaining Redis node",
lambda: self.redis.get(lock_key) == owner_b and pool_b.snapshot().idle_count >= 1,
timeout=timedelta(seconds=60),
)
pool_a.start()
for index in range(6):
(pool_a if index % 2 == 0 else pool_b).resize(index % 3)
time.sleep(0.2)
pool_b.resize(1)
_eventually(
"Redis restart and resize jitter converge to configured maxIdle",
lambda: pool_a.snapshot().idle_count <= 1
and _count_tagged_sandboxes(self.manager, self.tag) <= 2,
timeout=timedelta(seconds=60),
)
@pytest.mark.timeout(420)
def test_redis_start_overwrites_stale_shared_max_idle_after_restart(self) -> None:
pool_name = f"redis-restart-config-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 1)
self.pools.append(pool_a)
pool_a.start()
_eventually("initial Redis pool warms", lambda: pool_a.snapshot().idle_count >= 1)
pool_a.resize(0)
_eventually("initial Redis pool drains to zero", lambda: pool_a.snapshot().idle_count == 0)
pool_a.shutdown(False)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.append(pool_b)
pool_b.start()
_eventually(
"restart with same Redis namespace uses new configured max_idle",
lambda: pool_b.snapshot().max_idle == 2 and pool_b.snapshot().idle_count >= 2,
)
@pytest.mark.timeout(420)
def test_redis_secondary_resize_is_applied_by_primary_periodic_reconcile(self) -> None:
pool_name = f"redis-secondary-resize-{self.tag}"
owner_a = f"owner-a-{self.tag}"
owner_b = f"owner-b-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, owner_a, store_a, self.tag, 2)
pool_b = _create_pool(pool_name, owner_b, store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
lock_key = store_a._primary_lock_key(pool_name)
pool_a.start()
_eventually(
"primary Redis node owns lock and warms",
lambda: self.redis.get(lock_key) == owner_a and pool_a.snapshot().idle_count >= 2,
)
pool_b.start()
pool_b.resize(0)
_eventually(
"secondary resize to zero is applied by primary",
lambda: self.redis.get(lock_key) == owner_a and pool_a.snapshot().idle_count == 0,
)
pool_b.resize(2)
_eventually(
"secondary resize up is applied by primary",
lambda: self.redis.get(lock_key) == owner_a and pool_a.snapshot().idle_count >= 2,
)
@pytest.mark.timeout(360)
def test_redis_concurrent_cross_node_acquire_and_atomic_take(self) -> None:
pool_name = f"redis-concurrent-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 2)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
_eventually("Redis pool warms two idle", lambda: pool_a.snapshot().idle_count >= 2)
start = threading.Event()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(lambda: (start.wait(), pool_a.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST))[1]),
executor.submit(lambda: (start.wait(), pool_b.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST))[1]),
]
start.set()
sandboxes = [future.result(timeout=90) for future in futures]
self.borrowed.extend(sandboxes)
ids = {sandbox.id for sandbox in sandboxes}
assert len(ids) == 2
assert all(sandbox.is_healthy() for sandbox in sandboxes)
store = RedisPoolStateStore(self.redis, self.key_prefix)
contention_pool = f"redis-store-contention-{uuid.uuid4()}"
for i in range(50):
store.put_idle(contention_pool, f"id-{i}")
taken: set[str] = set()
taken_lock = threading.Lock()
def take_until_empty() -> None:
while True:
sandbox_id = store.try_take_idle(contention_pool)
if sandbox_id is None:
return
with taken_lock:
assert sandbox_id not in taken
taken.add(sandbox_id)
with ThreadPoolExecutor(max_workers=16) as executor:
futures = [executor.submit(take_until_empty) for _ in range(16)]
for future in as_completed(futures, timeout=30):
future.result()
assert len(taken) == 50
assert store.snapshot_counters(contention_pool).idle_count == 0
@pytest.mark.timeout(60)
def test_redis_expired_idle_is_not_removed_by_snapshot_but_take_reaps_it(self) -> None:
store = RedisPoolStateStore(self.redis, self.key_prefix)
pool_name = f"redis-expired-idle-{self.tag}"
store.set_idle_entry_ttl(pool_name, timedelta(milliseconds=50))
store.put_idle(pool_name, f"expired-{uuid.uuid4().hex}")
time.sleep(0.1)
assert store.snapshot_counters(pool_name).idle_count == 1
assert store.try_take_idle(pool_name) is None
assert store.snapshot_counters(pool_name).idle_count == 0
@pytest.mark.timeout(420)
def test_redis_concurrent_acquire_and_resize_jitter_remain_bounded(self) -> None:
pool_name = f"redis-acquire-resize-jitter-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 2)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
_eventually("Redis jitter pool warms two idle", lambda: pool_a.snapshot().idle_count >= 2)
acquired_ids: set[str] = set()
acquired_lock = threading.Lock()
def acquire_once(index: int) -> None:
pool = pool_a if index % 2 == 0 else pool_b
sandbox = pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(sandbox)
with acquired_lock:
assert sandbox.id not in acquired_ids
acquired_ids.add(sandbox.id)
result = sandbox.commands.run(f"echo py-redis-jitter-{index}")
assert result.error is None
def resize_jitter() -> None:
for index in range(8):
(pool_a if index % 2 == 0 else pool_b).resize(index % 3)
time.sleep(0.2)
pool_b.resize(2)
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(acquire_once, i) for i in range(4)]
futures.append(executor.submit(resize_jitter))
for future in as_completed(futures, timeout=180):
future.result()
assert len(acquired_ids) == 4
_eventually(
"Redis acquire plus resize jitter converges and stays bounded",
lambda: pool_a.snapshot().idle_count <= 2
and _count_tagged_sandboxes(self.manager, self.tag) <= 8,
timeout=timedelta(seconds=90),
)
@pytest.mark.timeout(360)
def test_redis_stale_idle_is_removed_and_direct_create_fallback_works(self) -> None:
pool_name = f"redis-stale-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 0)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 0)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
store_a.put_idle(pool_name, f"missing-{uuid.uuid4().hex}")
with pytest.raises(PoolAcquireFailedException):
pool_b.acquire(timedelta(seconds=2), AcquirePolicy.FAIL_FAST)
assert store_a.snapshot_counters(pool_name).idle_count == 0
sandbox = pool_b.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(sandbox)
assert sandbox.is_healthy()
@pytest.mark.timeout(420)
def test_redis_lost_lock_window_discards_orphan_and_recovers(self) -> None:
pool_name = f"redis-renew-window-{self.tag}"
owner = f"owner-a-{self.tag}"
store = RedisPoolStateStore(self.redis, self.key_prefix)
lock_key = store._primary_lock_key(pool_name)
dropped_once = threading.Event()
def drop_lock_once(_: SandboxSync) -> None:
if not dropped_once.is_set():
dropped_once.set()
self.redis.delete(lock_key)
pool = _create_pool(
pool_name,
owner,
store,
self.tag,
1,
warmup_sandbox_preparer=drop_lock_once,
)
self.pools.append(pool)
pool.start()
_eventually(
"Redis pool recovers after losing primary lock during warmup",
lambda: pool.snapshot().idle_count == 1,
timeout=timedelta(seconds=90),
interval=timedelta(milliseconds=500),
)
_eventually(
"lost-lock orphan cleanup keeps remote count bounded",
lambda: _count_tagged_sandboxes(self.manager, self.tag) == 1,
timeout=timedelta(seconds=60),
)
@pytest.mark.timeout(420)
def test_redis_destroy_tombstone_blocks_all_nodes_and_direct_create(self) -> None:
pool_name = f"redis-destroy-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 1)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 1)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
_eventually("Redis destroy pool warms", lambda: pool_a.snapshot().idle_count >= 1)
pool_manager = SandboxPoolManagerSync(
state_store=store_b,
connection_config=create_connection_config_sync(),
owner_id=f"destroyer-{self.tag}",
)
result = pool_manager.destroy(
pool_name,
PoolDestroyOptions(drain_timeout=timedelta(seconds=60)),
)
assert result.state == PoolDestroyState.DESTROYED
assert store_a.get_destroy_state(pool_name) == PoolDestroyState.DESTROYED
with pytest.raises(PoolDestroyedException):
pool_a.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
with pytest.raises(PoolDestroyedException):
pool_b.resize(1)
@pytest.mark.timeout(60)
def test_redis_begin_destroy_fence_blocks_start_and_direct_create(self) -> None:
pool_name = f"redis-destroying-fence-{self.tag}"
store = RedisPoolStateStore(self.redis, self.key_prefix)
store.begin_destroy(pool_name, f"destroyer-{self.tag}")
assert store.get_destroy_state(pool_name) == PoolDestroyState.DESTROYING
pool = _create_pool(pool_name, f"owner-{self.tag}", store, self.tag, 0)
self.pools.append(pool)
with pytest.raises(PoolDestroyedException):
pool.start()
running_pool_name = f"redis-destroying-running-{self.tag}"
running_store = RedisPoolStateStore(self.redis, self.key_prefix)
running_pool = _create_pool(
running_pool_name,
f"owner-running-{self.tag}",
running_store,
self.tag,
0,
)
self.pools.append(running_pool)
running_pool.start()
running_store.begin_destroy(running_pool_name, f"destroyer-{self.tag}")
with pytest.raises(PoolDestroyedException):
running_pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
def _create_pool(
pool_name: str,
owner_id: str,
state_store: PoolStateStore,
tag: str,
max_idle: int,
warmup_sandbox_preparer: Callable[[SandboxSync], None] | None = None,
connection_config: ConnectionConfigSync | None = None,
degraded_threshold: int = 3,
warmup_ready_timeout: timedelta = timedelta(seconds=30),
acquire_ready_timeout: timedelta = timedelta(seconds=30),
primary_lock_ttl: timedelta = PRIMARY_LOCK_TTL,
reconcile_interval: timedelta = RECONCILE_INTERVAL,
warmup_concurrency: int = 1,
) -> SandboxPoolSync:
return SandboxPoolSync(
pool_name=pool_name,
owner_id=owner_id,
max_idle=max_idle,
warmup_concurrency=warmup_concurrency,
state_store=state_store,
connection_config=connection_config or create_connection_config_sync(),
creation_spec=PoolCreationSpec(
image=get_sandbox_image(),
entrypoint=["tail", "-f", "/dev/null"],
metadata={"tag": tag, "suite": "sandbox-pool-python-e2e"},
env={
"E2E_TEST": "true",
"EXECD_API_GRACE_SHUTDOWN": "3s",
"EXECD_JUPYTER_IDLE_POLL_INTERVAL": "1s",
},
resource=get_e2e_sandbox_resource(),
),
reconcile_interval=reconcile_interval,
primary_lock_ttl=primary_lock_ttl,
drain_timeout=DRAIN_TIMEOUT,
warmup_sandbox_preparer=warmup_sandbox_preparer,
degraded_threshold=degraded_threshold,
warmup_ready_timeout=warmup_ready_timeout,
acquire_ready_timeout=acquire_ready_timeout,
)
def _broken_connection_config() -> ConnectionConfigSync:
return ConnectionConfigSync(
domain="127.0.0.1:9",
api_key="broken-e2e-test",
request_timeout=timedelta(seconds=1),
transport=httpx.HTTPTransport(
limits=httpx.Limits(max_connections=2, max_keepalive_connections=0)
),
)
def _eventually(
description: str,
condition: Callable[[], bool],
timeout: timedelta = AWAIT_TIMEOUT,
interval: timedelta = timedelta(seconds=1),
) -> None:
deadline = time.monotonic() + timeout.total_seconds()
last_error: BaseException | None = None
while time.monotonic() < deadline:
try:
if condition():
return
except BaseException as exc:
last_error = exc
time.sleep(interval.total_seconds())
if last_error is not None:
raise AssertionError(f"Timed out waiting for {description}") from last_error
raise AssertionError(f"Timed out waiting for {description}")
def _cleanup_pool(pool: SandboxPoolSync) -> None:
try:
pool.resize(0)
except Exception:
pass
try:
pool.release_all_idle()
except Exception:
pass
try:
pool.shutdown(False)
except Exception:
pass
def _cleanup_borrowed(sandboxes: list[SandboxSync]) -> None:
for sandbox in sandboxes:
try:
sandbox.kill()
except Exception:
pass
try:
sandbox.close()
except Exception:
pass
sandboxes.clear()
def _cleanup_tagged_sandboxes(manager: SandboxManagerSync, tag: str) -> None:
for _ in range(5):
try:
infos = manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": tag}, page_size=50)
)
if not infos.sandbox_infos:
return
for info in infos.sandbox_infos:
try:
manager.kill_sandbox(info.id)
except Exception:
pass
except Exception:
return
def _count_tagged_sandboxes(manager: SandboxManagerSync, tag: str) -> int:
infos = manager.list_sandbox_infos(SandboxFilter(metadata={"tag": tag}, page_size=50))
return len(infos.sandbox_infos)
def _tag(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
+614
View File
@@ -0,0 +1,614 @@
version = 1
revision = 3
requires-python = ">=3.10"
[manifest]
constraints = [{ name = "pyjwt", specifier = ">=2.13.0" }]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.0"
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/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
]
[[package]]
name = "async-timeout"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
]
[[package]]
name = "attrs"
version = "24.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984, upload-time = "2024-12-16T06:59:29.899Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397, upload-time = "2024-12-16T06:59:26.977Z" },
]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
[[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", marker = "python_full_version < '3.13'" },
]
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 = "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.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[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 = "nodeenv"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
]
[[package]]
name = "opensandbox"
source = { editable = "../../sdks/sandbox/python" }
dependencies = [
{ name = "attrs" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "python-dateutil" },
]
[package.metadata]
requires-dist = [
{ name = "attrs", specifier = ">=21.3.0" },
{ name = "httpx", specifier = ">=0.27.0,<1.0" },
{ name = "pydantic", specifier = ">=2.4.2,<3.0" },
{ name = "python-dateutil", specifier = ">=2.8.2,<3.0" },
{ name = "redis", marker = "extra == 'pool-redis'", specifier = ">=5.0,<6.0" },
]
provides-extras = ["pool-redis"]
[package.metadata.requires-dev]
dev = [
{ name = "openapi-python-client", specifier = ">=0.28.0" },
{ name = "pyright", specifier = ">=1.1.0" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" },
{ name = "pytest-cov", specifier = ">=4.0.0" },
{ name = "redis", specifier = ">=5.0,<6.0" },
{ name = "ruff", specifier = ">=0.14.8" },
]
[[package]]
name = "opensandbox-code-interpreter"
source = { editable = "../../sdks/code-interpreter/python" }
dependencies = [
{ name = "opensandbox" },
{ name = "pydantic" },
]
[package.metadata]
requires-dist = [
{ name = "opensandbox", editable = "../../sdks/sandbox/python" },
{ name = "pydantic", specifier = ">=2.4.2,<3.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.407" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" },
{ name = "pytest-cov", specifier = ">=4.0.0" },
{ name = "ruff", specifier = ">=0.14.8" },
]
[[package]]
name = "opensandbox-e2e-tests"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "opensandbox" },
{ name = "opensandbox-code-interpreter" },
{ name = "pydantic" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-order" },
{ name = "pytest-timeout" },
{ name = "redis" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "opensandbox", editable = "../../sdks/sandbox/python" },
{ name = "opensandbox-code-interpreter", editable = "../../sdks/code-interpreter/python" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" },
{ name = "pytest-order", specifier = ">=1.2.0" },
{ name = "pytest-timeout", specifier = ">=2.1.0" },
{ name = "redis", specifier = ">=5.0,<6.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.407" },
{ name = "ruff", specifier = ">=0.14.8" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[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 = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]
[[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 = "pyjwt"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[[package]]
name = "pyright"
version = "1.1.407"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-order"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/66/02ae17461b14a52ce5a29ae2900156b9110d1de34721ccc16ccd79419876/pytest_order-1.3.0.tar.gz", hash = "sha256:51608fec3d3ee9c0adaea94daa124a5c4c1d2bb99b00269f098f414307f23dde", size = 47544, upload-time = "2024-08-22T12:29:54.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/73/59b038d1aafca89f8e9936eaa8ffa6bb6138d00459d13a32ce070be4f280/pytest_order-1.3.0-py3-none-any.whl", hash = "sha256:2cd562a21380345dd8d5774aa5fd38b7849b6ee7397ca5f6999bbe6e89f07f6e", size = 14609, upload-time = "2024-08-22T12:29:53.156Z" },
]
[[package]]
name = "pytest-timeout"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "redis"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/cf/128b1b6d7086200c9f387bd4be9b2572a30b90745ef078bd8b235042dc9f/redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c", size = 4626200, upload-time = "2025-07-25T08:06:27.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/26/5c5fa0e83c3621db835cfc1f1d789b37e7fa99ed54423b5f519beb931aa7/redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97", size = 272833, upload-time = "2025-07-25T08:06:26.317Z" },
]
[[package]]
name = "ruff"
version = "0.14.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz", hash = "sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size = 5809165, upload-time = "2025-12-11T21:39:47.381Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl", hash = "sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size = 13576541, upload-time = "2025-12-11T21:39:14.806Z" },
{ url = "https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size = 13779363, upload-time = "2025-12-11T21:39:20.29Z" },
{ url = "https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size = 12925292, upload-time = "2025-12-11T21:39:38.757Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size = 13362894, upload-time = "2025-12-11T21:39:02.524Z" },
{ url = "https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size = 13311482, upload-time = "2025-12-11T21:39:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size = 14016100, upload-time = "2025-12-11T21:39:41.948Z" },
{ url = "https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size = 15477729, upload-time = "2025-12-11T21:39:23.279Z" },
{ url = "https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size = 15122386, upload-time = "2025-12-11T21:39:33.125Z" },
{ url = "https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size = 14497124, upload-time = "2025-12-11T21:38:59.33Z" },
{ url = "https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size = 14195343, upload-time = "2025-12-11T21:39:44.866Z" },
{ url = "https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size = 14346425, upload-time = "2025-12-11T21:39:05.927Z" },
{ url = "https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size = 13258768, upload-time = "2025-12-11T21:39:08.691Z" },
{ url = "https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size = 13326939, upload-time = "2025-12-11T21:39:53.842Z" },
{ url = "https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size = 13578888, upload-time = "2025-12-11T21:39:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size = 14314473, upload-time = "2025-12-11T21:39:50.78Z" },
{ url = "https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl", hash = "sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size = 13202651, upload-time = "2025-12-11T21:39:26.628Z" },
{ url = "https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl", hash = "sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size = 14702079, upload-time = "2025-12-11T21:39:11.954Z" },
{ url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "tomli"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]