Compare commits

...

6 Commits

Author SHA1 Message Date
Matte23 0b2d2982ab feat: Reduce resources for test containers
CI / unit-tests (push) Successful in 8s
CI / integration-tests (push) Successful in 23s
CI / publish (push) Failing after 5s
2026-05-04 11:26:06 +02:00
Matte23 909b238cab ci: Switch to uv
CI / unit-tests (push) Successful in 1m5s
CI / integration-tests (push) Failing after 47s
CI / publish (push) Has been skipped
2026-05-04 11:19:55 +02:00
Matte23 4ee0cda29a feat: Use liens for read_file tool 2026-05-04 11:19:48 +02:00
Matte23 eac1643d48 tests: Add more side cases for exec command 2026-05-04 11:19:28 +02:00
Matte23 9dc5b9ba50 fix: Timeout monkey patch not working on Linux. Implement native poll
CI / test (push) Failing after 36s
CI / publish (push) Has been skipped
override
2026-05-04 11:07:03 +02:00
Matte23 cb8daed405 ci: Add unit and integration tests 2026-05-04 11:06:20 +02:00
12 changed files with 1164 additions and 55 deletions
+28 -22
View File
@@ -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/*
+42 -12
View File
@@ -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",
new=lambda rlist, wlist, xlist: _original_select( class _PollWithTimeout:
rlist, wlist, xlist, timeout 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(
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:
+52 -19
View File
@@ -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
View File
@@ -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",
]
View File
View File
+43
View File
@@ -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}")
+281
View File
@@ -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()
+498
View File
@@ -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
View File
+99
View File
@@ -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
Generated
+108 -1
View File
@@ -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"