Compare commits
6 Commits
10cad4c3f7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
0b2d2982ab
|
|||
|
909b238cab
|
|||
|
4ee0cda29a
|
|||
|
eac1643d48
|
|||
|
9dc5b9ba50
|
|||
|
cb8daed405
|
+28
-22
@@ -7,47 +7,53 @@ on:
|
|||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
unit-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
enable-cache: true
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Run unit tests
|
||||||
run: |
|
run: uv run --extra dev pytest tests/unit/ -v --tb=short
|
||||||
python -m venv .venv
|
|
||||||
.venv/bin/pip install -e ".[dev]" -q
|
|
||||||
|
|
||||||
- name: Run tests
|
integration-tests:
|
||||||
run: .venv/bin/pytest tests/ -v
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: Pull container image
|
||||||
|
run: docker pull python:3.11-slim
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
run: uv run --extra dev pytest tests/integration/ -v --tb=short
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test
|
needs: [unit-tests, integration-tests]
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
enable-cache: true
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: |
|
run: uv build
|
||||||
pip install build -q
|
|
||||||
python -m build
|
|
||||||
|
|
||||||
- name: Publish to Gitea package registry
|
- name: Publish to Gitea package registry
|
||||||
env:
|
env:
|
||||||
TWINE_USERNAME: ${{ github.repository_owner }}
|
UV_PUBLISH_URL: ${{ gitea.server_url }}/api/packages/${{ github.repository_owner }}/pypi
|
||||||
TWINE_PASSWORD: ${{ secrets.GITEA_TOKEN }}
|
UV_PUBLISH_USERNAME: ${{ github.repository_owner }}
|
||||||
run: |
|
UV_PUBLISH_PASSWORD: ${{ secrets.GITEA_TOKEN }}
|
||||||
pip install twine -q
|
run: uv publish
|
||||||
twine upload \
|
|
||||||
--repository-url ${{ gitea.server_url }}/api/packages/${{ github.repository_owner }}/pypi \
|
|
||||||
dist/*
|
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import io
|
|||||||
import select as _select
|
import select as _select
|
||||||
import socket as _socket
|
import socket as _socket
|
||||||
import tarfile
|
import tarfile
|
||||||
|
from contextlib import ExitStack
|
||||||
from select import select as _original_select
|
from select import select as _original_select
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
_original_poll = getattr(_select, "poll", None)
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
import docker.errors
|
import docker.errors
|
||||||
from docker.utils.socket import consume_socket_output, demux_adaptor, frames_iter
|
from docker.utils.socket import consume_socket_output, demux_adaptor, frames_iter
|
||||||
@@ -46,6 +49,7 @@ class DockerSandbox:
|
|||||||
security_opt: list[str] | None = None,
|
security_opt: list[str] | None = None,
|
||||||
cpu_limit: float = 8,
|
cpu_limit: float = 8,
|
||||||
memory_limit: str = "16g",
|
memory_limit: str = "16g",
|
||||||
|
command: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.container_name = container_name
|
self.container_name = container_name
|
||||||
self._image = image
|
self._image = image
|
||||||
@@ -60,6 +64,7 @@ class DockerSandbox:
|
|||||||
self._security_opt = security_opt
|
self._security_opt = security_opt
|
||||||
self._nano_cpus = int(cpu_limit * 1e9)
|
self._nano_cpus = int(cpu_limit * 1e9)
|
||||||
self._memory_limit = memory_limit
|
self._memory_limit = memory_limit
|
||||||
|
self._command = command
|
||||||
self._client: docker.DockerClient = docker.from_env()
|
self._client: docker.DockerClient = docker.from_env()
|
||||||
self._container: docker.models.containers.Container | None = None
|
self._container: docker.models.containers.Container | None = None
|
||||||
|
|
||||||
@@ -132,7 +137,7 @@ class DockerSandbox:
|
|||||||
run_kwargs["mem_limit"] = self._memory_limit
|
run_kwargs["mem_limit"] = self._memory_limit
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._container = self._client.containers.run(self._image, **run_kwargs)
|
self._container = self._client.containers.run(self._image, self._command, **run_kwargs)
|
||||||
except docker.errors.ImageNotFound:
|
except docker.errors.ImageNotFound:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Image {self._image!r} not found locally. "
|
f"Image {self._image!r} not found locally. "
|
||||||
@@ -258,9 +263,12 @@ class DockerSandbox:
|
|||||||
|
|
||||||
# TODO(fragile): timeout enforcement relies on private docker-py internals
|
# TODO(fragile): timeout enforcement relies on private docker-py internals
|
||||||
# (frames_iter, demux_adaptor, consume_socket_output from docker.utils.socket)
|
# (frames_iter, demux_adaptor, consume_socket_output from docker.utils.socket)
|
||||||
# and monkey-patches select.select for the duration of the read — not thread-safe
|
# and monkey-patches select.select / select.poll for the duration of the read
|
||||||
# if multiple exec() calls run concurrently. Replace when docker-py adds native
|
# — not thread-safe if multiple exec() calls run concurrently. Replace when
|
||||||
# per-call timeout support. See https://github.com/docker/docker-py/issues/2651
|
# docker-py adds native per-call timeout support.
|
||||||
|
# See https://github.com/docker/docker-py/issues/2651
|
||||||
|
#
|
||||||
|
# On Linux docker-py uses select.poll (not select.select), so both are patched.
|
||||||
try:
|
try:
|
||||||
exec_id = self._client.api.exec_create(
|
exec_id = self._client.api.exec_create(
|
||||||
self._container.id,
|
self._container.id,
|
||||||
@@ -271,17 +279,39 @@ class DockerSandbox:
|
|||||||
)
|
)
|
||||||
sock = self._client.api.exec_start(exec_id["Id"], socket=True)
|
sock = self._client.api.exec_start(exec_id["Id"], socket=True)
|
||||||
sock._sock.settimeout(timeout)
|
sock._sock.settimeout(timeout)
|
||||||
with patch.object(
|
|
||||||
_select,
|
timeout_ms = timeout * 1000
|
||||||
"select",
|
|
||||||
|
class _PollWithTimeout:
|
||||||
|
def __init__(self):
|
||||||
|
self._inner = _original_poll()
|
||||||
|
|
||||||
|
def register(self, fd, eventmask):
|
||||||
|
return self._inner.register(fd, eventmask)
|
||||||
|
|
||||||
|
def poll(self, *args):
|
||||||
|
result = self._inner.poll(timeout_ms)
|
||||||
|
if not result:
|
||||||
|
raise _socket.timeout(f"timed out after {timeout}s")
|
||||||
|
return result
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
stack.enter_context(patch.object(
|
||||||
|
_select, "select",
|
||||||
new=lambda rlist, wlist, xlist: _original_select(
|
new=lambda rlist, wlist, xlist: _original_select(
|
||||||
rlist, wlist, xlist, timeout
|
rlist, wlist, xlist, timeout
|
||||||
),
|
),
|
||||||
):
|
))
|
||||||
|
if _original_poll is not None:
|
||||||
|
stack.enter_context(
|
||||||
|
patch.object(_select, "poll", new=_PollWithTimeout)
|
||||||
|
)
|
||||||
gen = (demux_adaptor(*frame) for frame in frames_iter(sock, tty=False))
|
gen = (demux_adaptor(*frame) for frame in frames_iter(sock, tty=False))
|
||||||
stdout, stderr = consume_socket_output(gen, demux=True)
|
stdout, stderr = consume_socket_output(gen, demux=True)
|
||||||
|
|
||||||
exit_code = self._client.api.exec_inspect(exec_id["Id"])["ExitCode"] or 0
|
exit_code = self._client.api.exec_inspect(exec_id["Id"])["ExitCode"]
|
||||||
|
if exit_code is None:
|
||||||
|
exit_code = 0
|
||||||
output = (stdout or b"") + (stderr or b"")
|
output = (stdout or b"") + (stderr or b"")
|
||||||
return exit_code, output.decode("utf-8", errors="replace")
|
return exit_code, output.decode("utf-8", errors="replace")
|
||||||
except _socket.timeout:
|
except _socket.timeout:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from typing import TYPE_CHECKING
|
|||||||
from langchain_core.tools import BaseTool, tool
|
from langchain_core.tools import BaseTool, tool
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from docker_agent_sandbox.tools._utils import _MAX_OUTPUT_CHARS
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||||
|
|
||||||
@@ -15,38 +17,69 @@ def make_read_file_tool(sandbox: "DockerSandbox") -> BaseTool:
|
|||||||
"""Return a read_file tool bound to *sandbox*."""
|
"""Return a read_file tool bound to *sandbox*."""
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def read_file(path: str, offset: int = 0, length: int = 5000) -> str:
|
def read_file(path: str, start_line: int = 1, end_line: int | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Read a file at *path*.
|
Read a file at *path*, returning its contents as text.
|
||||||
|
|
||||||
*path* can be absolute (``/tmp/re-agent/result.csv``) or relative to the
|
*path* can be absolute (/tmp/re-agent/result.csv) or relative to the
|
||||||
working directory.
|
working directory.
|
||||||
|
|
||||||
*offset* is the number of bytes to skip from the start of the file.
|
*start_line* is the 1-based line number to start reading from (default: 1).
|
||||||
*length* is the maximum number of bytes to return. If the file is
|
|
||||||
longer than ``offset + length``, the output is trimmed and a summary
|
|
||||||
line is appended showing how many bytes were omitted.
|
|
||||||
|
|
||||||
Returns the (possibly trimmed) file contents as text, or an error message.
|
*end_line* is the last line to include, inclusive (default: read as many
|
||||||
|
lines as the MAX_CHARS cap allows). Use start_line/end_line to page
|
||||||
|
through large files in chunks.
|
||||||
|
|
||||||
|
At most 5,000 characters are returned per call. If the requested range
|
||||||
|
exceeds this, output is truncated and a summary line is appended showing
|
||||||
|
how many lines were omitted.
|
||||||
|
|
||||||
|
Returns the (possibly truncated) file contents, or an error message.
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Reading file inside sandbox: {} offset={} length={}", path, offset, length
|
"Reading file inside sandbox: {} start_line={} end_line={}",
|
||||||
|
path,
|
||||||
|
start_line,
|
||||||
|
end_line,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
data = sandbox.read_file(path)
|
data = sandbox.read_file(path)
|
||||||
except (FileNotFoundError, IsADirectoryError, RuntimeError) as exc:
|
except (FileNotFoundError, IsADirectoryError, RuntimeError) as exc:
|
||||||
return f"[ERROR reading {path!r}] {exc}"
|
return f"[ERROR reading {path!r}] {exc}"
|
||||||
|
|
||||||
total = len(data)
|
lines = data.decode("utf-8", errors="replace").splitlines(keepends=True)
|
||||||
chunk = data[offset : offset + length]
|
total_lines = len(lines)
|
||||||
text = chunk.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
suffix = ""
|
# Clamp to valid range (1-based, inclusive)
|
||||||
if offset + length < total:
|
start_idx = max(0, start_line - 1)
|
||||||
remaining = total - (offset + length)
|
end_idx = total_lines if end_line is None else min(end_line, total_lines)
|
||||||
suffix = f"\n[... {remaining} more bytes not shown (total {total} bytes). Use offset/length to read further.]"
|
selected = lines[start_idx:end_idx]
|
||||||
elif offset > 0 or total > length:
|
|
||||||
suffix = f"\n[File total: {total} bytes, showing {len(chunk)} bytes from offset {offset}.]"
|
# Enforce character cap
|
||||||
return text + suffix
|
text = ""
|
||||||
|
last_included_line = start_idx # track how far we got
|
||||||
|
for i, line in enumerate(selected):
|
||||||
|
if len(text) + len(line) > _MAX_OUTPUT_CHARS:
|
||||||
|
break
|
||||||
|
text += line
|
||||||
|
last_included_line = start_idx + i + 1 # 1-based
|
||||||
|
|
||||||
|
# Build informative suffix
|
||||||
|
suffix_parts = []
|
||||||
|
if last_included_line < end_idx:
|
||||||
|
omitted = end_idx - last_included_line
|
||||||
|
suffix_parts.append(
|
||||||
|
f"[... {omitted} more lines not shown (char cap reached). "
|
||||||
|
f"Call again with start_line={last_included_line + 1}.]"
|
||||||
|
)
|
||||||
|
elif end_idx < total_lines:
|
||||||
|
suffix_parts.append(
|
||||||
|
f"[Showing lines {start_line}–{end_idx} of {total_lines} total.]"
|
||||||
|
)
|
||||||
|
|
||||||
|
if suffix_parts:
|
||||||
|
text += "\n" + " ".join(suffix_parts)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
return read_file
|
return read_file
|
||||||
|
|||||||
+13
-1
@@ -9,8 +9,20 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest"]
|
dev = ["pytest>=8.0"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
markers = [
|
||||||
|
"unit: pure-Python tests, no Docker required",
|
||||||
|
"integration: tests that spin up a real Docker container",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"coverage>=7.13.5",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Shared fixtures for integration tests.
|
||||||
|
|
||||||
|
All integration tests share a single container (session scope) to avoid the
|
||||||
|
overhead of starting/stopping Docker for every test function. Each test that
|
||||||
|
needs filesystem isolation gets its own temporary working directory via the
|
||||||
|
``workdir`` fixture, which is torn down after the test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from docker_agent_sandbox import DockerSandbox
|
||||||
|
|
||||||
|
# python:3.11-slim ships bash, grep (GNU), find, and standard POSIX utilities.
|
||||||
|
_IMAGE = "python:3.11-slim"
|
||||||
|
_CONTAINER_NAME = "docker-agent-sandbox-tests"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def sandbox():
|
||||||
|
"""Start a long-running container shared by all integration tests."""
|
||||||
|
sb = DockerSandbox(
|
||||||
|
container_name=_CONTAINER_NAME,
|
||||||
|
image=_IMAGE,
|
||||||
|
command="sleep infinity",
|
||||||
|
cpu_limit=2,
|
||||||
|
memory_limit="512m",
|
||||||
|
)
|
||||||
|
sb.start()
|
||||||
|
yield sb
|
||||||
|
sb.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workdir(sandbox: DockerSandbox):
|
||||||
|
"""Create a fresh temp directory in the container for the calling test."""
|
||||||
|
d = f"/tmp/test-{uuid.uuid4().hex}"
|
||||||
|
sandbox.exec(f"mkdir -p {d}")
|
||||||
|
yield d
|
||||||
|
sandbox.exec(f"rm -rf {d}")
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Integration tests for DockerSandbox core (exec, file I/O, path resolution)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from docker_agent_sandbox import DockerSandbox
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# exec()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_simple_command(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("echo hello")
|
||||||
|
assert code == 0
|
||||||
|
assert "hello" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_nonzero_exit_code(sandbox: DockerSandbox):
|
||||||
|
code, _ = sandbox.exec("exit 42")
|
||||||
|
assert code == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_stderr_captured(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("echo msg_on_stderr >&2")
|
||||||
|
assert code == 0
|
||||||
|
assert "msg_on_stderr" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_combined_stdout_and_stderr(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("echo stdout_line; echo stderr_line >&2")
|
||||||
|
assert code == 0
|
||||||
|
assert "stdout_line" in out
|
||||||
|
assert "stderr_line" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_returns_error_when_container_not_running():
|
||||||
|
# Construct a sandbox without starting it to exercise the guard.
|
||||||
|
sb = DockerSandbox.__new__(DockerSandbox)
|
||||||
|
sb._container = None
|
||||||
|
sb._working_dir = None
|
||||||
|
code, out = sb.exec("echo hi")
|
||||||
|
assert code == 1
|
||||||
|
assert "not running" in out.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_instant_command(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("true")
|
||||||
|
assert code == 0
|
||||||
|
assert out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_instant_nonzero(sandbox: DockerSandbox):
|
||||||
|
code, _ = sandbox.exec("false")
|
||||||
|
assert code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_delayed_command_within_timeout(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("sleep 1 && echo done", timeout=10)
|
||||||
|
assert code == 0
|
||||||
|
assert "done" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_timeout(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("sleep 60", timeout=2)
|
||||||
|
assert code == 124
|
||||||
|
assert "timed out" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_timeout_longer_than_sleep(sandbox: DockerSandbox):
|
||||||
|
# Command finishes before timeout — must not raise or return 124.
|
||||||
|
code, out = sandbox.exec("sleep 1 && echo ok", timeout=10)
|
||||||
|
assert code == 0
|
||||||
|
assert "ok" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_and_chain_both_succeed(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("echo first && echo second")
|
||||||
|
assert code == 0
|
||||||
|
assert "first" in out
|
||||||
|
assert "second" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_and_chain_short_circuits_on_failure(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("false && echo should_not_print")
|
||||||
|
assert code != 0
|
||||||
|
assert "should_not_print" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_pipe(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("echo hello world | tr ' ' '_'")
|
||||||
|
assert code == 0
|
||||||
|
assert "hello_world" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_pipe_exit_code_is_last_command(sandbox: DockerSandbox):
|
||||||
|
# grep finds no match → exit 1, even though echo succeeded
|
||||||
|
code, _ = sandbox.exec("echo hello | grep nomatch")
|
||||||
|
assert code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_stdout_redirect_to_file(sandbox: DockerSandbox, workdir: str):
|
||||||
|
code, out = sandbox.exec(f"echo redirected > {workdir}/out.txt && cat {workdir}/out.txt")
|
||||||
|
assert code == 0
|
||||||
|
assert "redirected" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_stderr_redirect_to_stdout(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("echo err_msg >&2 2>&1")
|
||||||
|
assert code == 0
|
||||||
|
assert "err_msg" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_subshell(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("result=$(echo inner) && echo $result")
|
||||||
|
assert code == 0
|
||||||
|
assert "inner" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_multiline_via_semicolons(sandbox: DockerSandbox):
|
||||||
|
code, out = sandbox.exec("echo a; echo b; echo c")
|
||||||
|
assert code == 0
|
||||||
|
assert "a" in out
|
||||||
|
assert "b" in out
|
||||||
|
assert "c" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_exec_working_dir_respected():
|
||||||
|
"""When working_dir is set, exec uses it as cwd."""
|
||||||
|
sb = DockerSandbox(
|
||||||
|
container_name="test-workdir-check",
|
||||||
|
image="python:3.11-slim",
|
||||||
|
command="sleep infinity",
|
||||||
|
working_dir="/tmp",
|
||||||
|
cpu_limit=1,
|
||||||
|
memory_limit="256m",
|
||||||
|
)
|
||||||
|
sb.start()
|
||||||
|
try:
|
||||||
|
code, out = sb.exec("pwd")
|
||||||
|
assert code == 0
|
||||||
|
assert "/tmp" in out
|
||||||
|
finally:
|
||||||
|
sb.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# write_file() / read_file()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_read_roundtrip(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/hello.txt"
|
||||||
|
sandbox.write_file(path, "hello world\n")
|
||||||
|
assert sandbox.read_file(path) == b"hello world\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_read_unicode(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/unicode.txt"
|
||||||
|
content = "héllo wörld 你好\n"
|
||||||
|
sandbox.write_file(path, content)
|
||||||
|
assert sandbox.read_file(path).decode("utf-8") == content
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_creates_parent_directories(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/deep/nested/dir/file.txt"
|
||||||
|
sandbox.write_file(path, "content")
|
||||||
|
assert sandbox.read_file(path) == b"content"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_overwrites_existing_file(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/overwrite.txt"
|
||||||
|
sandbox.write_file(path, "first")
|
||||||
|
sandbox.write_file(path, "second")
|
||||||
|
assert sandbox.read_file(path) == b"second"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_not_found_raises(sandbox: DockerSandbox, workdir: str):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
sandbox.read_file(f"{workdir}/no_such_file.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_directory_raises(sandbox: DockerSandbox, workdir: str):
|
||||||
|
sandbox.exec(f"mkdir -p {workdir}/subdir")
|
||||||
|
with pytest.raises(IsADirectoryError):
|
||||||
|
sandbox.read_file(f"{workdir}/subdir")
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_when_container_not_running_raises():
|
||||||
|
sb = DockerSandbox.__new__(DockerSandbox)
|
||||||
|
sb._container = None
|
||||||
|
sb._working_dir = None
|
||||||
|
with pytest.raises(RuntimeError, match="not running"):
|
||||||
|
sb.read_file("/tmp/anything.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_file_when_container_not_running_raises():
|
||||||
|
sb = DockerSandbox.__new__(DockerSandbox)
|
||||||
|
sb._container = None
|
||||||
|
sb._working_dir = None
|
||||||
|
with pytest.raises(RuntimeError, match="not running"):
|
||||||
|
sb.write_file("/tmp/anything.txt", "data")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_host_port()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_host_port_raises_when_not_running():
|
||||||
|
sb = DockerSandbox.__new__(DockerSandbox)
|
||||||
|
sb._container = None
|
||||||
|
with pytest.raises(RuntimeError, match="not running"):
|
||||||
|
sb.get_host_port("8080/tcp")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_host_port_raises_for_unmapped_port(sandbox: DockerSandbox):
|
||||||
|
with pytest.raises(RuntimeError, match="not mapped"):
|
||||||
|
sandbox.get_host_port("9999/tcp")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _resolve_path()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path, working_dir, expected",
|
||||||
|
[
|
||||||
|
("/absolute/path", "/work", "/absolute/path"),
|
||||||
|
("relative/file", "/work", "/work/relative/file"),
|
||||||
|
("relative/file", None, "relative/file"),
|
||||||
|
("/absolute/path", None, "/absolute/path"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_path(path, working_dir, expected):
|
||||||
|
sb = DockerSandbox.__new__(DockerSandbox)
|
||||||
|
sb._working_dir = working_dir
|
||||||
|
assert str(sb._resolve_path(path)) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# context manager
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_manager_stops_container():
|
||||||
|
sb = DockerSandbox(
|
||||||
|
container_name="test-ctx-manager",
|
||||||
|
image="python:3.11-slim",
|
||||||
|
command="sleep infinity",
|
||||||
|
cpu_limit=1,
|
||||||
|
memory_limit="256m",
|
||||||
|
)
|
||||||
|
sb.start()
|
||||||
|
with sb:
|
||||||
|
code, _ = sb.exec("echo alive")
|
||||||
|
assert code == 0
|
||||||
|
assert sb._container is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_image_if_missing()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_image_if_missing_skips_when_present(sandbox: DockerSandbox):
|
||||||
|
# python:3.11-slim was already pulled by the session fixture; this must not
|
||||||
|
# raise even though dockerfile_dir is None.
|
||||||
|
sandbox.build_image_if_missing()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_image_if_missing_raises_without_dockerfile_dir():
|
||||||
|
sb = DockerSandbox(
|
||||||
|
container_name="irrelevant",
|
||||||
|
image="image-that-does-not-exist-xyzzy123",
|
||||||
|
)
|
||||||
|
with pytest.raises((RuntimeError, ValueError)):
|
||||||
|
sb.build_image_if_missing()
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""Integration tests for all LangChain tools.
|
||||||
|
|
||||||
|
Each tool is invoked through its public LangChain interface (``tool.invoke``)
|
||||||
|
so that argument validation, logging, and output formatting are all exercised
|
||||||
|
exactly as they would be when called by an LLM agent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langchain_core.tools import BaseTool
|
||||||
|
|
||||||
|
from docker_agent_sandbox import (
|
||||||
|
DockerSandbox,
|
||||||
|
make_bash_tool,
|
||||||
|
make_copy_file_tool,
|
||||||
|
make_delete_file_tool,
|
||||||
|
make_edit_file_tool,
|
||||||
|
make_file_ops_tools,
|
||||||
|
make_grep_tool,
|
||||||
|
make_list_dir_tool,
|
||||||
|
make_make_dir_tool,
|
||||||
|
make_move_file_tool,
|
||||||
|
make_read_file_tool,
|
||||||
|
make_search_files_tool,
|
||||||
|
make_write_file_tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_success(sandbox: DockerSandbox):
|
||||||
|
tool = make_bash_tool(sandbox)
|
||||||
|
result = tool.invoke({"command": "echo hello"})
|
||||||
|
assert result.startswith("EXIT:0")
|
||||||
|
assert "hello" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_nonzero_exit(sandbox: DockerSandbox):
|
||||||
|
tool = make_bash_tool(sandbox)
|
||||||
|
result = tool.invoke({"command": "exit 3"})
|
||||||
|
assert result.startswith("EXIT:3")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_stderr_included(sandbox: DockerSandbox):
|
||||||
|
tool = make_bash_tool(sandbox)
|
||||||
|
result = tool.invoke({"command": "echo err >&2"})
|
||||||
|
assert "EXIT:0" in result
|
||||||
|
assert "err" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_large_output_truncated(sandbox: DockerSandbox):
|
||||||
|
tool = make_bash_tool(sandbox)
|
||||||
|
# Generate 300 lines — more than the 200-line cap.
|
||||||
|
result = tool.invoke({"command": "python3 -c \"print('\\n'.join(['x'] * 300))\""})
|
||||||
|
assert "[output truncated]" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_bash_timeout(sandbox: DockerSandbox):
|
||||||
|
tool = make_bash_tool(sandbox)
|
||||||
|
result = tool.invoke({"command": "sleep 60", "timeout": 2})
|
||||||
|
assert "EXIT:124" in result
|
||||||
|
assert "timed out" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# write_file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_file_ok(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_write_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": f"{workdir}/new.txt", "content": "data"})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
assert "bytes" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_file_reports_byte_count(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_write_file_tool(sandbox)
|
||||||
|
content = "hello"
|
||||||
|
result = tool.invoke({"path": f"{workdir}/bytes.txt", "content": content})
|
||||||
|
assert str(len(content.encode())) in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_file_creates_parent_dirs(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_write_file_tool(sandbox)
|
||||||
|
path = f"{workdir}/a/b/c/file.txt"
|
||||||
|
result = tool.invoke({"path": path, "content": "nested"})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
# Verify the file exists
|
||||||
|
code, _ = sandbox.exec(f"test -f {path}")
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# read_file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_full(sandbox: DockerSandbox, workdir: str):
|
||||||
|
sandbox.write_file(f"{workdir}/r.txt", "line1\nline2\nline3\n")
|
||||||
|
tool = make_read_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": f"{workdir}/r.txt"})
|
||||||
|
assert "line1" in result
|
||||||
|
assert "line2" in result
|
||||||
|
assert "line3" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_pagination(sandbox: DockerSandbox, workdir: str):
|
||||||
|
content = "\n".join(f"line{i}" for i in range(1, 11)) + "\n"
|
||||||
|
sandbox.write_file(f"{workdir}/paged.txt", content)
|
||||||
|
tool = make_read_file_tool(sandbox)
|
||||||
|
result = tool.invoke(
|
||||||
|
{"path": f"{workdir}/paged.txt", "start_line": 3, "end_line": 5}
|
||||||
|
)
|
||||||
|
assert "line3" in result
|
||||||
|
assert "line5" in result
|
||||||
|
assert "line1" not in result
|
||||||
|
assert "line6" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_shows_total_line_count(sandbox: DockerSandbox, workdir: str):
|
||||||
|
content = "\n".join(f"line{i}" for i in range(1, 21)) + "\n"
|
||||||
|
sandbox.write_file(f"{workdir}/info.txt", content)
|
||||||
|
tool = make_read_file_tool(sandbox)
|
||||||
|
result = tool.invoke(
|
||||||
|
{"path": f"{workdir}/info.txt", "start_line": 1, "end_line": 5}
|
||||||
|
)
|
||||||
|
# There are 20 lines but we only requested 1-5, suffix should mention totals.
|
||||||
|
assert "20" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_missing_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_read_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": f"{workdir}/does_not_exist.txt"})
|
||||||
|
assert result.startswith("[ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_file_directory_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
sandbox.exec(f"mkdir -p {workdir}/adir")
|
||||||
|
tool = make_read_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": f"{workdir}/adir"})
|
||||||
|
assert result.startswith("[ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# edit_file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_basic_replace(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/edit.txt"
|
||||||
|
sandbox.write_file(path, "foo bar baz\n")
|
||||||
|
tool = make_edit_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": path, "old_str": "bar", "new_str": "qux"})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
assert sandbox.read_file(path) == b"foo qux baz\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_old_str_not_found(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/nf.txt"
|
||||||
|
sandbox.write_file(path, "hello\n")
|
||||||
|
tool = make_edit_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": path, "old_str": "missing", "new_str": "x"})
|
||||||
|
assert result.startswith("[ERROR]")
|
||||||
|
assert "not found" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_ambiguous_old_str(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/amb.txt"
|
||||||
|
sandbox.write_file(path, "foo\nfoo\n")
|
||||||
|
tool = make_edit_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": path, "old_str": "foo", "new_str": "bar"})
|
||||||
|
assert result.startswith("[ERROR]")
|
||||||
|
assert "2 times" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_delete_block(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/del.txt"
|
||||||
|
sandbox.write_file(path, "keep\nremove me\nalso keep\n")
|
||||||
|
tool = make_edit_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": path, "old_str": "remove me\n", "new_str": ""})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
content = sandbox.read_file(path).decode()
|
||||||
|
assert "remove me" not in content
|
||||||
|
assert "keep" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_missing_file_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_edit_file_tool(sandbox)
|
||||||
|
result = tool.invoke(
|
||||||
|
{"path": f"{workdir}/ghost.txt", "old_str": "x", "new_str": "y"}
|
||||||
|
)
|
||||||
|
assert result.startswith("[ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_multiline_replace(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/multi.txt"
|
||||||
|
sandbox.write_file(path, "line1\nline2\nline3\n")
|
||||||
|
tool = make_edit_file_tool(sandbox)
|
||||||
|
result = tool.invoke(
|
||||||
|
{"path": path, "old_str": "line1\nline2\n", "new_str": "replaced\n"}
|
||||||
|
)
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
assert sandbox.read_file(path) == b"replaced\nline3\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_over_size_limit_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/big.txt"
|
||||||
|
# Write just over 1 MB
|
||||||
|
sandbox.write_file(path, "x" * (1_000_001))
|
||||||
|
tool = make_edit_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": path, "old_str": "x", "new_str": "y"})
|
||||||
|
assert result.startswith("[ERROR]")
|
||||||
|
assert "bytes" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# list_dir
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_dir_shows_files(sandbox: DockerSandbox, workdir: str):
|
||||||
|
sandbox.write_file(f"{workdir}/a.txt", "a")
|
||||||
|
sandbox.write_file(f"{workdir}/b.txt", "b")
|
||||||
|
tool = make_list_dir_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": workdir})
|
||||||
|
assert "a.txt" in result
|
||||||
|
assert "b.txt" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_dir_missing_path_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_list_dir_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": f"{workdir}/nonexistent"})
|
||||||
|
assert result.startswith("[ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_dir_default_path(sandbox: DockerSandbox):
|
||||||
|
# Default path is "." — just check it doesn't crash and returns something.
|
||||||
|
tool = make_list_dir_tool(sandbox)
|
||||||
|
result = tool.invoke({})
|
||||||
|
assert "[ERROR" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# delete_file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_file_removes_file(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/to_delete.txt"
|
||||||
|
sandbox.write_file(path, "bye")
|
||||||
|
tool = make_delete_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": path})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
code, _ = sandbox.exec(f"test -f {path}")
|
||||||
|
assert code != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_file_missing_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_delete_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": f"{workdir}/ghost.txt"})
|
||||||
|
assert result.startswith("[ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_file_nonempty_dir_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
d = f"{workdir}/nonempty"
|
||||||
|
sandbox.exec(f"mkdir -p {d}")
|
||||||
|
sandbox.write_file(f"{d}/file.txt", "x")
|
||||||
|
tool = make_delete_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": d})
|
||||||
|
assert result.startswith("[ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_empty_directory(sandbox: DockerSandbox, workdir: str):
|
||||||
|
d = f"{workdir}/emptydir"
|
||||||
|
sandbox.exec(f"mkdir -p {d}")
|
||||||
|
tool = make_delete_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": d})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# move_file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_file_renames_file(sandbox: DockerSandbox, workdir: str):
|
||||||
|
src = f"{workdir}/src.txt"
|
||||||
|
dst = f"{workdir}/dst.txt"
|
||||||
|
sandbox.write_file(src, "move me")
|
||||||
|
tool = make_move_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"src": src, "dst": dst})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
assert sandbox.read_file(dst) == b"move me"
|
||||||
|
code, _ = sandbox.exec(f"test -f {src}")
|
||||||
|
assert code != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_file_creates_parent_dirs(sandbox: DockerSandbox, workdir: str):
|
||||||
|
src = f"{workdir}/mv_src.txt"
|
||||||
|
dst = f"{workdir}/new/nested/dst.txt"
|
||||||
|
sandbox.write_file(src, "data")
|
||||||
|
tool = make_move_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"src": src, "dst": dst})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
assert sandbox.read_file(dst) == b"data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_file_missing_src_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_move_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"src": f"{workdir}/nope.txt", "dst": f"{workdir}/out.txt"})
|
||||||
|
assert result.startswith("[ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# copy_file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_file_duplicates_file(sandbox: DockerSandbox, workdir: str):
|
||||||
|
src = f"{workdir}/orig.txt"
|
||||||
|
dst = f"{workdir}/copy.txt"
|
||||||
|
sandbox.write_file(src, "original")
|
||||||
|
tool = make_copy_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"src": src, "dst": dst})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
assert sandbox.read_file(dst) == b"original"
|
||||||
|
assert sandbox.read_file(src) == b"original" # source still present
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_file_creates_parent_dirs(sandbox: DockerSandbox, workdir: str):
|
||||||
|
src = f"{workdir}/cp_src.txt"
|
||||||
|
dst = f"{workdir}/deep/copy/file.txt"
|
||||||
|
sandbox.write_file(src, "copied")
|
||||||
|
tool = make_copy_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"src": src, "dst": dst})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
assert sandbox.read_file(dst) == b"copied"
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_file_missing_src_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_copy_file_tool(sandbox)
|
||||||
|
result = tool.invoke({"src": f"{workdir}/ghost.txt", "dst": f"{workdir}/out.txt"})
|
||||||
|
assert result.startswith("[ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# make_dir
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_dir_creates_directory(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/brand_new"
|
||||||
|
tool = make_make_dir_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": path})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
code, _ = sandbox.exec(f"test -d {path}")
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_dir_idempotent(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/existing_dir"
|
||||||
|
sandbox.exec(f"mkdir -p {path}")
|
||||||
|
tool = make_make_dir_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": path})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_dir_nested(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/a/b/c/d"
|
||||||
|
tool = make_make_dir_tool(sandbox)
|
||||||
|
result = tool.invoke({"path": path})
|
||||||
|
assert result.startswith("[OK]")
|
||||||
|
code, _ = sandbox.exec(f"test -d {path}")
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# search_files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_files_finds_match(sandbox: DockerSandbox, workdir: str):
|
||||||
|
sandbox.write_file(f"{workdir}/target.py", "# python file")
|
||||||
|
sandbox.write_file(f"{workdir}/other.txt", "text file")
|
||||||
|
tool = make_search_files_tool(sandbox)
|
||||||
|
result = tool.invoke({"pattern": "*.py", "directory": workdir})
|
||||||
|
assert "target.py" in result
|
||||||
|
assert "other.txt" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_files_no_matches(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_search_files_tool(sandbox)
|
||||||
|
result = tool.invoke({"pattern": "*.nonexistent", "directory": workdir})
|
||||||
|
assert result == "[no matches found]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_files_nested(sandbox: DockerSandbox, workdir: str):
|
||||||
|
sandbox.write_file(f"{workdir}/sub/deep.txt", "content")
|
||||||
|
tool = make_search_files_tool(sandbox)
|
||||||
|
result = tool.invoke({"pattern": "*.txt", "directory": workdir})
|
||||||
|
assert "deep.txt" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_files_by_exact_name(sandbox: DockerSandbox, workdir: str):
|
||||||
|
sandbox.write_file(f"{workdir}/exact_name.txt", "x")
|
||||||
|
tool = make_search_files_tool(sandbox)
|
||||||
|
result = tool.invoke({"pattern": "exact_name.txt", "directory": workdir})
|
||||||
|
assert "exact_name.txt" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# grep
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_finds_pattern(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/grep_me.txt"
|
||||||
|
sandbox.write_file(path, "line one\nline two\nline three\n")
|
||||||
|
tool = make_grep_tool(sandbox)
|
||||||
|
result = tool.invoke({"pattern": "two", "path": path})
|
||||||
|
assert "line two" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_no_matches(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/grep_empty.txt"
|
||||||
|
sandbox.write_file(path, "no match here\n")
|
||||||
|
tool = make_grep_tool(sandbox)
|
||||||
|
result = tool.invoke({"pattern": "zzznomatch", "path": path})
|
||||||
|
assert result == "[no matches found]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_includes_line_numbers(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/ln.txt"
|
||||||
|
sandbox.write_file(path, "alpha\nbeta\ngamma\n")
|
||||||
|
tool = make_grep_tool(sandbox)
|
||||||
|
result = tool.invoke({"pattern": "beta", "path": path})
|
||||||
|
assert "2" in result # line number
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_recursive(sandbox: DockerSandbox, workdir: str):
|
||||||
|
sandbox.write_file(f"{workdir}/d/a.txt", "find_me\n")
|
||||||
|
sandbox.write_file(f"{workdir}/d/b.txt", "not here\n")
|
||||||
|
tool = make_grep_tool(sandbox)
|
||||||
|
result = tool.invoke(
|
||||||
|
{"pattern": "find_me", "path": f"{workdir}/d", "recursive": True}
|
||||||
|
)
|
||||||
|
assert "find_me" in result
|
||||||
|
assert "a.txt" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_extended_regex(sandbox: DockerSandbox, workdir: str):
|
||||||
|
path = f"{workdir}/regex.txt"
|
||||||
|
sandbox.write_file(path, "foo123\nbar456\nbaz\n")
|
||||||
|
tool = make_grep_tool(sandbox)
|
||||||
|
result = tool.invoke({"pattern": "foo[0-9]+", "path": path})
|
||||||
|
assert "foo123" in result
|
||||||
|
assert "bar456" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_missing_file_returns_error(sandbox: DockerSandbox, workdir: str):
|
||||||
|
tool = make_grep_tool(sandbox)
|
||||||
|
result = tool.invoke({"pattern": "x", "path": f"{workdir}/no_file.txt"})
|
||||||
|
assert result.startswith("[ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# make_file_ops_tools assembly
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_file_ops_tools_returns_ten_tools(sandbox: DockerSandbox):
|
||||||
|
tools = make_file_ops_tools(sandbox)
|
||||||
|
assert len(tools) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_file_ops_tools_all_are_base_tools(sandbox: DockerSandbox):
|
||||||
|
for t in make_file_ops_tools(sandbox):
|
||||||
|
assert isinstance(t, BaseTool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_file_ops_tools_expected_names(sandbox: DockerSandbox):
|
||||||
|
names = {t.name for t in make_file_ops_tools(sandbox)}
|
||||||
|
expected = {
|
||||||
|
"read_file",
|
||||||
|
"write_file",
|
||||||
|
"edit_file",
|
||||||
|
"list_dir",
|
||||||
|
"delete_file",
|
||||||
|
"move_file",
|
||||||
|
"copy_file",
|
||||||
|
"make_dir",
|
||||||
|
"search_files",
|
||||||
|
"grep",
|
||||||
|
}
|
||||||
|
assert names == expected
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Unit tests for docker_agent_sandbox.tools._utils — no Docker required."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from docker_agent_sandbox.tools._utils import (
|
||||||
|
_MAX_OUTPUT_CHARS,
|
||||||
|
_MAX_OUTPUT_LINES,
|
||||||
|
_TRUNCATION_NOTICE,
|
||||||
|
_parent,
|
||||||
|
truncate_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# truncate_output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_output_short_string_unchanged():
|
||||||
|
assert truncate_output("hello world") == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_output_empty_string():
|
||||||
|
assert truncate_output("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_output_exactly_at_line_limit():
|
||||||
|
output = "line\n" * _MAX_OUTPUT_LINES
|
||||||
|
assert truncate_output(output) == output
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_output_one_over_line_limit():
|
||||||
|
output = "line\n" * (_MAX_OUTPUT_LINES + 1)
|
||||||
|
result = truncate_output(output)
|
||||||
|
assert _TRUNCATION_NOTICE in result
|
||||||
|
# 200 "line\n" kept + notice; the 201st "line" must not appear
|
||||||
|
assert result.count("line\n") == _MAX_OUTPUT_LINES
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_output_line_limit_keeps_first_200():
|
||||||
|
output = "line\n" * 250
|
||||||
|
result = truncate_output(output)
|
||||||
|
assert result.startswith("line\n" * _MAX_OUTPUT_LINES)
|
||||||
|
assert _TRUNCATION_NOTICE in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_output_exactly_at_char_limit():
|
||||||
|
output = "x" * _MAX_OUTPUT_CHARS
|
||||||
|
assert truncate_output(output) == output
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_output_one_over_char_limit():
|
||||||
|
output = "x" * (_MAX_OUTPUT_CHARS + 1)
|
||||||
|
result = truncate_output(output)
|
||||||
|
assert _TRUNCATION_NOTICE in result
|
||||||
|
# Exactly _MAX_OUTPUT_CHARS x's are kept before the notice
|
||||||
|
assert result.startswith("x" * _MAX_OUTPUT_CHARS)
|
||||||
|
assert result[_MAX_OUTPUT_CHARS] != "x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_output_char_limit_takes_first_20000():
|
||||||
|
output = "x" * 25_000
|
||||||
|
result = truncate_output(output)
|
||||||
|
assert result.startswith("x" * _MAX_OUTPUT_CHARS)
|
||||||
|
assert _TRUNCATION_NOTICE in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_output_line_limit_checked_before_char_limit():
|
||||||
|
# 201 lines of 200 chars each = 201 * 201 = ~40k chars (> char limit too).
|
||||||
|
# Lines are checked first, so only the line-limit truncation notice appears.
|
||||||
|
output = ("x" * 200 + "\n") * 201
|
||||||
|
result = truncate_output(output)
|
||||||
|
assert _TRUNCATION_NOTICE in result
|
||||||
|
# After line truncation the result is 200 * 201 = 40200 chars + notice,
|
||||||
|
# which is still > _MAX_OUTPUT_CHARS, so the char truncation fires too.
|
||||||
|
# Either way the result must be shorter than the input.
|
||||||
|
assert len(result) < len(output)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _parent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path, expected",
|
||||||
|
[
|
||||||
|
("/foo/bar/baz.txt", "/foo/bar"),
|
||||||
|
("/foo/bar/baz/", "/foo/bar"), # trailing slash stripped before dirname
|
||||||
|
("/foo/bar", "/foo"),
|
||||||
|
("/foo", "/"),
|
||||||
|
("foo/bar/baz", "foo/bar"),
|
||||||
|
("foo/bar", "foo"),
|
||||||
|
("foo", "."),
|
||||||
|
("", "."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parent(path, expected):
|
||||||
|
assert _parent(path) == expected
|
||||||
@@ -131,6 +131,105 @@ 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" },
|
{ 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 = "coverage"
|
||||||
|
version = "7.13.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docker"
|
name = "docker"
|
||||||
version = "7.1.0"
|
version = "7.1.0"
|
||||||
@@ -160,15 +259,23 @@ dev = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "docker", specifier = ">=7.1.0" },
|
{ name = "docker", specifier = ">=7.1.0" },
|
||||||
{ name = "langchain-core", specifier = ">=1.2.24" },
|
{ name = "langchain-core", specifier = ">=1.2.24" },
|
||||||
{ name = "loguru", specifier = ">=0.7.3" },
|
{ name = "loguru", specifier = ">=0.7.3" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "coverage", specifier = ">=7.13.5" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user