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
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:
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
#
|
||||
@@ -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]}"
|
||||
Generated
+614
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user