chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:47:58 +08:00
commit b16403ea71
789 changed files with 115226 additions and 0 deletions
@@ -0,0 +1,9 @@
import pytest
from e2b import Sandbox
@pytest.mark.skip_debug()
def test_get_info(sandbox: Sandbox):
info = Sandbox.get_info(sandbox.sandbox_id)
assert info.sandbox_id == sandbox.sandbox_id
@@ -0,0 +1,21 @@
import pytest
from e2b import Sandbox, SandboxQuery, SandboxState
@pytest.mark.skip_debug()
def test_kill_existing_sandbox(sandbox: Sandbox, sandbox_test_id: str):
assert Sandbox.kill(sandbox.sandbox_id)
paginator = Sandbox.list(
query=SandboxQuery(
state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id}
)
)
sandboxes = paginator.next_items()
assert sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes]
@pytest.mark.skip_debug()
def test_kill_non_existing_sandbox():
assert not Sandbox.kill("nonexistingsandbox")
@@ -0,0 +1,189 @@
import uuid
import pytest
from e2b import Sandbox, SandboxQuery, SandboxState
@pytest.mark.skip_debug()
def test_list_sandboxes(sandbox: Sandbox, sandbox_test_id: str):
paginator = Sandbox.list(
query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id})
)
sandboxes = paginator.next_items()
assert len(sandboxes) >= 1
assert sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes]
@pytest.mark.skip_debug()
def test_list_sandboxes_with_filter(sandbox_factory, sandbox_test_id: str):
unique_id = str(uuid.uuid4())
extra_sbx = sandbox_factory(metadata={"unique_id": unique_id})
paginator = Sandbox.list(query=SandboxQuery(metadata={"unique_id": unique_id}))
sandboxes = paginator.next_items()
assert len(sandboxes) == 1
assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id
@pytest.mark.skip_debug()
def test_list_running_sandboxes(sandbox: Sandbox, sandbox_test_id: str):
paginator = Sandbox.list(
query=SandboxQuery(
metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.RUNNING]
)
)
sandboxes = paginator.next_items()
assert len(sandboxes) >= 1
# Verify our running sandbox is in the list
assert any(
s.sandbox_id == sandbox.sandbox_id and s.state == SandboxState.RUNNING
for s in sandboxes
)
@pytest.mark.skip_debug()
def test_list_paused_sandboxes(sandbox: Sandbox, sandbox_test_id: str):
sandbox.beta_pause()
paginator = Sandbox.list(
query=SandboxQuery(
metadata={"sandbox_test_id": sandbox_test_id}, state=[SandboxState.PAUSED]
)
)
sandboxes = paginator.next_items()
assert len(sandboxes) >= 1
# Verify our paused sandbox is in the list
assert any(
s.sandbox_id == sandbox.sandbox_id and s.state == SandboxState.PAUSED
for s in sandboxes
)
@pytest.mark.skip_debug()
def test_paginate_running_sandboxes(
sandbox: Sandbox, sandbox_factory, sandbox_test_id: str
):
# Create two sandboxes
extra_sbx = sandbox_factory(metadata={"sandbox_test_id": sandbox_test_id})
# Test pagination with limit
paginator = Sandbox.list(
query=SandboxQuery(
metadata={"sandbox_test_id": sandbox_test_id},
state=[SandboxState.RUNNING],
),
limit=1,
)
sandboxes = paginator.next_items()
# Check first page
assert len(sandboxes) == 1
assert sandboxes[0].state == SandboxState.RUNNING
assert paginator.has_next is True
assert paginator.next_token is not None
assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id
# Get second page
sandboxes = paginator.next_items()
# Check second page
assert len(sandboxes) == 1
assert sandboxes[0].state == SandboxState.RUNNING
assert paginator.has_next is False
assert paginator.next_token is None
assert sandboxes[0].sandbox_id == sandbox.sandbox_id
@pytest.mark.skip_debug()
def test_paginate_paused_sandboxes(
sandbox: Sandbox, sandbox_factory, sandbox_test_id: str
):
sandbox.beta_pause()
# create another paused sandbox
extra_sbx = sandbox_factory(metadata={"sandbox_test_id": sandbox_test_id})
extra_sbx.beta_pause()
# Test pagination with limit
paginator = Sandbox.list(
query=SandboxQuery(
state=[SandboxState.PAUSED],
metadata={"sandbox_test_id": sandbox_test_id},
),
limit=1,
)
sandboxes = paginator.next_items()
# Check first page
assert len(sandboxes) == 1
assert sandboxes[0].state == SandboxState.PAUSED
assert paginator.has_next is True
assert paginator.next_token is not None
assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id
# Get second page
sandboxes = paginator.next_items()
# Check second page
assert len(sandboxes) == 1
assert sandboxes[0].state == SandboxState.PAUSED
assert paginator.has_next is False
assert paginator.next_token is None
assert sandboxes[0].sandbox_id == sandbox.sandbox_id
@pytest.mark.skip_debug()
def test_paginate_running_and_paused_sandboxes(
sandbox: Sandbox, sandbox_factory, sandbox_test_id: str
):
# Create extra paused sandbox
extra_sbx = sandbox_factory(metadata={"sandbox_test_id": sandbox_test_id})
extra_sbx.beta_pause()
# Test pagination with limit
paginator = Sandbox.list(
query=SandboxQuery(
metadata={"sandbox_test_id": sandbox_test_id},
state=[SandboxState.RUNNING, SandboxState.PAUSED],
),
limit=1,
)
sandboxes = paginator.next_items()
# Check first page
assert len(sandboxes) == 1
assert sandboxes[0].state == SandboxState.PAUSED
assert paginator.has_next is True
assert paginator.next_token is not None
assert sandboxes[0].sandbox_id == extra_sbx.sandbox_id
# Get second page
sandboxes = paginator.next_items()
# Check second page
assert len(sandboxes) == 1
assert sandboxes[0].state == SandboxState.RUNNING
assert paginator.has_next is False
assert paginator.next_token is None
assert sandboxes[0].sandbox_id == sandbox.sandbox_id
@pytest.mark.skip_debug()
def test_paginate_iterator(sandbox: Sandbox, sandbox_test_id: str):
paginator = Sandbox.list(
query=SandboxQuery(metadata={"sandbox_test_id": sandbox_test_id})
)
sandboxes_list = []
while paginator.has_next:
sandboxes = paginator.next_items()
sandboxes_list.extend(sandboxes)
assert len(sandboxes_list) > 0
assert sandbox.sandbox_id in [sbx.sandbox_id for sbx in sandboxes_list]
@@ -0,0 +1,19 @@
import pytest
from e2b import Sandbox
@pytest.mark.skip_debug()
def test_pause_sandbox(sandbox: Sandbox):
Sandbox.pause(sandbox.sandbox_id)
assert not sandbox.is_running()
@pytest.mark.skip_debug()
def test_resume_sandbox(sandbox: Sandbox):
# pause
Sandbox.pause(sandbox.sandbox_id)
assert not sandbox.is_running()
# resume
Sandbox.connect(sandbox.sandbox_id)
assert sandbox.is_running()
@@ -0,0 +1,18 @@
import pytest
from e2b import NotFoundException
def test_connect_to_process(sandbox):
cmd = sandbox.commands.run("sleep 10", background=True)
pid = cmd.pid
process_info = sandbox.commands.connect(pid)
assert process_info.pid == pid
def test_connect_to_non_existing_process(sandbox):
non_existing_pid = 999999
with pytest.raises(NotFoundException):
sandbox.commands.connect(non_existing_pid)
@@ -0,0 +1,19 @@
import pytest
from e2b import Sandbox, CommandExitException
def test_kill_process(sandbox: Sandbox):
cmd = sandbox.commands.run("sleep 10", background=True)
pid = cmd.pid
sandbox.commands.kill(pid)
with pytest.raises(CommandExitException):
sandbox.commands.run(f"kill -0 {pid}")
def test_kill_non_existing_process(sandbox):
non_existing_pid = 999999
assert not sandbox.commands.kill(non_existing_pid)
@@ -0,0 +1,13 @@
from e2b import Sandbox
def test_kill_process(sandbox: Sandbox):
c1 = sandbox.commands.run("sleep 10", background=True)
c2 = sandbox.commands.run("sleep 10", background=True)
processes = sandbox.commands.list()
assert len(processes) >= 2
pids = [p.pid for p in processes]
assert c1.pid in pids
assert c2.pid in pids
@@ -0,0 +1,35 @@
import pytest
from e2b import Sandbox
def test_command_envs(sandbox: Sandbox):
cmd = sandbox.commands.run("echo $FOO", envs={"FOO": "bar"})
assert cmd.stdout.strip() == "bar"
@pytest.mark.skip_debug()
def test_sandbox_envs(sandbox_factory):
sbx = sandbox_factory(envs={"FOO": "bar"})
cmd = sbx.commands.run("echo $FOO")
assert cmd.stdout.strip() == "bar"
def test_bash_command_scoped_env_vars(sandbox: Sandbox):
cmd = sandbox.commands.run("echo $FOO", envs={"FOO": "bar"})
assert cmd.exit_code == 0
assert cmd.stdout.strip() == "bar"
# test that it is secure and not accessible to subsequent commands
cmd2 = sandbox.commands.run('sudo echo "$FOO"')
assert cmd2.exit_code == 0
assert cmd2.stdout.strip() == ""
def test_python_command_scoped_env_vars(sandbox: Sandbox):
cmd = sandbox.commands.run(
"python3 -c \"import os; print(os.environ['FOO'])\"", envs={"FOO": "bar"}
)
assert cmd.exit_code == 0
assert cmd.stdout.strip() == "bar"
@@ -0,0 +1,58 @@
import pytest
from e2b import Sandbox, TimeoutException
def test_run(sandbox: Sandbox):
text = "Hello, World!"
cmd = sandbox.commands.run(f'echo "{text}"')
assert cmd.exit_code == 0
assert cmd.stdout == f"{text}\n"
def test_run_with_special_characters(sandbox: Sandbox):
text = "!@#$%^&*()_+"
cmd = sandbox.commands.run(f'echo "{text}"')
assert cmd.exit_code == 0
assert cmd.stdout == f"{text}\n"
def test_run_with_broken_utf8(sandbox: Sandbox):
# Create a string with 8191 'a' characters followed by the problematic byte 0xe2
long_str = "a" * 8191 + "\\xe2"
result = sandbox.commands.run(f'printf "{long_str}"')
assert result.exit_code == 0
# The broken UTF-8 bytes should be replaced with the Unicode replacement character
assert result.stdout == ("a" * 8191 + "\ufffd")
def test_run_with_multiline_string(sandbox):
text = "Hello,\nWorld!"
cmd = sandbox.commands.run(f'echo "{text}"')
assert cmd.exit_code == 0
assert cmd.stdout == f"{text}\n"
def test_run_with_timeout(sandbox):
cmd = sandbox.commands.run('echo "Hello, World!"', timeout=10)
assert cmd.exit_code == 0
def test_run_with_too_short_timeout(sandbox):
with pytest.raises(TimeoutException):
sandbox.commands.run("sleep 10", timeout=2)
def test_run_with_too_short_timeout_iterating(sandbox):
cmd = sandbox.commands.run("sleep 10", timeout=2, background=True)
with pytest.raises(TimeoutException):
for _ in cmd:
pass
@@ -0,0 +1,16 @@
import pytest
from e2b import Sandbox, TimeoutException
@pytest.mark.skip_debug()
def test_kill_sandbox_while_command_is_running(sandbox: Sandbox):
cmd = sandbox.commands.run("sleep 60", background=True)
sandbox.kill()
with pytest.raises(TimeoutException) as exc_info:
cmd.wait()
# The health check confirms the sandbox is gone, so the error states it outright
assert "sandbox was killed or reached its end of life" in str(exc_info.value)
@@ -0,0 +1,57 @@
from e2b import Sandbox
def test_send_stdin_to_process(sandbox: Sandbox):
cmd = sandbox.commands.run("cat", background=True, stdin=True)
sandbox.commands.send_stdin(cmd.pid, "Hello, World!")
for stdout, _, _ in cmd:
assert stdout == "Hello, World!"
break
def test_send_bytes_stdin_to_process(sandbox: Sandbox):
cmd = sandbox.commands.run("cat", background=True, stdin=True)
sandbox.commands.send_stdin(cmd.pid, b"Hello, World!")
for stdout, _, _ in cmd:
assert stdout == "Hello, World!"
break
def test_send_stdin_via_command_handle(sandbox: Sandbox):
cmd = sandbox.commands.run("cat", background=True, stdin=True)
cmd.send_stdin("Hello, World!")
for stdout, _, _ in cmd:
assert stdout == "Hello, World!"
break
def test_close_stdin_via_command_handle(sandbox: Sandbox):
cmd = sandbox.commands.run("cat", background=True, stdin=True)
cmd.send_stdin("Hello, World!")
cmd.close_stdin()
# `cat` exits once stdin is closed (EOF).
result = cmd.wait()
assert result.exit_code == 0
assert result.stdout == "Hello, World!"
def test_send_special_characters_to_process(sandbox: Sandbox):
cmd = sandbox.commands.run("cat", background=True, stdin=True)
sandbox.commands.send_stdin(cmd.pid, "!@#$%^&*()_+")
for stdout, _, _ in cmd:
assert stdout == "!@#$%^&*()_+"
break
def test_send_multiline_string_to_process(sandbox: Sandbox):
cmd = sandbox.commands.run("cat", background=True, stdin=True)
sandbox.commands.send_stdin(cmd.pid, "Hello,\nWorld!")
for stdout, _, _ in cmd:
assert stdout == "Hello,\nWorld!"
break
@@ -0,0 +1,61 @@
from e2b.sandbox.filesystem.filesystem import WriteEntry
def test_write_and_read_with_gzip(sandbox, debug):
filename = "test_gzip_write.txt"
content = "This is a test file with gzip encoding."
info = sandbox.files.write(filename, content, gzip=True)
assert info.path == f"/home/user/{filename}"
read_content = sandbox.files.read(filename, gzip=True)
assert read_content == content
if debug:
sandbox.files.remove(filename)
def test_write_gzip_read_plain(sandbox, debug):
filename = "test_gzip_write_plain_read.txt"
content = "Written with gzip, read without."
sandbox.files.write(filename, content, gzip=True)
read_content = sandbox.files.read(filename)
assert read_content == content
if debug:
sandbox.files.remove(filename)
def test_write_files_with_gzip(sandbox, debug):
files = [
WriteEntry(path="gzip_multi_1.txt", data="File 1 content"),
WriteEntry(path="gzip_multi_2.txt", data="File 2 content"),
WriteEntry(path="gzip_multi_3.txt", data="File 3 content"),
]
infos = sandbox.files.write_files(files, gzip=True)
assert len(infos) == len(files)
for i, file in enumerate(files):
read_content = sandbox.files.read(file["path"])
assert read_content == file["data"]
if debug:
for file in files:
sandbox.files.remove(file["path"])
def test_read_bytes_with_gzip(sandbox, debug):
filename = "test_gzip_bytes.txt"
content = "Binary content with gzip."
sandbox.files.write(filename, content)
read_bytes = sandbox.files.read(filename, format="bytes", gzip=True)
assert isinstance(read_bytes, bytearray)
assert read_bytes.decode("utf-8") == content
if debug:
sandbox.files.remove(filename)
@@ -0,0 +1,8 @@
from e2b import Sandbox
def test_exists(sandbox: Sandbox):
filename = "test_exists.txt"
sandbox.files.write(filename, "test")
assert sandbox.files.exists(filename)
@@ -0,0 +1,247 @@
import uuid
from typing import Any
from e2b import Sandbox, FileType
def test_list_directory(sandbox: Sandbox):
home_dir_name = "/home/user"
parent_dir_name = f"test_directory_{uuid.uuid4()}"
sandbox.files.make_dir(parent_dir_name)
sandbox.files.make_dir(f"{parent_dir_name}/subdir1")
sandbox.files.make_dir(f"{parent_dir_name}/subdir2")
sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_1")
sandbox.files.make_dir(f"{parent_dir_name}/subdir1/subdir1_2")
sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_1")
sandbox.files.make_dir(f"{parent_dir_name}/subdir2/subdir2_2")
sandbox.files.write(f"{parent_dir_name}/file1.txt", "Hello, world!")
test_cases: list[dict[str, Any]] = [
{
"name": "default depth (1)",
"depth": None,
"expected_len": 3,
"expected_file_names": [
"file1.txt",
"subdir1",
"subdir2",
],
"expected_file_types": [
FileType.FILE,
FileType.DIR,
FileType.DIR,
],
"expected_file_paths": [
f"{home_dir_name}/{parent_dir_name}/file1.txt",
f"{home_dir_name}/{parent_dir_name}/subdir1",
f"{home_dir_name}/{parent_dir_name}/subdir2",
],
},
{
"name": "explicit depth 1",
"depth": 1,
"expected_len": 3,
"expected_file_names": [
"file1.txt",
"subdir1",
"subdir2",
],
"expected_file_types": [
FileType.FILE,
FileType.DIR,
FileType.DIR,
],
"expected_file_paths": [
f"{home_dir_name}/{parent_dir_name}/file1.txt",
f"{home_dir_name}/{parent_dir_name}/subdir1",
f"{home_dir_name}/{parent_dir_name}/subdir2",
],
},
{
"name": "explicit depth 2",
"depth": 2,
"expected_len": 7,
"expected_file_types": [
FileType.FILE,
FileType.DIR,
FileType.DIR,
FileType.DIR,
FileType.DIR,
FileType.DIR,
FileType.DIR,
],
"expected_file_names": [
"file1.txt",
"subdir1",
"subdir1_1",
"subdir1_2",
"subdir2",
"subdir2_1",
"subdir2_2",
],
"expected_file_paths": [
f"{home_dir_name}/{parent_dir_name}/file1.txt",
f"{home_dir_name}/{parent_dir_name}/subdir1",
f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1",
f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2",
f"{home_dir_name}/{parent_dir_name}/subdir2",
f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1",
f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2",
],
},
{
"name": "explicit depth 3 (should be the same as depth 2)",
"depth": 3,
"expected_len": 7,
"expected_file_names": [
"file1.txt",
"subdir1",
"subdir1_1",
"subdir1_2",
"subdir2",
"subdir2_1",
"subdir2_2",
],
"expected_file_types": [
FileType.FILE,
FileType.DIR,
FileType.DIR,
FileType.DIR,
FileType.DIR,
FileType.DIR,
FileType.DIR,
],
"expected_file_paths": [
f"{home_dir_name}/{parent_dir_name}/file1.txt",
f"{home_dir_name}/{parent_dir_name}/subdir1",
f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_1",
f"{home_dir_name}/{parent_dir_name}/subdir1/subdir1_2",
f"{home_dir_name}/{parent_dir_name}/subdir2",
f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_1",
f"{home_dir_name}/{parent_dir_name}/subdir2/subdir2_2",
],
},
]
for test_case in test_cases:
files = sandbox.files.list(
parent_dir_name,
depth=test_case["depth"] if test_case["depth"] is not None else None,
)
assert len(files) == test_case["expected_len"]
for i in range(len(test_case["expected_file_names"])):
assert files[i].name == test_case["expected_file_names"][i]
assert files[i].path == test_case["expected_file_paths"][i]
assert files[i].type == test_case["expected_file_types"][i]
sandbox.files.remove(parent_dir_name)
def test_list_directory_error_cases(sandbox: Sandbox):
parent_dir_name = f"test_directory_{uuid.uuid4()}"
sandbox.files.make_dir(parent_dir_name)
expected_error_message = "depth should be at least 1"
try:
sandbox.files.list(parent_dir_name, depth=-1)
assert False, "Expected error but none was thrown"
except Exception as err:
assert expected_error_message in str(err), (
f'expected error message to include "{expected_error_message}"'
)
sandbox.files.remove(parent_dir_name)
def test_file_entry_details(sandbox: Sandbox):
test_dir = "test-file-entry"
file_path = f"{test_dir}/test.txt"
content = "Hello, World!"
sandbox.files.make_dir(test_dir)
sandbox.files.write(file_path, content)
files = sandbox.files.list(test_dir, depth=1)
assert len(files) == 1
file_entry = files[0]
assert file_entry.name == "test.txt"
assert file_entry.path == f"/home/user/{file_path}"
assert file_entry.type == FileType.FILE
assert file_entry.mode == 0o644
assert file_entry.permissions == "-rw-r--r--"
assert file_entry.owner == "user"
assert file_entry.group == "user"
assert file_entry.size == len(content)
assert file_entry.modified_time is not None
assert file_entry.symlink_target is None
sandbox.files.remove(test_dir)
def test_directory_entry_details(sandbox: Sandbox):
test_dir = "test-entry-info"
sub_dir = f"{test_dir}/subdir"
sandbox.files.make_dir(test_dir)
sandbox.files.make_dir(sub_dir)
files = sandbox.files.list(test_dir, depth=1)
assert len(files) == 1
dir_entry = files[0]
assert dir_entry.name == "subdir"
assert dir_entry.path == f"/home/user/{sub_dir}"
assert dir_entry.type == FileType.DIR
assert dir_entry.mode == 0o755
assert dir_entry.permissions == "drwxr-xr-x"
assert dir_entry.owner == "user"
assert dir_entry.group == "user"
assert dir_entry.modified_time is not None
sandbox.files.remove(test_dir)
def test_mixed_entries(sandbox: Sandbox):
test_dir = "test-mixed-entries"
sub_dir = f"{test_dir}/subdir"
file_path = f"{test_dir}/test.txt"
content = "Hello, World!"
sandbox.files.make_dir(test_dir)
sandbox.files.make_dir(sub_dir)
sandbox.files.write(file_path, content)
files = sandbox.files.list(test_dir, depth=1)
assert len(files) == 2
# Create a dictionary of entries by name for easier verification
entries = {entry.name: entry for entry in files}
# Verify directory entry
dir_entry = entries.get("subdir")
assert dir_entry is not None
assert dir_entry.path == f"/home/user/{sub_dir}"
assert dir_entry.type == FileType.DIR
assert dir_entry.mode == 0o755
assert dir_entry.permissions == "drwxr-xr-x"
assert dir_entry.owner == "user"
assert dir_entry.group == "user"
assert dir_entry.modified_time is not None
# Verify file entry
file_entry = entries.get("test.txt")
assert file_entry is not None
assert file_entry.path == f"/home/user/{file_path}"
assert file_entry.type == FileType.FILE
assert file_entry.mode == 0o644
assert file_entry.permissions == "-rw-r--r--"
assert file_entry.owner == "user"
assert file_entry.group == "user"
assert file_entry.size == len(content)
assert file_entry.modified_time is not None
sandbox.files.remove(test_dir)
@@ -0,0 +1,75 @@
import pytest
from e2b.exceptions import FileNotFoundException
from e2b import Sandbox, FileType
def test_get_info_of_file(sandbox: Sandbox):
filename = "test_file.txt"
sandbox.files.write(filename, "test")
info = sandbox.files.get_info(filename)
current_path = sandbox.commands.run("pwd")
assert info.name == filename
assert info.type == FileType.FILE
assert info.path == f"{current_path.stdout.strip()}/{filename}"
assert info.size == 4
assert info.mode == 0o644
assert info.permissions == "-rw-r--r--"
assert info.owner == "user"
assert info.group == "user"
assert info.modified_time is not None
assert info.modified_time.tzinfo is not None
def test_get_info_of_nonexistent_file(sandbox: Sandbox):
filename = "test_does_not_exist.txt"
with pytest.raises(FileNotFoundException):
sandbox.files.get_info(filename)
def test_get_info_of_directory(sandbox: Sandbox):
dirname = "test_dir"
sandbox.files.make_dir(dirname)
info = sandbox.files.get_info(dirname)
current_path = sandbox.commands.run("pwd")
assert info.name == dirname
assert info.type == FileType.DIR
assert info.path == f"{current_path.stdout.strip()}/{dirname}"
assert info.size > 0
assert info.mode == 0o755
assert info.permissions == "drwxr-xr-x"
assert info.owner == "user"
assert info.group == "user"
assert info.modified_time is not None
assert info.modified_time.tzinfo is not None
def test_get_info_of_nonexistent_directory(sandbox: Sandbox):
dirname = "test_does_not_exist_dir"
with pytest.raises(FileNotFoundException):
sandbox.files.get_info(dirname)
def test_file_symlink(sandbox: Sandbox):
test_dir = "test-simlink-entry"
file_name = "test.txt"
content = "Hello, World!"
sandbox.files.make_dir(test_dir)
sandbox.files.write(f"{test_dir}/{file_name}", content)
symlink_name = "symlink_to_test.txt"
sandbox.commands.run(f"ln -s {file_name} {symlink_name}", cwd=test_dir)
file = sandbox.files.get_info(f"{test_dir}/{symlink_name}")
pwd = sandbox.commands.run("pwd")
assert file.type == FileType.FILE
assert file.symlink_target == f"{pwd.stdout.strip()}/{test_dir}/{file_name}"
sandbox.files.remove(test_dir)
@@ -0,0 +1,29 @@
import uuid
from e2b import Sandbox
def test_make_directory(sandbox: Sandbox):
dir_name = f"test_directory_{uuid.uuid4()}"
sandbox.files.make_dir(dir_name)
exists = sandbox.files.exists(dir_name)
assert exists
async def test_make_directory_already_exists(sandbox: Sandbox):
dir_name = f"test_directory_{uuid.uuid4()}"
created = sandbox.files.make_dir(dir_name)
assert created
created = sandbox.files.make_dir(dir_name)
assert not created
def test_make_nested_directory(sandbox: Sandbox):
nested_dir_name = f"test_directory_{uuid.uuid4()}/nested_directory"
sandbox.files.make_dir(nested_dir_name)
exists = sandbox.files.exists(nested_dir_name)
assert exists
@@ -0,0 +1,163 @@
import pytest
from e2b.exceptions import InvalidArgumentException
def test_write_file_with_metadata(sandbox, debug):
filename = "test_metadata.txt"
content = "This is a test file with metadata."
metadata = {"author": "mish", "purpose": "upload"}
info = sandbox.files.write(filename, content, metadata=metadata)
assert info.metadata == metadata
# Metadata is persisted and surfaced on subsequent reads.
stat = sandbox.files.get_info(filename)
assert stat.metadata == metadata
if debug:
sandbox.files.remove(filename)
def test_write_file_with_metadata_octet_stream(sandbox, debug):
filename = "test_metadata_octet.txt"
content = "This is a test file with metadata."
metadata = {"author": "mish", "purpose": "upload"}
info = sandbox.files.write(
filename, content, metadata=metadata, use_octet_stream=True
)
assert info.metadata == metadata
stat = sandbox.files.get_info(filename)
assert stat.metadata == metadata
if debug:
sandbox.files.remove(filename)
def test_write_file_without_metadata(sandbox, debug):
filename = "test_no_metadata.txt"
info = sandbox.files.write(filename, "no metadata here")
assert info.metadata is None
stat = sandbox.files.get_info(filename)
assert stat.metadata is None
if debug:
sandbox.files.remove(filename)
def test_write_files_applies_metadata_to_every_file(sandbox, debug):
from e2b.sandbox.filesystem.filesystem import WriteEntry
# The same metadata is applied to every file in the upload.
metadata = {"source": "test-suite"}
files = [
WriteEntry(path="metadata_multi_1.txt", data="File 1"),
WriteEntry(path="metadata_multi_2.txt", data="File 2"),
]
infos = sandbox.files.write_files(files, metadata=metadata)
assert len(infos) == len(files)
for info in infos:
assert info.metadata == metadata
stat = sandbox.files.get_info(info.path)
assert stat.metadata == metadata
if debug:
for file in files:
sandbox.files.remove(file["path"])
def test_metadata_surfaced_when_listing(sandbox, debug):
dirname = "metadata_list_dir"
filename = "listed.txt"
metadata = {"tag": "listed"}
sandbox.files.make_dir(dirname)
sandbox.files.write(f"{dirname}/{filename}", "content", metadata=metadata)
entries = sandbox.files.list(dirname)
entry = next((e for e in entries if e.name == filename), None)
assert entry is not None
assert entry.metadata == metadata
if debug:
sandbox.files.remove(dirname)
def test_metadata_surfaced_after_rename(sandbox, debug):
old_path = "metadata_rename_old.txt"
new_path = "metadata_rename_new.txt"
metadata = {"stage": "renamed"}
sandbox.files.write(old_path, "content", metadata=metadata)
info = sandbox.files.rename(old_path, new_path)
assert info.metadata == metadata
if debug:
sandbox.files.remove(new_path)
def test_overwriting_clears_stale_metadata(sandbox, debug):
filename = "metadata_overwrite.txt"
sandbox.files.write(filename, "first", metadata={"author": "mish"})
# Overwriting without metadata removes the previously stored metadata.
info = sandbox.files.write(filename, "second")
assert info.metadata is None
stat = sandbox.files.get_info(filename)
assert stat.metadata is None
if debug:
sandbox.files.remove(filename)
def test_metadata_set_via_xattrs_surfaced_in_get_info(sandbox, debug):
filename = "metadata_xattr.txt"
sandbox.files.write(filename, "content")
file_path = sandbox.commands.run(f"realpath {filename}").stdout.strip()
# Set an xattr directly in the `user.e2b.` namespace (out-of-band, not via
# the SDK upload); it should surface as metadata (with the namespace prefix
# stripped) when reading the file info.
sandbox.commands.run(
f"python3 -c \"import os; os.setxattr('{file_path}', 'user.e2b.author', b'mish')\""
)
info = sandbox.files.get_info(filename)
assert info.metadata == {"author": "mish"}
if debug:
sandbox.files.remove(filename)
def test_write_rejects_invalid_metadata(sandbox):
filename = "invalid_metadata.txt"
# Key with a space is not a valid HTTP header token.
with pytest.raises(InvalidArgumentException):
sandbox.files.write(filename, "x", metadata={"bad key": "value"})
# Empty key.
with pytest.raises(InvalidArgumentException):
sandbox.files.write(filename, "x", metadata={"": "value"})
# Value with a non-printable / non-ASCII character.
with pytest.raises(InvalidArgumentException):
sandbox.files.write(filename, "x", metadata={"good": "bad\nvalue"})
# Trailing newline (Python's `$` would accept it; `\Z` must not).
with pytest.raises(InvalidArgumentException):
sandbox.files.write(filename, "x", metadata={"good": "value\n"})
with pytest.raises(InvalidArgumentException):
sandbox.files.write(filename, "x", metadata={"key\n": "value"})
# The file must not have been created by a rejected write.
assert not sandbox.files.exists(filename)
@@ -0,0 +1,83 @@
import pytest
from e2b import FileNotFoundException, NotFoundException
def test_read_file(sandbox):
filename = "test_read.txt"
content = "Hello, world!"
sandbox.files.write(filename, content)
read_content = sandbox.files.read(filename)
assert read_content == content
def test_read_non_existing_file(sandbox):
filename = "non_existing_file.txt"
with pytest.raises(FileNotFoundException):
sandbox.files.read(filename)
def test_read_non_existing_file_catches_with_deprecated_not_found_exception(sandbox):
filename = "non_existing_file.txt"
with pytest.raises(NotFoundException):
sandbox.files.read(filename)
def test_read_empty_file(sandbox):
filename = "empty_file.txt"
content = ""
sandbox.commands.run(f"touch {filename}")
read_content = sandbox.files.read(filename)
assert read_content == content
def test_read_file_as_stream(sandbox):
filename = "test_read_stream.txt"
content = "Streamed read content. " * 10_000
sandbox.files.write(filename, content)
stream = sandbox.files.read(filename, format="stream")
read_content = b"".join(stream).decode("utf-8")
assert read_content == content
def test_read_file_as_stream_with_gzip(sandbox):
filename = "test_read_stream_gzip.txt"
content = "Streamed gzipped read content. " * 10_000
sandbox.files.write(filename, content)
stream = sandbox.files.read(filename, format="stream", gzip=True)
read_content = b"".join(stream).decode("utf-8")
assert read_content == content
def test_read_non_existing_file_as_stream(sandbox):
filename = "non_existing_file.txt"
with pytest.raises(FileNotFoundException):
sandbox.files.read(filename, format="stream")
def test_read_file_as_stream_context_manager(sandbox):
filename = "test_read_stream_ctx.txt"
content = "Streamed read content. " * 10_000
sandbox.files.write(filename, content)
with sandbox.files.read(filename, format="stream") as stream:
read_content = b"".join(stream).decode("utf-8")
assert read_content == content
def test_read_file_as_stream_partial_then_close(sandbox):
filename = "test_read_stream_partial.txt"
content = "Streamed read content. " * 10_000
sandbox.files.write(filename, content)
# Reading only the first chunk and closing must not raise or leak.
stream = sandbox.files.read(filename, format="stream")
first = next(iter(stream))
assert len(first) > 0
stream.close()
@@ -0,0 +1,18 @@
from e2b import Sandbox
def test_remove_file(sandbox: Sandbox):
filename = "test_remove.txt"
content = "This file will be removed."
sandbox.files.write(filename, content)
sandbox.files.remove(filename)
exists = sandbox.files.exists(filename)
assert not exists
def test_remove_non_existing_file(sandbox):
filename = "non_existing_file.txt"
sandbox.files.remove(filename)
@@ -0,0 +1,28 @@
import pytest
from e2b import FileNotFoundException, Sandbox
def test_rename_file(sandbox: Sandbox):
old_filename = "test_rename_old.txt"
new_filename = "test_rename_new.txt"
content = "This file will be renamed."
sandbox.files.write(old_filename, content)
info = sandbox.files.rename(old_filename, new_filename)
assert info.path == f"/home/user/{new_filename}"
exists_old = sandbox.files.exists(old_filename)
exists_new = sandbox.files.exists(new_filename)
assert not exists_old
assert exists_new
read_content = sandbox.files.read(new_filename)
assert read_content == content
def test_rename_non_existing_file(sandbox):
old_filename = "non_existing_file.txt"
new_filename = "new_non_existing_file.txt"
with pytest.raises(FileNotFoundException):
sandbox.files.rename(old_filename, new_filename)
@@ -0,0 +1,59 @@
import urllib.request
import urllib.error
import json
import pytest
@pytest.mark.skip_debug()
def test_download_url_with_signing(sandbox_factory):
sbx = sandbox_factory(timeout=100, secure=True)
file_path = "test_download_url_with_signing.txt"
file_content = "This file will be watched."
sbx.files.write(file_path, file_content)
signed_url = sbx.download_url(file_path, "user")
with urllib.request.urlopen(signed_url) as resp:
assert resp.status == 200
body_bytes = resp.read()
body_text = body_bytes.decode()
assert body_text == file_content
@pytest.mark.skip_debug()
def test_download_url_with_signing_and_expiration(sandbox_factory):
sbx = sandbox_factory(timeout=100, secure=True)
file_path = "test_download_url_with_signing.txt"
file_content = "This file will be watched."
sbx.files.write(file_path, file_content)
signed_url = sbx.download_url(file_path, "user", 120)
with urllib.request.urlopen(signed_url) as resp:
assert resp.status == 200
body_bytes = resp.read()
body_text = body_bytes.decode()
assert body_text == file_content
@pytest.mark.skip_debug()
def test_download_url_with_expired_signing(sandbox_factory):
sbx = sandbox_factory(timeout=100, secure=True)
file_path = "test_download_url_with_signing.txt"
file_content = "This file will be watched."
sbx.files.write(file_path, file_content)
signed_url = sbx.download_url(file_path, "user", use_signature_expiration=-120)
with pytest.raises(urllib.error.HTTPError) as exc_info:
urllib.request.urlopen(signed_url)
err = exc_info.value
assert err.code == 401, f"Unexpected status {err.code}"
error_json_str = err.read().decode() # bytes ➜ str
error_payload = json.loads(error_json_str) # str ➜ dict
expected_payload = {"code": 401, "message": "signature is already expired"}
assert error_payload == expected_payload
@@ -0,0 +1,192 @@
import pytest
from e2b import (
FileNotFoundException,
FileType,
FilesystemEventType,
Sandbox,
SandboxException,
)
def test_watch_directory_changes_with_entry_info(sandbox: Sandbox):
dirname = "test_watch_dir_entry"
filename = "test_watch.txt"
content = "This file will be watched."
new_content = "This file has been modified."
sandbox.files.make_dir(dirname)
sandbox.files.write(f"{dirname}/{filename}", content)
handle = sandbox.files.watch_dir(dirname, include_entry=True)
sandbox.files.write(f"{dirname}/{filename}", new_content)
events = handle.get_new_events()
write_event = None
for event in events:
if event.type == FilesystemEventType.WRITE and event.name == filename:
write_event = event
break
assert write_event is not None, (
f"Expected WRITE event for {filename}, but got events: {events}"
)
# The entry is populated best-effort for events where the path still exists.
assert write_event.entry is not None
assert write_event.entry.name == filename
assert write_event.entry.path == f"/home/user/{dirname}/{filename}"
assert write_event.entry.type == FileType.FILE
handle.stop()
def test_watch_directory_changes_with_network_mounts_allowed(sandbox: Sandbox):
dirname = "test_watch_dir_network_mounts"
filename = "test_watch.txt"
content = "This file will be watched."
new_content = "This file has been modified."
sandbox.files.make_dir(dirname)
sandbox.files.write(f"{dirname}/{filename}", content)
# The flag only lifts the network-mount restriction — watching a regular
# directory must work the same with it enabled.
handle = sandbox.files.watch_dir(dirname, allow_network_mounts=True)
sandbox.files.write(f"{dirname}/{filename}", new_content)
events = handle.get_new_events()
write_event = None
for event in events:
if event.type == FilesystemEventType.WRITE and event.name == filename:
write_event = event
break
assert write_event is not None, (
f"Expected WRITE event for {filename}, but got events: {events}"
)
handle.stop()
def test_watch_directory_changes(sandbox: Sandbox):
dirname = "test_watch_dir"
filename = "test_watch.txt"
content = "This file will be watched."
new_content = "This file has been modified."
sandbox.files.make_dir(dirname)
sandbox.files.write(f"{dirname}/{filename}", content)
handle = sandbox.files.watch_dir(dirname)
sandbox.files.write(f"{dirname}/{filename}", new_content)
events = handle.get_new_events()
write_event = None
for event in events:
if event.type == FilesystemEventType.WRITE and event.name == filename:
write_event = event
break
assert write_event is not None, (
f"Expected WRITE event for {filename}, but got events: {events}"
)
assert write_event.name == filename
handle.stop()
def test_watch_iterated(sandbox: Sandbox):
dirname = "test_watch_dir_iterated"
filename = "test_watch_iterated.txt"
content = "This file will be watched."
new_content = "This file has been modified."
sandbox.files.make_dir(dirname)
handle = sandbox.files.watch_dir(dirname)
sandbox.files.write(f"{dirname}/{filename}", content)
events = handle.get_new_events()
assert len(events) == 3
sandbox.files.write(f"{dirname}/{filename}", new_content)
events = handle.get_new_events()
for event in events:
if event.type == FilesystemEventType.WRITE and event.name == filename:
break
handle.stop()
def test_watch_recursive_directory_changes(sandbox: Sandbox):
dirname = "test_recursive_watch_dir"
nested_dirname = "test_nested_watch_dir"
filename = "test_watch.txt"
content = "This file will be watched."
sandbox.files.remove(dirname)
sandbox.files.make_dir(f"{dirname}/{nested_dirname}")
handle = sandbox.files.watch_dir(dirname, recursive=True)
sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content)
events = handle.get_new_events()
assert len(events) == 3
expected_filename = f"{nested_dirname}/{filename}"
assert events[0].type == FilesystemEventType.CREATE
assert events[0].name == expected_filename
handle.stop()
def test_watch_recursive_directory_after_nested_folder_addition(sandbox: Sandbox):
dirname = "test_recursive_watch_dir_add"
nested_dirname = "test_nested_watch_dir"
filename = "test_watch.txt"
content = "This file will be watched."
sandbox.files.remove(dirname)
sandbox.files.make_dir(dirname)
handle = sandbox.files.watch_dir(dirname, recursive=True)
sandbox.files.make_dir(f"{dirname}/{nested_dirname}")
sandbox.files.write(f"{dirname}/{nested_dirname}/{filename}", content)
expected_filename = f"{nested_dirname}/{filename}"
events = handle.get_new_events()
file_changed = False
folder_created = False
for event in events:
if event.type == FilesystemEventType.WRITE and event.name == expected_filename:
file_changed = True
continue
if event.type == FilesystemEventType.CREATE and event.name == nested_dirname:
folder_created = True
assert folder_created
assert file_changed
handle.stop()
def test_watch_non_existing_directory(sandbox: Sandbox):
dirname = "non_existing_watch_dir"
with pytest.raises(FileNotFoundException):
sandbox.files.watch_dir(dirname)
def test_watch_file(sandbox: Sandbox):
filename = "test_watch.txt"
sandbox.files.write(filename, "This file will be watched.")
with pytest.raises(SandboxException):
sandbox.files.watch_dir(filename)
def test_watch_file_with_secured_envd(sandbox_factory):
sbx = sandbox_factory(timeout=30, secure=True)
sbx.files.watch_dir("/home/user/")
sbx.files.write("test_watch.txt", "This file will be watched.")
@@ -0,0 +1,218 @@
import io
import uuid
from e2b.sandbox.filesystem.filesystem import FileType, WriteInfo, WriteEntry
def test_write_text_file(sandbox, debug):
filename = "test_write.txt"
content = "This is a test file."
info = sandbox.files.write(filename, content)
assert info.path == f"/home/user/{filename}"
assert info.type == FileType.FILE
exists = sandbox.files.exists(filename)
assert exists
read_content = sandbox.files.read(filename)
assert read_content == content
if debug:
sandbox.files.remove(filename)
def test_write_binary_file(sandbox, debug):
filename = "test_write.bin"
text = "This is a test binary file."
# equivalent to `open("path/to/local/file", "rb")`
content = io.BytesIO(text.encode("utf-8"))
info = sandbox.files.write(filename, content)
assert info.path == f"/home/user/{filename}"
exists = sandbox.files.exists(filename)
assert exists
read_content = sandbox.files.read(filename)
assert read_content == text
if debug:
sandbox.files.remove(filename)
def test_write_multiple_files(sandbox, debug):
num_test_files = 10
# Attempt to write with empty files array
empty_info = sandbox.files.write_files([])
assert isinstance(empty_info, list)
assert len(empty_info) == 0
# Attempt to write with no files
assert sandbox.files.write_files([]) == []
# Attempt to write with one file in array
one_file_path = "one_test_file.txt"
info = sandbox.files.write_files(
[WriteEntry(path=one_file_path, data="This is a test file.")]
)
assert isinstance(info, list)
assert len(info) == 1
info = info[0]
assert isinstance(info, WriteInfo)
assert info.path == "/home/user/one_test_file.txt"
exists = sandbox.files.exists(info.path)
assert exists
read_content = sandbox.files.read(info.path)
assert read_content == "This is a test file."
# Attempt to write with multiple files in array
files = []
for i in range(num_test_files):
path = f"test_write_{i}.txt"
content = f"This is a test file {i}."
files.append(WriteEntry(path=path, data=content))
infos = sandbox.files.write_files(files)
assert isinstance(infos, list)
assert len(infos) == len(files)
for i, info in enumerate(infos):
assert isinstance(info, WriteInfo)
assert info.path == f"/home/user/test_write_{i}.txt"
assert info.type == FileType.FILE
exists = sandbox.files.exists(path)
assert exists
read_content = sandbox.files.read(info.path)
assert read_content == files[i]["data"]
if debug:
sandbox.files.remove(one_file_path)
for i in range(num_test_files):
sandbox.files.remove(f"test_write_{i}.txt")
def test_overwrite_file(sandbox, debug):
filename = "test_overwrite.txt"
initial_content = "Initial content."
new_content = "New content."
sandbox.files.write(filename, initial_content)
sandbox.files.write(filename, new_content)
read_content = sandbox.files.read(filename)
assert read_content == new_content
if debug:
sandbox.files.remove(filename)
def test_write_to_non_existing_directory(sandbox, debug):
filename = "non_existing_dir/test_write.txt"
content = "This should succeed too."
sandbox.files.write(filename, content)
exists = sandbox.files.exists(filename)
assert exists
read_content = sandbox.files.read(filename)
assert read_content == content
if debug:
sandbox.files.remove(filename)
def test_write_with_secured_envd(sandbox_factory):
filename = f"non_existing_dir_{uuid.uuid4()}/test_write.txt"
content = "This should succeed too."
sbx = sandbox_factory(timeout=30, secure=True)
assert sbx.is_running()
assert sbx._envd_version is not None
assert sbx._envd_access_token is not None
sbx.files.write(filename, content)
exists = sbx.files.exists(filename)
assert exists
read_content = sbx.files.read(filename)
assert read_content == content
def test_write_files_with_different_data_types(sandbox, debug):
text_data = "Text string data"
bytes_data = b"Bytes data"
bytes_io_data = io.BytesIO(b"BytesIO data")
string_io_data = io.StringIO("StringIO data")
files = [
WriteEntry(path="writefiles_text.txt", data=text_data),
WriteEntry(path="writefiles_bytes.bin", data=bytes_data),
WriteEntry(path="writefiles_bytesio.bin", data=bytes_io_data),
WriteEntry(path="writefiles_stringio.txt", data=string_io_data),
]
infos = sandbox.files.write_files(files)
assert len(infos) == 4
text_content = sandbox.files.read("writefiles_text.txt")
assert text_content == text_data
bytes_content = sandbox.files.read("writefiles_bytes.bin")
assert bytes_content == "Bytes data"
bytes_io_content = sandbox.files.read("writefiles_bytesio.bin")
assert bytes_io_content == "BytesIO data"
string_io_content = sandbox.files.read("writefiles_stringio.txt")
assert string_io_content == "StringIO data"
if debug:
for file in files:
sandbox.files.remove(file["path"])
def test_write_io_with_octet_stream(sandbox, debug):
filename = "test_write_octet_io.bin"
text = "Streamed octet-stream upload. " * 10_000
content = io.BytesIO(text.encode("utf-8"))
info = sandbox.files.write(filename, content, use_octet_stream=True)
assert info.path == f"/home/user/{filename}"
read_content = sandbox.files.read(filename)
assert read_content == text
if debug:
sandbox.files.remove(filename)
def test_write_text_io_with_octet_stream(sandbox, debug):
filename = "test_write_octet_text_io.txt"
text = "Streamed text octet-stream upload."
sandbox.files.write(filename, io.StringIO(text), use_octet_stream=True)
read_content = sandbox.files.read(filename)
assert read_content == text
if debug:
sandbox.files.remove(filename)
def test_write_io_with_octet_stream_and_gzip(sandbox, debug):
filename = "test_write_octet_io_gzip.bin"
text = "Streamed gzipped octet-stream upload. " * 10_000
content = io.BytesIO(text.encode("utf-8"))
sandbox.files.write(filename, content, use_octet_stream=True, gzip=True)
read_content = sandbox.files.read(filename)
assert read_content == text
if debug:
sandbox.files.remove(filename)
@@ -0,0 +1,17 @@
from e2b import Sandbox
from e2b.sandbox.commands.command_handle import PtySize
def test_pty(sandbox: Sandbox):
def append_data(data: list, x: bytes):
data.append(x.decode("utf-8"))
terminal = sandbox.pty.create(PtySize(80, 24), envs={"ABC": "123"}, cwd="/")
sandbox.pty.send_stdin(terminal.pid, b"echo $ABC\nexit\n")
output = []
result = terminal.wait(on_pty=lambda x: append_data(output, x))
assert result.exit_code == 0
assert "123" in "\n".join(output)
@@ -0,0 +1,30 @@
import pytest
from e2b.sandbox.commands.command_handle import PtySize
@pytest.mark.skip_debug()
def test_connect_to_pty(sandbox_factory):
sandbox = sandbox_factory(timeout=100)
output = []
def append_data(data: list, x: bytes):
data.append(x.decode("utf-8"))
terminal = sandbox.pty.create(PtySize(80, 24), envs={"FOO": "bar"})
sandbox.pty.send_stdin(terminal.pid, b"echo $FOO\n")
terminal.disconnect()
# Now connect again, with a new on_pty handler
reconnect_handle = sandbox.pty.connect(terminal.pid)
sandbox.pty.send_stdin(terminal.pid, b"echo $FOO\nexit\n")
result = reconnect_handle.wait(on_pty=lambda x: append_data(output, x))
assert terminal.pid == reconnect_handle.pid
assert result.exit_code == 0
assert "bar" in "".join(output)
@@ -0,0 +1,20 @@
import pytest
from e2b import Sandbox, CommandExitException
from e2b.sandbox.commands.command_handle import PtySize
def test_kill_pty(sandbox: Sandbox):
terminal = sandbox.pty.create(PtySize(80, 24))
assert sandbox.pty.kill(terminal.pid)
# The PTY process should no longer be running.
with pytest.raises(CommandExitException):
sandbox.commands.run(f"kill -0 {terminal.pid}")
def test_kill_non_existing_pty(sandbox: Sandbox):
non_existing_pid = 999999
assert not sandbox.pty.kill(non_existing_pid)
@@ -0,0 +1,28 @@
from e2b import Sandbox
from e2b.sandbox.commands.command_handle import PtySize
def test_resize(sandbox: Sandbox):
def append_data(data: list, x: bytes):
data.append(x.decode("utf-8"))
terminal = sandbox.pty.create(PtySize(cols=80, rows=24))
sandbox.pty.send_stdin(terminal.pid, b"tput cols\nexit\n")
output = []
result = terminal.wait(on_pty=lambda x: append_data(output, x))
assert result.exit_code == 0
assert "80" in "".join(output)
terminal = sandbox.pty.create(PtySize(cols=80, rows=24))
sandbox.pty.resize(terminal.pid, PtySize(cols=100, rows=24))
sandbox.pty.send_stdin(terminal.pid, b"tput cols\nexit\n")
output = []
result = terminal.wait(on_pty=lambda x: append_data(output, x))
assert result.exit_code == 0
assert "100" in "".join(output)
@@ -0,0 +1,9 @@
from e2b import Sandbox
from e2b.sandbox.commands.command_handle import PtySize
def test_send_input(sandbox: Sandbox):
terminal = sandbox.pty.create(PtySize(cols=80, rows=24))
sandbox.pty.send_stdin(terminal.pid, b"exit\n")
result = terminal.wait()
assert result.exit_code == 0
@@ -0,0 +1,115 @@
from unittest.mock import Mock
import pytest
from packaging.version import Version
from e2b import Sandbox
from e2b.api import SandboxCreateResponse
from e2b.connection_config import ConnectionConfig
import e2b.sandbox_sync.main as sandbox_sync_main
BASE_DOMAIN = "base.e2b.dev"
BASE_REQUEST_TIMEOUT = 11
BASE_DEBUG = False
BASE_HEADERS = {"X-Test": "base"}
def create_sandbox(monkeypatch, api_key: str) -> Sandbox:
monkeypatch.setattr(
sandbox_sync_main, "Filesystem", lambda *args, **kwargs: object()
)
monkeypatch.setattr(sandbox_sync_main, "Commands", lambda *args, **kwargs: object())
monkeypatch.setattr(sandbox_sync_main, "Pty", lambda *args, **kwargs: object())
monkeypatch.setattr(sandbox_sync_main, "Git", lambda *args, **kwargs: object())
return Sandbox(
sandbox_id="sbx-test",
sandbox_domain="sandbox.e2b.dev",
envd_version=Version("0.2.4"),
envd_access_token="tok",
traffic_access_token="tok",
connection_config=ConnectionConfig(
api_key=api_key,
domain=BASE_DOMAIN,
request_timeout=BASE_REQUEST_TIMEOUT,
debug=BASE_DEBUG,
api_headers=BASE_HEADERS,
),
)
@pytest.mark.skip_debug()
def test_pause_passes_connection_config_without_overrides(monkeypatch, test_api_key):
mock_pause = Mock(return_value="sbx-test")
monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_pause", mock_pause)
sandbox = create_sandbox(monkeypatch, test_api_key)
sandbox.pause()
mock_pause.assert_called_once()
assert mock_pause.call_args.kwargs["sandbox_id"] == "sbx-test"
assert mock_pause.call_args.kwargs["api_key"] == test_api_key
assert mock_pause.call_args.kwargs["domain"] == BASE_DOMAIN
assert mock_pause.call_args.kwargs["request_timeout"] == BASE_REQUEST_TIMEOUT
assert mock_pause.call_args.kwargs["debug"] == BASE_DEBUG
assert mock_pause.call_args.kwargs["headers"]["X-Test"] == BASE_HEADERS["X-Test"]
@pytest.mark.skip_debug()
def test_pause_applies_overrides(monkeypatch, test_api_key):
mock_pause = Mock(return_value="sbx-test")
monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_pause", mock_pause)
sandbox = create_sandbox(monkeypatch, test_api_key)
sandbox.pause(
domain="override.e2b.dev",
request_timeout=20,
api_headers={"X-Extra": "1"},
)
mock_pause.assert_called_once()
assert mock_pause.call_args.kwargs["sandbox_id"] == "sbx-test"
assert mock_pause.call_args.kwargs["api_key"] == test_api_key
assert mock_pause.call_args.kwargs["domain"] == "override.e2b.dev"
assert mock_pause.call_args.kwargs["request_timeout"] == 20
assert mock_pause.call_args.kwargs["debug"] == BASE_DEBUG
assert mock_pause.call_args.kwargs["headers"]["X-Test"] == BASE_HEADERS["X-Test"]
assert mock_pause.call_args.kwargs["headers"]["X-Extra"] == "1"
@pytest.mark.skip_debug()
def test_connect_sets_stable_host_routing_headers(monkeypatch, test_api_key):
mock_connect = Mock(
return_value=SandboxCreateResponse(
sandbox_id="sbx-test",
sandbox_domain="e2b.app",
envd_version="0.4.0",
envd_access_token="tok",
traffic_access_token="traffic",
)
)
monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect)
monkeypatch.setattr(ConnectionConfig, "_integration", "testing/version")
config = ConnectionConfig(
api_key=test_api_key,
headers=BASE_HEADERS,
)
sandbox = Sandbox.connect(
"sbx-test",
**config.get_api_params(),
)
assert sandbox.envd_api_url == "https://sandbox.e2b.app"
assert "X-Test" not in sandbox.connection_config.sandbox_headers
assert sandbox.connection_config.sandbox_headers["User-Agent"].startswith(
"e2b-python-sdk/"
)
assert sandbox.connection_config.sandbox_headers["User-Agent"].endswith(
" testing/version"
)
assert sandbox.connection_config.sandbox_headers["E2b-Sandbox-Id"] == "sbx-test"
assert sandbox.connection_config.sandbox_headers["E2b-Sandbox-Port"] == str(
ConnectionConfig.envd_port
)
assert sandbox.connection_config.sandbox_headers["X-Access-Token"] == "tok"
@@ -0,0 +1,136 @@
import uuid
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from e2b import Sandbox
from e2b.api.client.api.sandboxes import post_sandboxes_sandbox_id_connect
from e2b.api.client.models import Sandbox as SandboxModel
import e2b.sandbox_sync.main as sandbox_sync_main
@pytest.mark.skip_debug()
def test_connect(sandbox_factory):
sbx = sandbox_factory(timeout=10)
assert sbx.is_running()
sbx_connection = Sandbox.connect(sbx.sandbox_id)
assert sbx_connection.is_running()
@pytest.mark.skip_debug()
def test_connect_with_secure(sandbox_factory):
dir_name = f"test_directory_{uuid.uuid4()}"
sbx = sandbox_factory(timeout=10, secure=True)
assert sbx.is_running()
sbx_connection = Sandbox.connect(sbx.sandbox_id)
sbx_connection.files.make_dir(dir_name)
files = sbx_connection.files.list(dir_name)
assert len(files) == 0
@pytest.mark.skip_debug()
def test_connect_to_paused_sandbox_resumes(sandbox):
sandbox.pause()
assert not sandbox.is_running()
resumed = Sandbox.connect(sandbox.sandbox_id)
assert resumed.is_running()
@pytest.mark.skip_debug()
def test_resume_does_not_shorten_timeout_on_running_sandbox(sandbox_factory):
# Create sandbox with a 300 second timeout
sbx = sandbox_factory(timeout=300)
assert sbx.is_running()
# Get initial info to check end_at
info_before = Sandbox.get_info(sbx.sandbox_id)
# Connect with a shorter timeout (10 seconds)
Sandbox.connect(sbx.sandbox_id, timeout=10)
# Get info after connection
info_after = Sandbox.get_info(sbx.sandbox_id)
# The end_at time should not have been shortened. It should be the same
assert info_after.end_at == info_before.end_at, (
f"Timeout was shortened: before={info_before.end_at}, after={info_after.end_at}"
)
@pytest.mark.skip_debug()
def test_connect_extends_timeout_on_running_sandbox(sandbox):
# Get initial info to check end_at
info_before = sandbox.get_info()
# Connect with a longer timeout
Sandbox.connect(sandbox.sandbox_id, timeout=600)
# Get info after connection
info_after = sandbox.get_info()
# The end_at time should have been extended
assert info_after.end_at > info_before.end_at, (
f"Timeout was not extended: before={info_before.end_at}, after={info_after.end_at}"
)
def test_connect_in_debug_mode_does_not_call_api(monkeypatch, test_api_key):
mock_connect = Mock()
monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect)
sbx = Sandbox.connect("sbx-debug", debug=True, api_key=test_api_key)
mock_connect.assert_not_called()
assert sbx.sandbox_id == "sbx-debug"
assert sbx._envd_access_token is None
assert sbx.traffic_access_token is None
def test_connect_in_env_debug_mode_does_not_call_api(monkeypatch, test_api_key):
monkeypatch.setenv("E2B_DEBUG", "true")
mock_connect = Mock()
monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect)
sbx = Sandbox.connect("sbx-debug", api_key=test_api_key)
mock_connect.assert_not_called()
assert sbx.sandbox_id == "sbx-debug"
def test_instance_connect_in_debug_mode_does_not_call_api(monkeypatch, test_api_key):
mock_connect = Mock()
monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect)
sbx = Sandbox.connect("sbx-debug", debug=True, api_key=test_api_key)
assert sbx.connect() is sbx
mock_connect.assert_not_called()
def test_connect_normalizes_unset_tokens(monkeypatch, test_api_key):
# Tokens and domain are absent in the API response for non-secure sandboxes
model = SandboxModel(
client_id="client-id",
envd_version="0.2.4",
sandbox_id="sbx-test",
template_id="template-id",
)
mock_request = Mock(return_value=SimpleNamespace(status_code=200, parsed=model))
monkeypatch.setattr(
post_sandboxes_sandbox_id_connect, "sync_detailed", mock_request
)
sbx = Sandbox.connect("sbx-test", debug=False, api_key=test_api_key)
mock_request.assert_called_once()
assert sbx._envd_access_token is None
assert sbx.traffic_access_token is None
assert "signature" not in sbx.download_url("test.txt")
@@ -0,0 +1,192 @@
from time import sleep
from typing import Any, cast
import httpx
import pytest
from e2b import Sandbox, SandboxState
from e2b.api.client.models import (
NewSandbox,
SandboxAutoResumeConfig,
)
from e2b.exceptions import InvalidArgumentException
from e2b.sandbox.sandbox_api import SandboxQuery
@pytest.mark.skip_debug()
def test_start(sandbox_factory):
sbx = sandbox_factory(timeout=5)
assert sbx.is_running()
assert sbx._envd_version is not None
@pytest.mark.skip_debug()
def test_metadata(sandbox_factory):
sbx = sandbox_factory(timeout=5, metadata={"test-key": "test-value"})
paginator = Sandbox.list(query=SandboxQuery(metadata={"test-key": "test-value"}))
sandboxes = paginator.next_items()
for sbx_info in sandboxes:
if sbx.sandbox_id == sbx_info.sandbox_id:
assert sbx_info.metadata is not None
assert sbx_info.metadata["test-key"] == "test-value"
break
else:
assert False, "Sandbox not found"
def test_create_payload_serializes_auto_resume_enabled():
body = NewSandbox(
template_id="template-id",
auto_pause=True,
auto_resume=SandboxAutoResumeConfig(enabled=True),
)
assert body.to_dict()["autoPause"] is True
assert body.to_dict()["autoResume"] == {"enabled": True}
def test_create_payload_deserializes_auto_resume_enabled():
body = NewSandbox.from_dict(
{
"templateID": "template-id",
"autoPause": False,
"autoResume": {"enabled": False},
}
)
assert isinstance(body.auto_resume, SandboxAutoResumeConfig)
assert body.auto_resume.to_dict() == {"enabled": False}
@pytest.mark.skip_debug()
def test_filesystem_only_auto_pause_rejects_auto_resume():
# A filesystem-only auto-pause snapshot can only be resumed explicitly, so
# combining keep_memory=False with auto_resume is rejected client-side.
with pytest.raises(InvalidArgumentException):
Sandbox.create(
timeout=3,
lifecycle={
"on_timeout": {"action": "pause", "keep_memory": False},
"auto_resume": True,
},
)
@pytest.mark.skip_debug()
def test_keep_memory_not_allowed_with_kill():
# The discriminated union forbids keep_memory on action="kill" at type-check
# time; the runtime guard rejects it for callers that bypass the type
# (cast(Any, ...) feeds the deliberately type-invalid input).
with pytest.raises(InvalidArgumentException):
Sandbox.create(
timeout=3,
lifecycle=cast(
Any, {"on_timeout": {"action": "kill", "keep_memory": False}}
),
)
@pytest.mark.skip_debug()
def test_invalid_on_timeout_type_does_not_crash(sandbox_factory):
# An untyped/invalid on_timeout (e.g. None) must not crash create; it falls
# back to kill semantics like a missing on_timeout (the sandbox just starts).
sbx = sandbox_factory(timeout=10, lifecycle=cast(Any, {"on_timeout": None}))
assert sbx.is_running()
@pytest.mark.skip_debug()
def test_keep_memory_none_defaults_to_full_memory(sandbox_factory):
# An explicit None keep_memory must default to full memory (not filesystem-only):
# the timeout auto-pause then resumes the SAME sandbox in place (memory restore),
# so the boot id is unchanged. A changed boot id would mean None was wrongly
# treated as filesystem-only (cold boot).
sbx = sandbox_factory(
timeout=60,
lifecycle={"on_timeout": {"action": "pause", "keep_memory": None}},
)
boot_before = sbx.files.read("/proc/sys/kernel/random/boot_id").strip()
sbx.set_timeout(0) # force the timeout auto-pause now
for _ in range(150):
if not sbx.is_running():
break
sleep(0.2)
assert not sbx.is_running()
resumed = sbx.connect()
assert resumed.sandbox_id == sbx.sandbox_id # same sandbox
boot_after = resumed.files.read("/proc/sys/kernel/random/boot_id").strip()
assert boot_after == boot_before # memory restore in place, not a cold boot
@pytest.mark.skip_debug()
def test_auto_pause_filesystem_only_reboots(sandbox_factory):
# keep_memory=False makes the timeout auto-pause filesystem-only, so resuming
# cold-boots the sandbox from disk.
sandbox = sandbox_factory(
timeout=3,
lifecycle={"on_timeout": {"action": "pause", "keep_memory": False}},
)
marker = "auto-pause-fs-only"
sandbox.files.write("/home/user/auto-pause-marker.txt", marker)
boot_before = sandbox.files.read("/proc/sys/kernel/random/boot_id").strip()
sleep(5)
assert sandbox.get_info().state == SandboxState.PAUSED
# A filesystem-only snapshot cannot auto-resume on traffic; connect resumes
# it by cold-booting.
resumed = sandbox.connect()
persisted = resumed.files.read("/home/user/auto-pause-marker.txt").strip()
assert persisted == marker
boot_after = resumed.files.read("/proc/sys/kernel/random/boot_id").strip()
assert boot_after != boot_before
@pytest.mark.skip_debug()
def test_auto_pause_without_auto_resume_requires_connect(sandbox_factory):
sandbox = sandbox_factory(
timeout=3,
lifecycle={"on_timeout": "pause", "auto_resume": False},
)
sleep(5)
assert sandbox.get_info().state == SandboxState.PAUSED
assert not sandbox.is_running()
sandbox.connect()
assert sandbox.get_info().state == SandboxState.RUNNING
assert sandbox.is_running()
@pytest.mark.skip_debug()
def test_auto_resume_wakes_on_http_request(sandbox_factory):
sandbox = sandbox_factory(
timeout=3,
lifecycle={"on_timeout": "pause", "auto_resume": True},
)
cmd = sandbox.commands.run("python3 -m http.server 8000", background=True)
try:
sleep(5)
url = f"https://{sandbox.get_host(8000)}"
res = httpx.get(url, timeout=15.0)
assert res.status_code == 200
assert sandbox.get_info().state == SandboxState.RUNNING
assert sandbox.is_running()
finally:
try:
cmd.kill()
except Exception:
pass
@@ -0,0 +1,24 @@
import httpx
from time import sleep
def test_ping_server(sandbox, debug, helpers):
cmd = sandbox.commands.run("python -m http.server 8001", background=True)
try:
host = sandbox.get_host(8001)
status_code = None
for _ in range(20):
res = httpx.get(f"{'http' if debug else 'https'}://{host}")
status_code = res.status_code
if res.status_code == 200:
break
sleep(0.5)
assert status_code == 200
except Exception as e:
helpers.check_cmd_exit_error(cmd)
raise e
finally:
cmd.kill()
@@ -0,0 +1,42 @@
import pytest
from e2b.sandbox.commands.command_handle import CommandExitException
@pytest.mark.skip_debug()
def test_internet_access_enabled(sandbox_factory):
"""Test that sandbox with internet access enabled can reach external websites."""
sbx = sandbox_factory(allow_internet_access=True)
# Test internet connectivity by making a curl request to a reliable external site
result = sbx.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204"
)
assert result.exit_code == 0
assert result.stdout.strip() == "204"
@pytest.mark.skip_debug()
def test_internet_access_disabled(sandbox_factory):
"""Test that sandbox with internet access disabled cannot reach external websites."""
sbx = sandbox_factory(allow_internet_access=False)
# Test that internet connectivity is blocked by making a curl request
with pytest.raises(CommandExitException) as exc_info:
sbx.commands.run(
"curl --connect-timeout 3 --max-time 5 -Is https://connectivitycheck.gstatic.com/generate_204"
)
# The command should fail or timeout when internet access is disabled
assert exc_info.value.exit_code != 0
@pytest.mark.skip_debug()
def test_internet_access_default(sandbox):
"""Test that sandbox with default settings (no explicit allow_internet_access) has internet access."""
# Test internet connectivity by making a curl request to a reliable external site
result = sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204"
)
assert result.exit_code == 0
assert result.stdout.strip() == "204"
@@ -0,0 +1,16 @@
import pytest
from e2b import Sandbox, SandboxQuery, SandboxState
@pytest.mark.skip_debug()
def test_kill(sandbox: Sandbox, sandbox_test_id: str):
sandbox.kill()
paginator = Sandbox.list(
query=SandboxQuery(
state=[SandboxState.RUNNING], metadata={"sandbox_test_id": sandbox_test_id}
)
)
sandboxes = paginator.next_items()
assert sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes]
@@ -0,0 +1,62 @@
import datetime
import time
import pytest
@pytest.mark.skip_debug()
@pytest.mark.timeout(60)
def test_sbx_metrics(sandbox_factory) -> None:
sbx = sandbox_factory(timeout=60)
# Wait for the sandbox to have some metrics
metrics = []
for _ in range(60):
metrics = sbx.get_metrics()
if len(metrics) > 0:
break
time.sleep(0.5)
assert len(metrics) > 0
metric = metrics[0]
assert metric.cpu_count is not None
assert metric.cpu_used_pct is not None
assert metric.mem_used is not None
assert metric.mem_total is not None
assert metric.disk_used is not None
assert metric.disk_total is not None
@pytest.mark.skip_debug()
@pytest.mark.timeout(60)
def test_sbx_metrics_time_range(sandbox_factory) -> None:
start_time = datetime.datetime.now(datetime.timezone.utc)
sbx = sandbox_factory(timeout=60)
# Wait for the sandbox to have some metrics within the test's time window
metrics = []
end_time = start_time
for _ in range(60):
end_time = datetime.datetime.now(datetime.timezone.utc)
metrics = sbx.get_metrics(start=start_time, end=end_time)
if len(metrics) > 0:
break
time.sleep(0.5)
assert len(metrics) > 0
# All returned metrics must fall within the requested time range
# (10s slack - metric timestamps are aligned to collection buckets,
# currently 5s, and the query params are second-precision)
slack = 10
for metric in metrics:
assert metric.timestamp.timestamp() >= start_time.timestamp() - slack
assert metric.timestamp.timestamp() <= end_time.timestamp() + slack
# A time range from before the sandbox existed must return no metrics
metrics = sbx.get_metrics(
start=start_time - datetime.timedelta(hours=1),
end=start_time - datetime.timedelta(minutes=30),
)
assert len(metrics) == 0
@@ -0,0 +1,313 @@
import json
import time
import httpx
import pytest
from e2b import SandboxNetworkOpts
from e2b.sandbox.commands.command_handle import CommandExitException
def wait_for_status(
client: httpx.Client,
url: str,
status_code: int,
headers: dict[str, str] | None = None,
timeout: float = 15,
) -> httpx.Response:
deadline = time.monotonic() + timeout
response: httpx.Response | None = None
while time.monotonic() < deadline:
response = client.get(url, headers=headers, follow_redirects=True)
if response.status_code == status_code:
return response
time.sleep(1)
assert response is not None
return response
@pytest.mark.skip_debug()
def test_allow_specific_ip_with_deny_all(sandbox_factory):
"""Test that sandbox with denyOut all and allowOut creates a whitelist."""
sandbox = sandbox_factory(
network=SandboxNetworkOpts(
deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1"]
)
)
# Test that allowed IP works
result = sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1"
)
assert result.exit_code == 0
assert result.stdout.strip() == "301"
# Test that other IPs are denied
with pytest.raises(CommandExitException) as exc_info:
sandbox.commands.run(
"curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8"
)
assert exc_info.value.exit_code != 0
@pytest.mark.skip_debug()
def test_deny_specific_ip(sandbox_factory):
"""Test that sandbox with denyOut denies specified IP addresses."""
sandbox = sandbox_factory(network=SandboxNetworkOpts(deny_out=["8.8.8.8"]))
# Test that denied IP fails
with pytest.raises(CommandExitException) as exc_info:
sandbox.commands.run(
"curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8"
)
assert exc_info.value.exit_code != 0
# Test that other IPs work
result = sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1"
)
assert result.exit_code == 0
assert result.stdout.strip() == "301"
@pytest.mark.skip_debug()
def test_deny_all_traffic(sandbox_factory):
"""Test that sandbox can deny all traffic using the all_traffic selector."""
sandbox = sandbox_factory(
network=SandboxNetworkOpts(deny_out=lambda ctx: [ctx.all_traffic]), timeout=30
)
# Test that all traffic is denied
with pytest.raises(CommandExitException) as exc_info:
sandbox.commands.run(
"curl --connect-timeout 3 --max-time 5 -Is https://1.1.1.1"
)
assert exc_info.value.exit_code != 0
with pytest.raises(CommandExitException) as exc_info:
sandbox.commands.run(
"curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8"
)
assert exc_info.value.exit_code != 0
@pytest.mark.skip_debug()
def test_allow_takes_precedence_over_deny(sandbox_factory):
"""Test that allowOut takes precedence over denyOut."""
sandbox = sandbox_factory(
network=SandboxNetworkOpts(
deny_out=lambda ctx: [ctx.all_traffic], allow_out=["1.1.1.1", "8.8.8.8"]
)
)
# Test that 1.1.1.1 works (explicitly allowed)
result1 = sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1"
)
assert result1.exit_code == 0
assert result1.stdout.strip() == "301"
# Test that 8.8.8.8 also works (explicitly allowed, takes precedence over deny_out)
result2 = sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8"
)
assert result2.exit_code == 0
assert result2.stdout.strip() == "302"
@pytest.mark.skip_debug()
def test_allow_public_traffic_false(sandbox_factory):
"""Test that sandbox with allow_public_traffic=False requires traffic access token."""
sandbox = sandbox_factory(
secure=True, network=SandboxNetworkOpts(allow_public_traffic=False)
)
# Verify the sandbox was created successfully and has a traffic access token
assert sandbox.traffic_access_token is not None
# Start a simple HTTP server in the sandbox
port = 8080
sandbox.commands.run(
f"python3 -m http.server {port}",
background=True,
)
# Wait for server to start
time.sleep(3)
# Get the public URL for the sandbox
sandbox_url = f"https://{sandbox.get_host(port)}"
with httpx.Client() as client:
# Test 1: Request without traffic access token should fail with 403
response = client.get(sandbox_url, follow_redirects=True)
assert response.status_code == 403
# Test 2: Request with valid traffic access token should succeed
headers = {"e2b-traffic-access-token": sandbox.traffic_access_token}
response = wait_for_status(client, sandbox_url, 200, headers=headers)
assert response.status_code == 200
@pytest.mark.skip_debug()
def test_allow_public_traffic_true(sandbox_factory):
"""Test that sandbox with allow_public_traffic=True works without token."""
sandbox = sandbox_factory(network=SandboxNetworkOpts(allow_public_traffic=True))
# Start a simple HTTP server in the sandbox
port = 8080
sandbox.commands.run(
f"python3 -m http.server {port}",
background=True,
)
# Wait for server to start
time.sleep(3)
# Get the public URL for the sandbox
sandbox_url = f"https://{sandbox.get_host(port)}"
with httpx.Client() as client:
# Request without traffic access token should succeed (public access enabled)
response = wait_for_status(client, sandbox_url, 200)
assert response.status_code == 200
@pytest.mark.skip_debug()
def test_firewall_transform_injects_headers(sandbox_factory):
"""Test that a firewall rule with a transform injects headers into outbound requests."""
injected_header = "X-E2B-Test-Token"
injected_value = "e2b-transform-value-123"
network: SandboxNetworkOpts = {
"rules": {
"httpbin.e2b.team": [
{"transform": {"headers": {injected_header: injected_value}}},
],
},
}
sandbox = sandbox_factory(network=network)
result = sandbox.commands.run(
"curl -sS --max-time 10 https://httpbin.e2b.team/headers"
)
assert result.exit_code == 0
parsed = json.loads(result.stdout)
reflected = parsed["headers"].get(injected_header)
assert reflected == injected_value, (
f"expected httpbin to reflect {injected_header}={injected_value}, "
f"got headers: {parsed['headers']}"
)
@pytest.mark.skip_debug()
def test_update_network_applies_restrictions(sandbox_factory):
"""update_network can add egress restrictions to a running sandbox."""
sandbox = sandbox_factory()
# Baseline: 8.8.8.8 reachable.
before = sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8"
)
assert before.exit_code == 0
sandbox.update_network({"deny_out": ["8.8.8.8"]})
# 8.8.8.8 is now denied.
with pytest.raises(CommandExitException) as exc_info:
sandbox.commands.run(
"curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8"
)
assert exc_info.value.exit_code != 0
# Other destinations stay reachable.
result = sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1"
)
assert result.exit_code == 0
@pytest.mark.skip_debug()
def test_update_network_clears_existing_rules(sandbox_factory):
"""update_network replaces all egress rules; omitted fields are cleared."""
sandbox = sandbox_factory(
network=SandboxNetworkOpts(
deny_out=lambda ctx: [ctx.all_traffic],
allow_out=["1.1.1.1"],
)
)
# Baseline from create-time config: 8.8.8.8 denied.
with pytest.raises(CommandExitException):
sandbox.commands.run(
"curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8"
)
# Empty update clears allow_out / deny_out entirely.
sandbox.update_network({})
r1 = sandbox.commands.run("curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1")
assert r1.exit_code == 0
r2 = sandbox.commands.run("curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8")
assert r2.exit_code == 0
@pytest.mark.skip_debug()
def test_mask_request_host(sandbox_factory):
"""Test that mask_request_host modifies the Host header correctly."""
sandbox = sandbox_factory(
network=SandboxNetworkOpts(mask_request_host="custom-host.example.com:${PORT}"),
timeout=60,
)
import time
import httpx
port = 8080
output_file = "/tmp/headers.txt"
# Start a Python HTTP server that captures request headers and writes them to a file
sandbox.commands.run(
f"""python3 -c "
import http.server, json
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
with open('{output_file}', 'w') as f:
for k, v in self.headers.items():
f.write(k + ': ' + v + chr(10))
self.send_response(200)
self.end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(('', {port}), H).handle_request()
" """,
background=True,
)
time.sleep(2)
# Get the public URL for the sandbox
sandbox_url = f"https://{sandbox.get_host(port)}"
# Make a request from OUTSIDE the sandbox through the proxy
# The Host header should be modified according to mask_request_host
with httpx.Client() as client:
try:
client.get(sandbox_url, timeout=5.0)
except Exception:
pass
time.sleep(1)
# Read the captured headers from inside the sandbox
result = sandbox.commands.run(f"cat {output_file}")
# Verify the Host header was modified according to mask_request_host
assert "Host:" in result.stdout
assert "custom-host.example.com" in result.stdout
assert str(port) in result.stdout
@@ -0,0 +1,26 @@
import pytest
from e2b import Sandbox
@pytest.mark.skip_debug()
def test_start_secured(sandbox_factory):
sbx = sandbox_factory(timeout=5, secure=True)
assert sbx.is_running()
assert sbx._envd_version is not None
assert sbx._envd_access_token is not None
@pytest.mark.skip_debug()
def test_connect_to_secured(sandbox_factory):
sbx = sandbox_factory(timeout=5, secure=True)
assert sbx.is_running()
assert sbx._envd_version is not None
assert sbx._envd_access_token is not None
sbx_connection = Sandbox.connect(sbx.sandbox_id)
assert sbx_connection.is_running()
assert sbx_connection._envd_version is not None
assert sbx_connection._envd_access_token is not None
@@ -0,0 +1,15 @@
import pytest
from e2b import Sandbox
@pytest.mark.skip_debug()
def test_snapshot(sandbox: Sandbox):
assert sandbox.is_running()
sandbox.pause()
assert not sandbox.is_running()
resumed_sandbox = sandbox.connect()
assert sandbox.is_running()
assert resumed_sandbox.is_running()
assert resumed_sandbox.sandbox_id == sandbox.sandbox_id
@@ -0,0 +1,162 @@
import pytest
from e2b import Sandbox
@pytest.mark.skip_debug()
def test_create_snapshot(sandbox: Sandbox):
snapshot = sandbox.create_snapshot()
assert snapshot.snapshot_id
assert len(snapshot.snapshot_id) > 0
Sandbox.delete_snapshot(snapshot.snapshot_id)
@pytest.mark.skip_debug()
def test_create_sandbox_from_snapshot(sandbox: Sandbox):
test_content = "content from original sandbox"
sandbox.files.write("/home/user/test.txt", test_content)
snapshot = sandbox.create_snapshot()
try:
new_sandbox = Sandbox.create(snapshot.snapshot_id)
try:
content = new_sandbox.files.read("/home/user/test.txt")
assert content == test_content
finally:
new_sandbox.kill()
finally:
Sandbox.delete_snapshot(snapshot.snapshot_id)
@pytest.mark.skip_debug()
def test_create_multiple_sandboxes_from_snapshot(sandbox: Sandbox):
test_content = "shared snapshot content"
sandbox.files.write("/home/user/shared.txt", test_content)
snapshot = sandbox.create_snapshot()
try:
sandbox1 = Sandbox.create(snapshot.snapshot_id)
sandbox2 = Sandbox.create(snapshot.snapshot_id)
try:
content1 = sandbox1.files.read("/home/user/shared.txt")
content2 = sandbox2.files.read("/home/user/shared.txt")
assert content1 == test_content
assert content2 == test_content
sandbox1.files.write("/home/user/shared.txt", "modified in sandbox1")
modified_content = sandbox1.files.read("/home/user/shared.txt")
unchanged_content = sandbox2.files.read("/home/user/shared.txt")
assert modified_content == "modified in sandbox1"
assert unchanged_content == test_content
finally:
sandbox1.kill()
sandbox2.kill()
finally:
Sandbox.delete_snapshot(snapshot.snapshot_id)
@pytest.mark.skip_debug()
def test_list_snapshots(sandbox: Sandbox):
snapshot = sandbox.create_snapshot()
try:
paginator = Sandbox.list_snapshots()
assert paginator.has_next
snapshots = paginator.next_items()
assert isinstance(snapshots, list)
found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots)
assert found
finally:
Sandbox.delete_snapshot(snapshot.snapshot_id)
@pytest.mark.skip_debug()
def test_list_snapshots_for_sandbox(sandbox: Sandbox):
snapshot = sandbox.create_snapshot()
try:
paginator = Sandbox.list_snapshots(sandbox_id=sandbox.sandbox_id)
snapshots = paginator.next_items()
found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots)
assert found
finally:
Sandbox.delete_snapshot(snapshot.snapshot_id)
@pytest.mark.skip_debug()
def test_create_named_snapshot(sandbox: Sandbox, sandbox_test_id: str):
snapshot_name = f"snap-{sandbox_test_id}"
snapshot = sandbox.create_snapshot(name=snapshot_name)
try:
assert snapshot.snapshot_id
assert isinstance(snapshot.names, list)
assert len(snapshot.names) > 0
assert any(snapshot_name in n for n in snapshot.names)
finally:
Sandbox.delete_snapshot(snapshot.snapshot_id)
@pytest.mark.skip_debug()
def test_delete_snapshot(sandbox: Sandbox):
snapshot = sandbox.create_snapshot()
deleted = Sandbox.delete_snapshot(snapshot.snapshot_id)
assert deleted is True
deleted_again = Sandbox.delete_snapshot(snapshot.snapshot_id)
assert deleted_again is False
@pytest.mark.skip_debug()
def test_snapshot_preserves_filesystem(sandbox: Sandbox):
app_dir = "/home/user/app"
config_path = f"{app_dir}/config.json"
config_content = '{"env": "test"}'
data_path = f"{app_dir}/data.txt"
data_content = "important data"
sandbox.files.make_dir(app_dir)
sandbox.files.write(config_path, config_content)
sandbox.files.write(data_path, data_content)
snapshot = sandbox.create_snapshot()
try:
new_sandbox = Sandbox.create(snapshot.snapshot_id)
try:
dir_exists = new_sandbox.files.exists(app_dir)
assert dir_exists
config = new_sandbox.files.read(config_path)
data = new_sandbox.files.read(data_path)
assert config == config_content
assert data == data_content
finally:
new_sandbox.kill()
finally:
Sandbox.delete_snapshot(snapshot.snapshot_id)
@pytest.mark.skip_debug()
def test_create_snapshot_class_method(sandbox: Sandbox):
snapshot = Sandbox.create_snapshot(sandbox.sandbox_id)
assert snapshot.snapshot_id
assert len(snapshot.snapshot_id) > 0
Sandbox.delete_snapshot(snapshot.snapshot_id)
@@ -0,0 +1,29 @@
import pytest
from e2b import Sandbox
@pytest.mark.skip_debug()
def test_pause_filesystem_only(sandbox: Sandbox):
# A marker on the persisted rootfs and the kernel boot id before pausing.
sandbox.files.write("/home/user/fs-only-marker.txt", "persisted")
boot_before = sandbox.files.read("/proc/sys/kernel/random/boot_id").strip()
# Filesystem-only pause: only the rootfs is persisted, no memory snapshot.
assert sandbox.pause(keep_memory=False)
assert not sandbox.is_running()
# Resuming a filesystem-only snapshot cold-boots (reboots) from the rootfs.
resumed = sandbox.connect()
assert resumed.is_running()
assert resumed.sandbox_id == sandbox.sandbox_id
# connect() returns the same handle, and its credentials stay valid across
# the resume (the backend re-binds the same envd access token on the cold
# boot). The rootfs survives the reboot...
marker = resumed.files.read("/home/user/fs-only-marker.txt").strip()
assert marker == "persisted"
# ...while a fresh kernel boot id proves the guest cold-booted rather than
# being restored from a memory snapshot.
boot_after = resumed.files.read("/proc/sys/kernel/random/boot_id").strip()
assert boot_after != boot_before
@@ -0,0 +1,300 @@
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
import threading
from types import SimpleNamespace
from unittest.mock import Mock, sentinel
import httpx
from packaging.version import Version
from e2b.connection_config import ConnectionConfig
from e2b.sandbox_sync.commands.command import Commands
from e2b.sandbox_sync.commands.pty import Pty
from e2b.sandbox_sync.filesystem import filesystem as filesystem_sync
from e2b.sandbox_sync.filesystem.filesystem import Filesystem
from e2b.sandbox_sync.filesystem.watch_handle import WatchHandle
import e2b.sandbox_sync.commands.command as command_sync
import e2b.sandbox_sync.commands.pty as pty_sync
import e2b.sandbox_sync.main as sandbox_sync_main
ENVD_API_URL = "https://sandbox.e2b.app"
ENVD_VERSION = Version("0.6.2")
def run_in_worker_thread(fn):
with ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(fn).result()
def fake_transport():
return SimpleNamespace(pool=object())
def test_sync_sandbox_envd_api_is_bound_per_calling_thread(monkeypatch, test_api_key):
config = ConnectionConfig(api_key=test_api_key)
main_api = Mock(spec=httpx.Client)
filesystem = SimpleNamespace(_envd_api=main_api)
monkeypatch.setattr(
sandbox_sync_main, "Filesystem", lambda *args, **kwargs: filesystem
)
monkeypatch.setattr(sandbox_sync_main, "Commands", lambda *args, **kwargs: object())
monkeypatch.setattr(sandbox_sync_main, "Pty", lambda *args, **kwargs: object())
monkeypatch.setattr(sandbox_sync_main, "Git", lambda *args, **kwargs: object())
sandbox = sandbox_sync_main.Sandbox(
sandbox_id="sbx-test",
sandbox_domain="e2b.app",
envd_version=ENVD_VERSION,
envd_access_token="tok",
traffic_access_token="tok",
connection_config=config,
)
assert sandbox._envd_api is main_api
assert sandbox._envd_api is sandbox.files._envd_api
assert not hasattr(sandbox, "_transport")
def test_sync_sandbox_clients_are_created_once_per_calling_thread(
monkeypatch, test_api_key
):
config = ConnectionConfig(api_key=test_api_key)
created = []
transports = {}
lock = threading.Lock()
def get_transport(*_args, **_kwargs):
thread_id = threading.get_ident()
with lock:
transport = transports.get(thread_id)
if transport is None:
transport = fake_transport()
transports[thread_id] = transport
return transport
def record(kind):
with lock:
created.append((kind, threading.get_ident()))
class FakeHttpClient:
def __init__(self, *args, **kwargs):
self.transport = kwargs["transport"]
record("http")
class FakeFilesystemClient:
def __init__(self, *args, **kwargs):
self.pool = kwargs["pool"]
record("filesystem_rpc")
class FakeProcessClient:
def __init__(self, *args, **kwargs):
self.pool = kwargs["pool"]
record("process_rpc")
monkeypatch.setattr(filesystem_sync, "get_envd_transport", get_transport)
monkeypatch.setattr(command_sync, "get_envd_transport", get_transport)
monkeypatch.setattr(pty_sync, "get_envd_transport", get_transport)
monkeypatch.setattr(filesystem_sync.httpx, "Client", FakeHttpClient)
monkeypatch.setattr(
filesystem_sync.filesystem_connect, "FilesystemClient", FakeFilesystemClient
)
monkeypatch.setattr(
command_sync.process_connect, "ProcessClient", FakeProcessClient
)
monkeypatch.setattr(pty_sync.process_connect, "ProcessClient", FakeProcessClient)
main_thread_id = threading.get_ident()
sandbox = sandbox_sync_main.Sandbox(
sandbox_id="sbx-test",
sandbox_domain="e2b.app",
envd_version=ENVD_VERSION,
envd_access_token="tok",
traffic_access_token="tok",
connection_config=config,
)
assert Counter(kind for kind, thread in created if thread == main_thread_id) == {}
worker_count = 10
barrier = threading.Barrier(worker_count + 1)
def worker():
barrier.wait()
envd_api = sandbox._envd_api
files_envd_api = sandbox.files._envd_api
files_rpc = sandbox.files._rpc
commands_rpc = sandbox.commands._rpc
pty_rpc = sandbox.pty._rpc
return {
"thread": threading.get_ident(),
"same_envd_api": envd_api is files_envd_api,
"stable": (
envd_api is sandbox._envd_api
and files_envd_api is sandbox.files._envd_api
and files_rpc is sandbox.files._rpc
and commands_rpc is sandbox.commands._rpc
and pty_rpc is sandbox.pty._rpc
),
}
with ThreadPoolExecutor(max_workers=worker_count) as executor:
futures = [executor.submit(worker) for _ in range(worker_count)]
barrier.wait()
results = [future.result() for future in futures]
worker_threads = {result["thread"] for result in results}
worker_created = [
(kind, thread) for kind, thread in created if thread in worker_threads
]
assert Counter(result["same_envd_api"] for result in results) == {
True: worker_count
}
assert Counter(result["stable"] for result in results) == {True: worker_count}
assert Counter(kind for kind, _thread in worker_created) == {
"http": worker_count,
"filesystem_rpc": worker_count,
"process_rpc": worker_count * 2,
}
def test_sync_filesystem_envd_clients_are_bound_per_calling_thread(
monkeypatch, test_api_key
):
config = ConnectionConfig(api_key=test_api_key)
main_thread_id = threading.get_ident()
main_transport = fake_transport()
worker_transport = fake_transport()
main_api = Mock(spec=httpx.Client)
worker_api = Mock(spec=httpx.Client)
main_rpc = sentinel.main_filesystem_rpc
worker_rpc = sentinel.worker_filesystem_rpc
monkeypatch.setattr(
filesystem_sync,
"get_envd_transport",
lambda *_args, **_kwargs: main_transport
if threading.get_ident() == main_thread_id
else worker_transport,
)
monkeypatch.setattr(
filesystem_sync.httpx,
"Client",
lambda *args, **kwargs: main_api
if kwargs["transport"] is main_transport
else worker_api,
)
monkeypatch.setattr(
filesystem_sync.filesystem_connect,
"FilesystemClient",
lambda *args, **kwargs: main_rpc
if kwargs["pool"] is main_transport.pool
else worker_rpc,
)
fs = Filesystem(
ENVD_API_URL,
ENVD_VERSION,
config,
)
assert fs._envd_api is main_api
assert fs._rpc is main_rpc
worker_api_result, worker_rpc_result = run_in_worker_thread(
lambda: (fs._envd_api, fs._rpc)
)
assert worker_api_result is worker_api
assert worker_rpc_result is worker_rpc
assert fs._envd_api is main_api
assert fs._rpc is main_rpc
def test_sync_command_rpc_clients_are_bound_per_calling_thread(
monkeypatch, test_api_key
):
config = ConnectionConfig(api_key=test_api_key)
main_thread_id = threading.get_ident()
main_transport = fake_transport()
worker_transport = fake_transport()
main_rpc = sentinel.main_command_rpc
worker_rpc = sentinel.worker_command_rpc
monkeypatch.setattr(
command_sync,
"get_envd_transport",
lambda *_args, **_kwargs: main_transport
if threading.get_ident() == main_thread_id
else worker_transport,
)
monkeypatch.setattr(
command_sync.process_connect,
"ProcessClient",
lambda *args, **kwargs: main_rpc
if kwargs["pool"] is main_transport.pool
else worker_rpc,
)
commands = Commands(ENVD_API_URL, config, ENVD_VERSION)
assert commands._rpc is main_rpc
worker_rpc_result = run_in_worker_thread(lambda: commands._rpc)
assert worker_rpc_result is worker_rpc
assert commands._rpc is main_rpc
def test_sync_pty_rpc_clients_are_bound_per_calling_thread(monkeypatch, test_api_key):
config = ConnectionConfig(api_key=test_api_key)
main_thread_id = threading.get_ident()
main_transport = fake_transport()
worker_transport = fake_transport()
main_rpc = sentinel.main_pty_rpc
worker_rpc = sentinel.worker_pty_rpc
monkeypatch.setattr(
pty_sync,
"get_envd_transport",
lambda *_args, **_kwargs: main_transport
if threading.get_ident() == main_thread_id
else worker_transport,
)
monkeypatch.setattr(
pty_sync.process_connect,
"ProcessClient",
lambda *args, **kwargs: main_rpc
if kwargs["pool"] is main_transport.pool
else worker_rpc,
)
pty = Pty(ENVD_API_URL, config, ENVD_VERSION)
assert pty._rpc is main_rpc
worker_rpc_result = run_in_worker_thread(lambda: pty._rpc)
assert worker_rpc_result is worker_rpc
assert pty._rpc is main_rpc
def test_sync_watch_handle_uses_calling_thread_rpc(test_api_key):
class FakeRpc:
def __init__(self, name):
self.name = name
self.calls = []
def remove_watcher(self, request, **opts):
self.calls.append(("remove", request.watcher_id))
config = ConnectionConfig(api_key=test_api_key)
main_rpc = FakeRpc("main")
worker_rpc = FakeRpc("worker")
handle = WatchHandle(lambda: main_rpc, "watcher-id", config, ENVD_VERSION)
handle.stop()
assert main_rpc.calls == [("remove", "watcher-id")]
handle = WatchHandle(lambda: worker_rpc, "watcher-id", config, ENVD_VERSION)
run_in_worker_thread(handle.stop)
assert worker_rpc.calls == [("remove", "watcher-id")]
@@ -0,0 +1,28 @@
from time import sleep
from datetime import datetime
import pytest
@pytest.mark.skip_debug()
def test_shorten_timeout(sandbox):
sandbox.set_timeout(5)
sleep(6)
is_running = sandbox.is_running(request_timeout=5)
assert is_running is False
@pytest.mark.skip_debug()
def test_shorten_then_lengthen_timeout(sandbox):
sandbox.set_timeout(5)
sleep(1)
sandbox.set_timeout(10)
sleep(6)
sandbox.is_running()
@pytest.mark.skip_debug()
def test_get_timeout(sandbox):
info = sandbox.get_info()
assert isinstance(info.end_at, datetime)
@@ -0,0 +1,11 @@
import os
import pytest
_DIR = os.path.dirname(os.path.abspath(__file__))
def pytest_collection_modifyitems(items):
for item in items:
if str(item.fspath).startswith(_DIR):
item.add_marker(pytest.mark.timeout(180))
@@ -0,0 +1,133 @@
import pytest
from e2b import Template
from e2b.template.types import InstructionType
@pytest.mark.skip_debug()
def test_from_dockerfile():
dockerfile = """FROM node:24
WORKDIR /app
COPY package.json .
RUN npm install
ENTRYPOINT ["sleep", "20"]"""
template = Template().from_dockerfile(dockerfile)
# base image
assert template._template._base_image == "node:24"
instructions = template._template._instructions
# Docker defaults
assert instructions[1]["type"] == InstructionType.WORKDIR
assert instructions[1]["args"][0] == "/"
# Instructions from Dockerfile
assert instructions[2]["type"] == InstructionType.WORKDIR
assert instructions[2]["args"][0] == "/app"
assert instructions[3]["type"] == InstructionType.COPY
assert instructions[3]["args"][0] == "package.json"
assert instructions[3]["args"][1] == "."
assert instructions[4]["type"] == InstructionType.RUN
assert instructions[4]["args"][0] == "npm install"
# E2B defaults appended
assert instructions[5]["type"] == InstructionType.USER
assert instructions[5]["args"][0] == "user"
# Start command
assert template._template._start_cmd == "sleep 20"
@pytest.mark.skip_debug()
def test_from_dockerfile_with_default_user_and_workdir():
dockerfile = "FROM node:24"
template = Template().from_dockerfile(dockerfile)
assert template._template._instructions[-2]["type"] == InstructionType.USER
assert template._template._instructions[-2]["args"][0] == "user"
assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR
assert template._template._instructions[-1]["args"][0] == "/home/user"
@pytest.mark.skip_debug()
def test_from_dockerfile_with_custom_user_and_workdir():
dockerfile = "FROM node:24\nUSER mish\nWORKDIR /home/mish"
template = Template().from_dockerfile(dockerfile)
assert template._template._instructions[-2]["type"] == InstructionType.USER
assert template._template._instructions[-2]["args"][0] == "mish"
assert template._template._instructions[-1]["type"] == InstructionType.WORKDIR
assert template._template._instructions[-1]["args"][0] == "/home/mish"
@pytest.mark.skip_debug()
def test_from_dockerfile_with_multi_source_copy():
dockerfile = """FROM node:24
COPY file1.txt file2.txt file3.txt /dest/"""
template = Template().from_dockerfile(dockerfile)
instructions = template._template._instructions
copy_instructions = [i for i in instructions if i["type"] == InstructionType.COPY]
assert len(copy_instructions) == 3
assert copy_instructions[0]["args"][0] == "file1.txt"
assert copy_instructions[0]["args"][1] == "/dest/"
assert copy_instructions[1]["args"][0] == "file2.txt"
assert copy_instructions[1]["args"][1] == "/dest/"
assert copy_instructions[2]["args"][0] == "file3.txt"
assert copy_instructions[2]["args"][1] == "/dest/"
@pytest.mark.skip_debug()
def test_from_dockerfile_with_multi_source_copy_chown():
dockerfile = """FROM node:24
COPY --chown=myuser:mygroup pkg.json pkg-lock.json /app/"""
template = Template().from_dockerfile(dockerfile)
instructions = template._template._instructions
copy_instructions = [i for i in instructions if i["type"] == InstructionType.COPY]
assert len(copy_instructions) == 2
assert copy_instructions[0]["args"][0] == "pkg.json"
assert copy_instructions[0]["args"][1] == "/app/"
assert copy_instructions[0]["args"][2] == "myuser:mygroup"
assert copy_instructions[1]["args"][0] == "pkg-lock.json"
assert copy_instructions[1]["args"][1] == "/app/"
assert copy_instructions[1]["args"][2] == "myuser:mygroup"
@pytest.mark.skip_debug()
def test_from_dockerfile_with_copy_chown():
dockerfile = """FROM node:24
COPY --chown=myuser:mygroup app.js /app/
COPY --chown=anotheruser config.json /config/"""
template = Template().from_dockerfile(dockerfile)
instructions = template._template._instructions
# First COPY instruction (after initial USER root and WORKDIR /)
copy_instruction1 = instructions[2]
assert copy_instruction1["type"] == InstructionType.COPY
assert copy_instruction1["args"][0] == "app.js"
assert copy_instruction1["args"][1] == "/app/"
assert copy_instruction1["args"][2] == "myuser:mygroup" # user from --chown
# Second COPY instruction
copy_instruction2 = instructions[3]
assert copy_instruction2["type"] == InstructionType.COPY
assert copy_instruction2["args"][0] == "config.json"
assert copy_instruction2["args"][1] == "/config/"
assert (
copy_instruction2["args"][2] == "anotheruser"
) # user from --chown (without group)
@@ -0,0 +1,32 @@
import pytest
from e2b import Template
@pytest.mark.skip_debug()
def test_make_symlink(build):
template = (
Template()
.from_image("ubuntu:22.04")
.skip_cache()
.make_symlink(".bashrc", ".bashrc.local")
.run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"')
)
build(template)
@pytest.mark.skip_debug()
def test_make_symlink_force(build):
template = (
Template()
.from_image("ubuntu:22.04")
.make_symlink(".bashrc", ".bashrc.local")
.skip_cache()
.make_symlink(
".bashrc", ".bashrc.local", force=True
) # Overwrite existing symlink
.run_cmd('test "$(readlink .bashrc.local)" = ".bashrc"')
)
build(template)
@@ -0,0 +1,40 @@
import pytest
from e2b import Template
@pytest.mark.skip_debug()
def test_run_command(build):
template = Template().from_image("ubuntu:22.04").skip_cache().run_cmd("ls -l")
build(template)
@pytest.mark.skip_debug()
def test_run_command_as_different_user(build):
template = (
Template()
.from_image("ubuntu:22.04")
.skip_cache()
.run_cmd('test "$(whoami)" = "root"', user="root")
)
build(template)
@pytest.mark.skip_debug()
def test_run_command_as_user_that_does_not_exist(build):
template = (
Template()
.from_image("ubuntu:22.04")
.skip_cache()
.run_cmd("whoami", user="root123")
)
with pytest.raises(Exception) as exc_info:
build(template)
assert (
"failed to run command 'whoami': command failed: unauthenticated: invalid username: 'root123'"
in str(exc_info.value)
)
@@ -0,0 +1,57 @@
import pytest
from e2b import Template
@pytest.mark.skip_debug()
def test_to_dockerfile():
template = (
Template()
.from_ubuntu_image("24.04")
.copy("README.md", "/app/README.md")
.run_cmd('echo "Hello, World!"')
)
dockerfile = Template.to_dockerfile(template)
expected_dockerfile = """FROM ubuntu:24.04
COPY README.md /app/README.md
RUN echo "Hello, World!"
"""
assert dockerfile == expected_dockerfile
@pytest.mark.skip_debug()
def test_to_dockerfile_with_options():
template = (
Template()
.from_ubuntu_image("24.04")
.copy("README.md", "/app/README.md", user="root")
.run_cmd('echo "Hello, World!"', user="root")
)
dockerfile = Template.to_dockerfile(template)
expected_dockerfile = """FROM ubuntu:24.04
COPY README.md /app/README.md
RUN echo "Hello, World!"
"""
assert dockerfile == expected_dockerfile
@pytest.mark.skip_debug()
def test_to_dockerfile_with_env_instructions():
template = (
Template()
.from_ubuntu_image("24.04")
.set_envs({"NODE_ENV": "production", "PORT": "8080"})
.set_envs({"DEBUG": "false"})
)
dockerfile = Template.to_dockerfile(template)
expected_dockerfile = """FROM ubuntu:24.04
ENV NODE_ENV=production PORT=8080
ENV DEBUG=false
"""
assert dockerfile == expected_dockerfile
@@ -0,0 +1,34 @@
import uuid
import pytest
from e2b import Template, wait_for_timeout
@pytest.mark.skip_debug()
@pytest.mark.timeout(10)
def test_build_in_background_should_start_build_and_return_info():
"""Test that build_in_background returns immediately without waiting for build to complete."""
template = (
Template()
.from_image("ubuntu:22.04")
.skip_cache()
.run_cmd("sleep 5") # Add a delay to ensure build takes time
.set_start_cmd('echo "Hello"', wait_for_timeout(10_000))
)
name = f"e2b-test:v1-{uuid.uuid4()}"
build_info = Template.build_in_background(
template,
name,
cpu_count=1,
memory_mb=1024,
)
# Should return quickly (within a few seconds), not wait for the full build
assert build_info is not None
# Verify the build is actually running
status = Template.get_build_status(build_info)
assert status.status.value == "building"
@@ -0,0 +1,92 @@
import tempfile
import pytest
import os
import shutil
from e2b import Template, wait_for_timeout, default_build_logger
@pytest.fixture(scope="module")
def setup_test_folder():
test_dir = tempfile.mkdtemp(prefix="python_sync_test_")
folder_path = os.path.join(test_dir, "folder")
os.makedirs(folder_path, exist_ok=True)
with open(os.path.join(folder_path, "test.txt"), "w") as f:
f.write("This is a test file.")
# Create relative symlink
symlink_path = os.path.join(folder_path, "symlink.txt")
if os.path.exists(symlink_path):
os.remove(symlink_path)
os.symlink("test.txt", symlink_path)
# Create absolute symlink
symlink2_path = os.path.join(folder_path, "symlink2.txt")
if os.path.exists(symlink2_path):
os.remove(symlink2_path)
os.symlink(os.path.join(folder_path, "test.txt"), symlink2_path)
# Create a symlink to a file that does not exist
symlink3_path = os.path.join(folder_path, "symlink3.txt")
if os.path.exists(symlink3_path):
os.remove(symlink3_path)
os.symlink("12345test.txt", symlink3_path)
yield test_dir
# Cleanup
shutil.rmtree(test_dir, ignore_errors=True)
@pytest.mark.skip_debug()
def test_build_template(build, setup_test_folder):
template = (
Template(file_context_path=setup_test_folder)
# using base image to avoid re-building ubuntu:22.04 image
.from_base_image()
.copy("folder/*", "folder", force_upload=True)
.run_cmd("cat folder/test.txt")
.set_workdir("/app")
.set_start_cmd("echo 'Hello, world!'", wait_for_timeout(10_000))
)
build(template, skip_cache=True, on_build_logs=default_build_logger())
@pytest.mark.skip_debug()
def test_build_template_from_base_template(build):
template = Template().from_template("base")
build(template, skip_cache=True, on_build_logs=default_build_logger())
@pytest.mark.skip_debug()
def test_build_template_with_symlinks(build, setup_test_folder):
template = (
Template(file_context_path=setup_test_folder)
.from_image("ubuntu:22.04")
.skip_cache()
.copy("folder/*", "folder", force_upload=True)
.run_cmd("cat folder/symlink.txt")
)
build(template)
@pytest.mark.skip_debug()
def test_build_template_with_resolve_symlinks(build, setup_test_folder):
template = (
Template(file_context_path=setup_test_folder)
.from_image("ubuntu:22.04")
.skip_cache()
.copy(
"folder/symlink.txt",
"folder/symlink.txt",
force_upload=True,
resolve_symlinks=True,
)
.run_cmd("cat folder/symlink.txt")
)
build(template)
@@ -0,0 +1,20 @@
import uuid
import pytest
from e2b import Template
@pytest.mark.skip_debug()
def test_check_base_template_name_exists():
"""Test that the base template name exists."""
exists = Template.exists("base")
assert exists is True
@pytest.mark.skip_debug()
def test_check_non_existing_name():
"""Test that a non-existing name returns False."""
non_existing_name = f"nonexistent-{uuid.uuid4()}"
exists = Template.exists(non_existing_name)
assert exists is False
@@ -0,0 +1,422 @@
import traceback
from types import SimpleNamespace
from typing import List, Optional
from uuid import uuid4
import pytest
import linecache
from e2b import Template, CopyItem, wait_for_timeout
from e2b.template.types import TemplateBuildStatus
import e2b.template_sync.main as template_sync_main
import e2b.template_sync.build_api as build_api_mod
non_existent_path = "nonexistent/path"
# map template alias -> failed step index
failure_map: dict[str, Optional[int]] = {
"from_image": 0,
"from_template": 0,
"from_dockerfile": 0,
"from_image_registry": 0,
"from_aws_registry": 0,
"from_gcp_registry": 0,
"copy": None,
"copy_items": None,
# multi-source copy produces two COPY instructions (steps 1 and 2),
# the run_cmd after it is step 3
"multi_source_copy_second_source": 2,
"multi_source_copy_next_step": 3,
"copy_items_second_item": 2,
"copy_items_next_step": 3,
"remove": 1,
"rename": 1,
"make_dir": 1,
"make_symlink": 1,
"run_cmd": 1,
"set_workdir": 1,
"set_user": 1,
"pip_install": 1,
"npm_install": 1,
"apt_install": 1,
"git_clone": 1,
"set_start_cmd": 1,
"add_mcp_server": None,
"beta_dev_container_prebuild": 1,
"beta_set_dev_container_start": 1,
}
@pytest.fixture(autouse=True)
def mock_template_build(monkeypatch):
def mock_request_build(
client, name: str, tags: Optional[List[str]], cpu_count: int, memory_mb: int
):
return SimpleNamespace(template_id=name, build_id=str(uuid4()), tags=tags or [])
def mock_trigger_build(client, template_id: str, build_id: str, template):
return None
def mock_get_file_upload_link(
client, template_id: str, files_hash: str, stack_trace=None
):
return SimpleNamespace(present=True, url=None)
def mock_get_build_status(
client, template_id: str, build_id: str, logs_offset: int
):
step = failure_map[template_id]
reason = SimpleNamespace(
message="Mocked API build error",
log_entries=[],
step=str(step) if step is not None else None,
)
return SimpleNamespace(
status=TemplateBuildStatus.ERROR,
log_entries=[],
reason=reason,
)
monkeypatch.setattr(template_sync_main, "request_build", mock_request_build)
monkeypatch.setattr(template_sync_main, "trigger_build", mock_trigger_build)
monkeypatch.setattr(
template_sync_main, "get_file_upload_link", mock_get_file_upload_link
)
monkeypatch.setattr(build_api_mod, "get_build_status", mock_get_build_status)
def _expect_to_throw_and_check_trace(func, expected_method: str):
try:
func()
assert False, "Expected Template.build to raise an exception"
except Exception as e: # noqa: BLE001 - we want to assert on the traceback regardless of type
tb = e.__traceback__
saw_this_file = False
saw_expected_method = False
while tb is not None:
traceback_file = tb.tb_frame.f_code.co_filename
if traceback_file == __file__:
saw_this_file = True
caller_line = linecache.getline(traceback_file, tb.tb_lineno)
if caller_line and f".{expected_method}(" in caller_line:
saw_expected_method = True
break
tb = tb.tb_next
assert saw_this_file, traceback.format_exc()
assert saw_expected_method, traceback.format_exc()
@pytest.mark.skip_debug()
def test_traces_on_from_image(build):
template = Template()
template = template.from_image("e2b.dev/this-image-does-not-exist")
_expect_to_throw_and_check_trace(
lambda: build(template, name="from_image", skip_cache=True), "from_image"
)
@pytest.mark.skip_debug()
def test_traces_on_from_template(build):
template = Template().from_template("this-template-does-not-exist")
_expect_to_throw_and_check_trace(
lambda: build(template, name="from_template", skip_cache=True), "from_template"
)
@pytest.mark.skip_debug()
def test_traces_on_from_dockerfile(build):
template = Template()
template = template.from_dockerfile("FROM ubuntu:22.04\nRUN nonexistent")
_expect_to_throw_and_check_trace(
lambda: build(template, name="from_dockerfile", skip_cache=True),
"from_dockerfile",
)
@pytest.mark.skip_debug()
def test_traces_on_from_image_registry(build):
template = Template()
template = template.from_image(
"registry.example.com/nonexistent:latest",
username="test",
password="test",
)
_expect_to_throw_and_check_trace(
lambda: build(template, name="from_image_registry", skip_cache=True),
"from_image",
)
@pytest.mark.skip_debug()
def test_traces_on_from_image_credentials():
_expect_to_throw_and_check_trace(
lambda: Template().from_image("ubuntu:22.04", username="user"),
"from_image",
)
@pytest.mark.skip_debug()
def test_traces_on_from_aws_registry(build):
template = Template()
template = template.from_aws_registry(
"123456789.dkr.ecr.us-east-1.amazonaws.com/nonexistent:latest",
access_key_id="test",
secret_access_key="test",
region="us-east-1",
)
_expect_to_throw_and_check_trace(
lambda: build(template, name="from_aws_registry"), "from_aws_registry"
)
@pytest.mark.skip_debug()
def test_traces_on_from_gcp_registry(build):
template = Template()
template = template.from_gcp_registry(
"gcr.io/nonexistent-project/nonexistent:latest",
service_account_json={
"type": "service_account",
},
)
_expect_to_throw_and_check_trace(
lambda: build(template, name="from_gcp_registry"), "from_gcp_registry"
)
@pytest.mark.skip_debug()
def test_traces_on_copy(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().copy(non_existent_path, non_existent_path)
_expect_to_throw_and_check_trace(lambda: build(template, name="copy"), "copy")
@pytest.mark.skip_debug()
def test_traces_on_copyItems(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().copy_items(
[CopyItem(src=non_existent_path, dest=non_existent_path)]
)
_expect_to_throw_and_check_trace(
lambda: build(template, name="copy_items"), "copy_items"
)
@pytest.mark.skip_debug()
def test_traces_on_second_source_of_multi_source_copy(build):
template = Template()
template = template.from_base_image()
template = template.copy(["test_stacktrace.py", "test_tags.py"], ".")
_expect_to_throw_and_check_trace(
lambda: build(template, name="multi_source_copy_second_source"), "copy"
)
@pytest.mark.skip_debug()
def test_traces_on_step_after_multi_source_copy(build):
template = Template()
template = template.from_base_image()
template = template.copy(["test_stacktrace.py", "test_tags.py"], ".")
template = template.run_cmd(f"cat {non_existent_path}")
_expect_to_throw_and_check_trace(
lambda: build(template, name="multi_source_copy_next_step"), "run_cmd"
)
@pytest.mark.skip_debug()
def test_traces_on_second_item_of_copy_items(build):
template = Template()
template = template.from_base_image()
template = template.copy_items(
[
CopyItem(src="test_stacktrace.py", dest="."),
CopyItem(src="test_tags.py", dest="."),
]
)
_expect_to_throw_and_check_trace(
lambda: build(template, name="copy_items_second_item"), "copy_items"
)
@pytest.mark.skip_debug()
def test_traces_on_step_after_copy_items(build):
template = Template()
template = template.from_base_image()
template = template.copy_items(
[
CopyItem(src="test_stacktrace.py", dest="."),
CopyItem(src="test_tags.py", dest="."),
]
)
template = template.run_cmd(f"cat {non_existent_path}")
_expect_to_throw_and_check_trace(
lambda: build(template, name="copy_items_next_step"), "run_cmd"
)
@pytest.mark.skip_debug()
def test_traces_on_copy_absolute_path():
_expect_to_throw_and_check_trace(
lambda: Template().from_base_image().copy("/absolute/path", "/absolute/path"),
"copy",
)
@pytest.mark.skip_debug()
def test_traces_on_copyItems_absolute_path():
_expect_to_throw_and_check_trace(
lambda: (
Template()
.from_base_image()
.copy_items([CopyItem(src="/absolute/path", dest="/absolute/path")])
),
"copy_items",
)
@pytest.mark.skip_debug()
def test_traces_on_remove(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().remove(non_existent_path)
_expect_to_throw_and_check_trace(lambda: build(template, name="remove"), "remove")
@pytest.mark.skip_debug()
def test_traces_on_rename(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().rename(non_existent_path, "/tmp/dest.txt")
_expect_to_throw_and_check_trace(lambda: build(template, name="rename"), "rename")
@pytest.mark.skip_debug()
def test_traces_on_make_dir(build):
template = Template()
template = template.from_base_image()
template = template.set_user("root").skip_cache().make_dir("/root/.bashrc")
_expect_to_throw_and_check_trace(
lambda: build(template, name="make_dir"), "make_dir"
)
@pytest.mark.skip_debug()
def test_traces_on_make_symlink(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().make_symlink(".bashrc", ".bashrc")
_expect_to_throw_and_check_trace(
lambda: build(template, name="make_symlink"), "make_symlink"
)
@pytest.mark.skip_debug()
def test_traces_on_run_cmd(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().run_cmd(f"cat {non_existent_path}")
_expect_to_throw_and_check_trace(lambda: build(template, name="run_cmd"), "run_cmd")
@pytest.mark.skip_debug()
def test_traces_on_set_workdir(build):
template = Template()
template = template.from_base_image()
template = template.set_user("root").skip_cache().set_workdir("/root/.bashrc")
_expect_to_throw_and_check_trace(
lambda: build(template, name="set_workdir"), "set_workdir"
)
@pytest.mark.skip_debug()
def test_traces_on_set_user(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().set_user("; exit 1")
_expect_to_throw_and_check_trace(
lambda: build(template, name="set_user"), "set_user"
)
@pytest.mark.skip_debug()
def test_traces_on_pip_install(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().pip_install("nonexistent-package")
_expect_to_throw_and_check_trace(
lambda: build(template, name="pip_install"), "pip_install"
)
@pytest.mark.skip_debug()
def test_traces_on_npm_install(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().npm_install("nonexistent-package")
_expect_to_throw_and_check_trace(
lambda: build(template, name="npm_install"), "npm_install"
)
@pytest.mark.skip_debug()
def test_traces_on_apt_install(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().apt_install("nonexistent-package")
_expect_to_throw_and_check_trace(
lambda: build(template, name="apt_install"), "apt_install"
)
@pytest.mark.skip_debug()
def test_traces_on_git_clone(build):
template = Template()
template = template.from_base_image()
template = template.skip_cache().git_clone("https://github.com/repo.git")
_expect_to_throw_and_check_trace(
lambda: build(template, name="git_clone"), "git_clone"
)
@pytest.mark.skip_debug()
def test_traces_on_set_start_cmd(build):
template = Template()
template = template.from_base_image()
template = template.set_start_cmd(
f"./{non_existent_path}", wait_for_timeout(10_000)
)
_expect_to_throw_and_check_trace(
lambda: build(template, name="set_start_cmd"), "set_start_cmd"
)
@pytest.mark.skip_debug()
def test_traces_on_add_mcp_server():
# needs mcp-gateway as base template, without it no mcp servers can be added
_expect_to_throw_and_check_trace(
lambda: Template().from_base_image().skip_cache().add_mcp_server("exa"),
"add_mcp_server",
)
@pytest.mark.skip_debug()
def test_traces_on_dev_container_prebuild(build):
template = Template()
template = template.from_template("devcontainer")
template = template.skip_cache().beta_dev_container_prebuild(non_existent_path)
_expect_to_throw_and_check_trace(
lambda: build(template, name="beta_dev_container_prebuild"),
"beta_dev_container_prebuild",
)
@pytest.mark.skip_debug()
def test_traces_on_set_dev_container_start(build):
template = Template()
template = template.from_template("devcontainer")
template = template.beta_set_dev_container_start(non_existent_path)
_expect_to_throw_and_check_trace(
lambda: build(template, name="beta_set_dev_container_start"),
"beta_set_dev_container_start",
)
@@ -0,0 +1,228 @@
import uuid
from datetime import datetime, timezone
from unittest.mock import Mock
import pytest
from e2b import TemplateTag, TemplateTagInfo, Template
from e2b.exceptions import TemplateException
import e2b.template_sync.main as template_sync_main
class TestAssignTags:
"""Tests for Template.assign_tags method."""
def test_assign_single_tag(self, monkeypatch):
"""Test assigning a single tag to a template."""
mock_assign_tags = Mock(
return_value=TemplateTagInfo(
build_id="00000000-0000-0000-0000-000000000000",
tags=["production"],
)
)
monkeypatch.setattr(
template_sync_main, "get_api_client", lambda *args, **kwargs: None
)
monkeypatch.setattr(template_sync_main, "assign_tags", mock_assign_tags)
result = Template.assign_tags("my-template:v1.0", "production")
assert result.build_id == "00000000-0000-0000-0000-000000000000"
assert "production" in result.tags
mock_assign_tags.assert_called_once()
_, target, tags = mock_assign_tags.call_args[0]
assert target == "my-template:v1.0"
assert tags == ["production"]
def test_assign_multiple_tags(self, monkeypatch):
"""Test assigning multiple tags to a template."""
mock_assign_tags = Mock(
return_value=TemplateTagInfo(
build_id="00000000-0000-0000-0000-000000000000",
tags=["production", "stable"],
)
)
monkeypatch.setattr(
template_sync_main, "get_api_client", lambda *args, **kwargs: None
)
monkeypatch.setattr(template_sync_main, "assign_tags", mock_assign_tags)
result = Template.assign_tags("my-template:v1.0", ["production", "stable"])
assert result.build_id == "00000000-0000-0000-0000-000000000000"
assert "production" in result.tags
assert "stable" in result.tags
mock_assign_tags.assert_called_once()
_, _, tags = mock_assign_tags.call_args[0]
assert tags == ["production", "stable"]
class TestRemoveTags:
"""Tests for Template.remove_tags method."""
def test_remove_single_tag(self, monkeypatch):
"""Test deleting a single tag from a template."""
mock_remove_tags = Mock(return_value=None)
monkeypatch.setattr(
template_sync_main, "get_api_client", lambda *args, **kwargs: None
)
monkeypatch.setattr(template_sync_main, "remove_tags", mock_remove_tags)
Template.remove_tags("my-template", "production")
mock_remove_tags.assert_called_once()
_, name, tags = mock_remove_tags.call_args[0]
assert name == "my-template"
assert tags == ["production"]
def test_remove_multiple_tags(self, monkeypatch):
"""Test deleting multiple tags from a template."""
mock_remove_tags = Mock(return_value=None)
monkeypatch.setattr(
template_sync_main, "get_api_client", lambda *args, **kwargs: None
)
monkeypatch.setattr(template_sync_main, "remove_tags", mock_remove_tags)
Template.remove_tags("my-template", ["production", "staging"])
mock_remove_tags.assert_called_once()
_, name, tags = mock_remove_tags.call_args[0]
assert name == "my-template"
assert tags == ["production", "staging"]
def test_remove_tags_error(self, monkeypatch):
"""Test that remove_tags raises an error for nonexistent template."""
mock_remove_tags = Mock(side_effect=TemplateException("Template not found"))
monkeypatch.setattr(
template_sync_main, "get_api_client", lambda *args, **kwargs: None
)
monkeypatch.setattr(template_sync_main, "remove_tags", mock_remove_tags)
with pytest.raises(TemplateException):
Template.remove_tags("nonexistent", ["tag"])
class TestGetTags:
"""Tests for Template.get_tags method."""
def test_get_tags(self, monkeypatch):
"""Test getting tags for a template."""
mock_get_template_tags = Mock(
return_value=[
TemplateTag(
tag="v1.0",
build_id="00000000-0000-0000-0000-000000000000",
created_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
),
TemplateTag(
tag="latest",
build_id="11111111-1111-1111-1111-111111111111",
created_at=datetime(2024, 1, 16, 12, 0, 0, tzinfo=timezone.utc),
),
]
)
monkeypatch.setattr(
template_sync_main, "get_api_client", lambda *args, **kwargs: None
)
monkeypatch.setattr(
template_sync_main, "get_template_tags", mock_get_template_tags
)
result = Template.get_tags("my-template")
assert len(result) == 2
assert result[0].tag == "v1.0"
assert result[0].build_id == "00000000-0000-0000-0000-000000000000"
assert isinstance(result[0].created_at, datetime)
assert result[1].tag == "latest"
mock_get_template_tags.assert_called_once()
def test_get_tags_error(self, monkeypatch):
"""Test that get_tags raises an error for nonexistent template."""
mock_get_template_tags = Mock(
side_effect=TemplateException("Template not found")
)
monkeypatch.setattr(
template_sync_main, "get_api_client", lambda *args, **kwargs: None
)
monkeypatch.setattr(
template_sync_main, "get_template_tags", mock_get_template_tags
)
with pytest.raises(TemplateException):
Template.get_tags("nonexistent")
# Integration tests
class TestTagsIntegration:
"""Integration tests for Template tags functionality."""
@pytest.mark.skip_debug()
def test_build_template_with_tags_assign_and_delete(self, build):
"""Test building a template with tags, assigning new tags, and deleting."""
template_name = "e2b-tags-test"
initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}"
# Build a template with initial tag
template = Template().from_base_image()
build_info = build(template, name=initial_tag)
assert build_info.build_id
assert build_info.template_id
# Assign additional tags
tag_info = Template.assign_tags(initial_tag, ["production", "latest"])
assert tag_info.build_id
# API returns just the tag portion, not the full alias:tag
assert "production" in tag_info.tags
assert "latest" in tag_info.tags
@pytest.mark.skip_debug()
def test_assign_single_tag_to_existing_template(self, build):
"""Test assigning a single tag (not array) to an existing template."""
template_name = "e2b-tags-test"
initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}"
template = Template().from_base_image()
build(template, name=initial_tag)
# Assign single tag (not array)
tag_info = Template.assign_tags(initial_tag, "stable")
assert tag_info.build_id
# API returns just the tag portion, not the full alias:tag
assert "stable" in tag_info.tags
@pytest.mark.skip_debug()
def test_rejects_invalid_tag_format_missing_alias(self, build):
"""Test that tag without alias (starts with colon) is rejected."""
template_name = "e2b-tags-test"
initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}"
template = Template().from_base_image()
build(template, name=initial_tag)
# Tag without alias (starts with colon) should be rejected
with pytest.raises(Exception):
Template.assign_tags(initial_tag, ":invalid-tag")
@pytest.mark.skip_debug()
def test_rejects_invalid_tag_format_missing_tag(self, build):
"""Test that tag without tag portion (ends with colon) is rejected."""
template_name = "e2b-tags-test"
initial_tag = f"{template_name}:v1-{uuid.uuid4().hex}"
template = Template().from_base_image()
build(template, name=initial_tag)
# Tag without tag portion (ends with colon) should be rejected
with pytest.raises(Exception):
Template.assign_tags(initial_tag, f"{template_name}:")
@@ -0,0 +1,193 @@
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from unittest import mock
import httpx
from e2b.api.client.client import AuthenticatedClient
from e2b.template import utils as template_utils
from e2b.template.consts import FILE_UPLOAD_TIMEOUT_SECONDS
from e2b.template_sync.build_api import upload_file
# Regression test for e2b-dev/e2b#1243 — upload_file must set Content-Length
# and must not fall back to Transfer-Encoding: chunked. S3 presigned PUT URLs
# reject chunked encoding with 501 NotImplemented. The archive is streamed
# from a temporary file on disk with a known Content-Length instead of being
# buffered in memory; this test guards that contract.
def _make_server():
state = {"headers": None, "body_length": 0}
class Handler(BaseHTTPRequestHandler):
def do_PUT(self):
state["headers"] = dict(self.headers)
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b""
state["body_length"] = len(body)
self.send_response(200)
self.end_headers()
def log_message(self, *args, **kwargs):
return
server = HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread, state
def test_upload_file_sets_content_length_and_no_chunked_encoding(tmp_path):
(tmp_path / "hello.txt").write_text("hello world")
server, thread, state = _make_server()
host, port = server.server_address
url = f"http://{host}:{port}/upload"
try:
class UploadClient(AuthenticatedClient):
def get_httpx_client(self):
raise AssertionError("signed uploads should not use the API client")
client = UploadClient(base_url="http://test", token="test")
upload_file(
api_client=client,
file_name="*.txt",
context_path=str(tmp_path),
url=url,
ignore_patterns=[],
resolve_symlinks=False,
gzip=True,
stack_trace=None,
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
assert state["headers"] is not None
content_length = state["headers"].get("Content-Length")
assert content_length is not None
assert int(content_length) > 0
assert int(content_length) == state["body_length"]
transfer_encoding = state["headers"].get("Transfer-Encoding")
if transfer_encoding is not None:
assert "chunked" not in transfer_encoding.lower()
assert "Authorization" not in state["headers"]
def _capture_upload_timeout(tmp_path, request_timeout=None):
"""Run upload_file against a local server, capturing the httpx timeout."""
(tmp_path / "hello.txt").write_text("hello world")
server, thread, state = _make_server()
host, port = server.server_address
url = f"http://{host}:{port}/upload"
captured = {}
real_client = httpx.Client
def spy_client(*args, **kwargs):
captured["timeout"] = kwargs.get("timeout")
return real_client(*args, **kwargs)
try:
client = AuthenticatedClient(base_url="http://test", token="test")
kwargs = {}
if request_timeout is not None:
kwargs["request_timeout"] = request_timeout
with mock.patch(
"e2b.template_sync.build_api.httpx.Client", side_effect=spy_client
):
upload_file(
api_client=client,
file_name="*.txt",
context_path=str(tmp_path),
url=url,
ignore_patterns=[],
resolve_symlinks=False,
gzip=True,
stack_trace=None,
**kwargs,
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
return captured["timeout"]
def test_upload_file_defaults_to_one_hour_timeout(tmp_path):
# Uploads of large archives need far longer than the 60s general API
# timeout, so the default upload timeout is 1 hour (matches the JS SDK).
timeout = _capture_upload_timeout(tmp_path)
assert timeout == httpx.Timeout(FILE_UPLOAD_TIMEOUT_SECONDS)
def test_upload_file_honors_explicit_request_timeout(tmp_path):
# An explicitly set request_timeout overrides the 1-hour upload default.
timeout = _capture_upload_timeout(tmp_path, request_timeout=5.0)
assert timeout == httpx.Timeout(5.0)
def test_upload_file_ignores_post_upload_close_failure(tmp_path):
# Regression test: once S3 has accepted the archive, closing the spooled
# temp file in the `finally` block can raise. That failure must not be
# wrapped as a FileUploadException — the upload already succeeded.
(tmp_path / "hello.txt").write_text("hello world")
server, thread, state = _make_server()
host, port = server.server_address
url = f"http://{host}:{port}/upload"
real_tar_file_stream = template_utils.tar_file_stream
class _FailingCloseFile:
# Proxies a real spooled temp file but raises on close(). The
# underlying file object is a C type whose `close` attribute can't
# be reassigned, so we wrap it instead.
def __init__(self, inner):
self._inner = inner
def __getattr__(self, name):
return getattr(self._inner, name)
def __iter__(self):
return iter(self._inner)
def close(self):
# Run the real close so we don't leak the temp file, then
# simulate a close failure surfacing from the `finally`.
self._inner.close()
raise OSError("close failed")
def failing_close_stream(*args, **kwargs):
return _FailingCloseFile(real_tar_file_stream(*args, **kwargs))
try:
client = AuthenticatedClient(base_url="http://test", token="test")
with mock.patch(
"e2b.template_sync.build_api.tar_file_stream",
side_effect=failing_close_stream,
):
# Must not raise despite close() failing after a 200 response.
upload_file(
api_client=client,
file_name="*.txt",
context_path=str(tmp_path),
url=url,
ignore_patterns=[],
resolve_symlinks=False,
gzip=True,
stack_trace=None,
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
assert state["headers"] is not None
@@ -0,0 +1,281 @@
import datetime
from io import BytesIO, StringIO
import pytest
from e2b import Volume
from e2b.exceptions import NotFoundException, VolumeException
class TestWriteFileAndReadFile:
def test_write_and_read_text_file(self, volume: Volume):
path = "/test.txt"
content = "Hello, World!"
volume.write_file(path, content)
read_content = volume.read_file(path, format="text")
assert read_content == content
def test_write_and_read_bytes(self, volume: Volume):
path = "/test-bytes.txt"
content = "Test bytes content"
content_bytes = content.encode("utf-8")
volume.write_file(path, content_bytes)
read_bytes = volume.read_file(path, format="bytes")
assert read_bytes == content_bytes
def test_write_and_read_binary_data(self, volume: Volume):
path = "/binary.bin"
binary_data = bytes([0, 1, 2, 3, 4])
volume.write_file(path, binary_data)
read_bytes = volume.read_file(path, format="bytes")
assert read_bytes == binary_data
def test_write_and_read_stream(self, volume: Volume):
path = "/test-stream.txt"
content = "Test stream content"
stream = BytesIO(content.encode("utf-8"))
volume.write_file(path, stream)
read_stream = volume.read_file(path, format="stream")
read_content = b"".join(read_stream).decode("utf-8")
assert read_content == content
def test_write_and_read_text_stream(self, volume: Volume):
path = "/test-text-stream.txt"
content = "Test text stream content"
stream = StringIO(content)
volume.write_file(path, stream)
read_content = volume.read_file(path, format="text")
assert read_content == content
def test_write_and_read_empty_file(self, volume: Volume):
path = "/empty.txt"
content = ""
volume.write_file(path, content)
read_content = volume.read_file(path, format="text")
assert read_content == content
def test_overwrite_with_force(self, volume: Volume):
path = "/overwrite.txt"
initial_content = "Initial content"
new_content = "New content"
volume.write_file(path, initial_content)
volume.write_file(path, new_content, force=True)
read_content = volume.read_file(path, format="text")
assert read_content == new_content
def test_write_existing_file_without_force_raises(self, volume: Volume):
path = "/no-overwrite.txt"
initial_content = "Initial content"
new_content = "New content"
volume.write_file(path, initial_content)
with pytest.raises(VolumeException):
volume.write_file(path, new_content, force=False)
def test_write_file_with_metadata(self, volume: Volume):
path = "/metadata.txt"
content = "File with metadata"
volume.write_file(path, content, uid=1000, gid=1000, mode=0o644)
entry_info = volume.get_info(path)
assert entry_info.type.value == "file"
assert entry_info.uid == 1000
assert entry_info.gid == 1000
assert entry_info.mode == 0o644
def test_write_file_in_nested_directory(self, volume: Volume):
dir_path = "/nested/deep/path"
file_path = f"{dir_path}/file.txt"
content = "Nested file content"
volume.make_dir(dir_path, force=True)
volume.write_file(file_path, content)
read_content = volume.read_file(file_path, format="text")
assert read_content == content
class TestGetInfo:
def test_get_info_for_file(self, volume: Volume):
path = "/info-file.txt"
content = "File for info test"
volume.write_file(path, content)
info = volume.get_info(path)
assert info.name == "info-file.txt"
assert info.type.value == "file"
assert info.path == path
assert isinstance(info.mtime, datetime.datetime)
assert isinstance(info.ctime, datetime.datetime)
def test_get_info_for_directory(self, volume: Volume):
path = "/info-dir"
volume.make_dir(path)
info = volume.get_info(path)
assert info.name == "info-dir"
assert info.type.value == "directory"
assert info.path == path
def test_exists_returns_false_for_nonexistent(self, volume: Volume):
assert volume.exists("/non-existent.txt") is False
class TestUpdateMetadata:
def test_update_file_metadata(self, volume: Volume):
path = "/metadata-update.txt"
volume.write_file(path, "Content")
updated = volume.update_metadata(path, uid=1001, gid=1001, mode=0o755)
assert updated.path == path
assert updated.type.value == "file"
assert updated.uid == 1001
assert updated.gid == 1001
assert updated.mode == 0o755
def test_update_metadata_nonexistent_raises(self, volume: Volume):
with pytest.raises(NotFoundException):
volume.update_metadata("/non-existent.txt", mode=0o644)
class TestMakeDir:
def test_create_directory(self, volume: Volume):
path = "/test-dir"
volume.make_dir(path)
info = volume.get_info(path)
assert info.type.value == "directory"
assert info.path == path
def test_create_nested_directories_with_force(self, volume: Volume):
path = "/nested/deep/directory"
volume.make_dir(path, force=True)
info = volume.get_info(path)
assert info.type.value == "directory"
def test_create_existing_directory_without_force_raises(self, volume: Volume):
path = "/existing-dir"
volume.make_dir(path)
with pytest.raises(VolumeException):
volume.make_dir(path, force=False)
def test_create_directory_with_metadata(self, volume: Volume):
path = "/dir-with-metadata"
volume.make_dir(path, uid=1000, gid=1000, mode=0o755)
info = volume.get_info(path)
assert info.type.value == "directory"
assert info.uid == 1000
assert info.gid == 1000
assert info.mode & 0o777 == 0o755
class TestList:
def test_list_directory_contents(self, volume: Volume):
volume.write_file("/file1.txt", "Content 1")
volume.write_file("/file2.txt", "Content 2")
volume.make_dir("/dir1")
entries = volume.list("/")
assert len(entries) >= 3
file_names = sorted([e.name for e in entries])
assert "file1.txt" in file_names
assert "file2.txt" in file_names
assert "dir1" in file_names
def test_list_nested_directory(self, volume: Volume):
volume.make_dir("/nested", force=True)
volume.write_file("/nested/file.txt", "Content")
entries = volume.list("/nested")
assert len(entries) >= 1
assert any(e.name == "file.txt" for e in entries)
@pytest.mark.skip(reason="depth option not yet supported")
def test_list_with_depth(self, volume: Volume):
volume.make_dir("/deep/nested/structure", force=True)
volume.write_file("/deep/nested/structure/file.txt", "Content")
entries = volume.list("/deep", depth=2)
assert len(entries) > 0
def test_list_nonexistent_raises(self, volume: Volume):
with pytest.raises(NotFoundException):
volume.list("/non-existent")
class TestRemove:
def test_remove_file(self, volume: Volume):
path = "/to-remove.txt"
volume.write_file(path, "Content")
volume.remove(path)
assert volume.exists(path) is False
def test_remove_directory(self, volume: Volume):
path = "/to-remove-dir"
volume.make_dir(path)
volume.remove(path)
assert volume.exists(path) is False
def test_remove_directory_recursively(self, volume: Volume):
dir_path = "/recursive-dir"
volume.make_dir(f"{dir_path}/nested", force=True)
volume.write_file(f"{dir_path}/nested/file.txt", "Content")
volume.remove(dir_path)
assert volume.exists(dir_path) is False
def test_remove_nonexistent_raises(self, volume: Volume):
with pytest.raises(NotFoundException):
volume.remove("/non-existent.txt")
class TestFileOperationsLifecycle:
def test_directory_with_multiple_files(self, volume: Volume):
dir_path = "/multi-file-dir"
volume.make_dir(dir_path)
files = ["file1.txt", "file2.txt", "file3.txt"]
for file_name in files:
volume.write_file(f"{dir_path}/{file_name}", f"Content of {file_name}")
entries = volume.list(dir_path)
assert len(entries) >= len(files)
for file_name in files:
content = volume.read_file(f"{dir_path}/{file_name}", format="text")
assert content == f"Content of {file_name}"
volume.remove(dir_path)
assert volume.exists(dir_path) is False
@@ -0,0 +1,175 @@
from http import HTTPStatus
from uuid import uuid4
import pytest
from e2b import Volume
from e2b.exceptions import NotFoundException
from e2b.api.client.models.volume_and_token import VolumeAndToken
from e2b.api.client.types import Response
import e2b.api.client.api.volumes.post_volumes as post_volumes_mod
import e2b.api.client.api.volumes.get_volumes as get_volumes_mod
import e2b.api.client.api.volumes.get_volumes_volume_id as get_volume_mod
import e2b.api.client.api.volumes.delete_volumes_volume_id as delete_volume_mod
# In-memory store for mock volumes
_volumes: dict[str, VolumeAndToken] = {}
@pytest.fixture(autouse=True)
def mock_volume_api(monkeypatch, test_api_key):
monkeypatch.setenv("E2B_API_KEY", test_api_key)
_volumes.clear()
def mock_post_volumes(*, client, body):
vol_id = str(uuid4())
token = f"vol-token-{uuid4()}"
vol = VolumeAndToken(volume_id=vol_id, name=body.name, token=token)
_volumes[vol_id] = vol
return Response(
status_code=HTTPStatus(201),
content=b"",
headers={},
parsed=vol,
)
def mock_get_volumes(*, client):
return Response(
status_code=HTTPStatus(200),
content=b"",
headers={},
parsed=list(_volumes.values()),
)
def mock_get_volume(volume_id, *, client):
vol = _volumes.get(volume_id)
if vol is None:
return Response(
status_code=HTTPStatus(404),
content=b"",
headers={},
parsed=None,
)
return Response(
status_code=HTTPStatus(200),
content=b"",
headers={},
parsed=vol,
)
def mock_delete_volume(volume_id, *, client):
if volume_id not in _volumes:
return Response(
status_code=HTTPStatus(404),
content=b"",
headers={},
parsed=None,
)
del _volumes[volume_id]
return Response(
status_code=HTTPStatus(204),
content=b"",
headers={},
parsed=None,
)
monkeypatch.setattr(post_volumes_mod, "sync_detailed", mock_post_volumes)
monkeypatch.setattr(get_volumes_mod, "sync_detailed", mock_get_volumes)
monkeypatch.setattr(get_volume_mod, "sync_detailed", mock_get_volume)
monkeypatch.setattr(delete_volume_mod, "sync_detailed", mock_delete_volume)
def test_create_volume():
vol = Volume.create("test-volume")
assert vol is not None
assert vol.volume_id is not None
assert vol.name == "test-volume"
assert vol.token is not None
def test_get_volume_info():
created = Volume.create("info-volume")
info = Volume.get_info(created.volume_id)
assert info.volume_id == created.volume_id
assert info.name == "info-volume"
assert info.token is not None
def test_list_volumes():
Volume.create("vol-a")
Volume.create("vol-b")
volumes = Volume.list()
assert len(volumes) == 2
names = sorted([v.name for v in volumes])
assert names == ["vol-a", "vol-b"]
def test_list_volumes_empty():
volumes = Volume.list()
assert len(volumes) == 0
def test_destroy_volume():
vol = Volume.create("to-delete")
result = Volume.destroy(vol.volume_id)
assert result is True
volumes = Volume.list()
assert len(volumes) == 0
def test_destroy_nonexistent_volume():
result = Volume.destroy("non-existent-id")
assert result is False
def test_get_info_nonexistent_volume():
with pytest.raises(NotFoundException):
Volume.get_info("non-existent-id")
def test_create_volume_keeps_proxy_for_content_calls():
vol = Volume.create("proxy-volume", proxy="http://user:pass@127.0.0.1:8080")
# The proxy is stored on the instance...
assert vol._proxy == "http://user:pass@127.0.0.1:8080"
# ...and instance methods (which build a VolumeConnectionConfig with no
# per-call proxy) pick it up rather than falling back to no proxy.
config = vol._get_volume_config()
assert config.proxy == "http://user:pass@127.0.0.1:8080"
def test_volume_per_call_proxy_overrides_instance():
vol = Volume.create("proxy-volume", proxy="http://127.0.0.1:8080")
config = vol._get_volume_config(proxy="http://127.0.0.1:9090")
assert config.proxy == "http://127.0.0.1:9090"
def test_volume_full_lifecycle():
# Create
vol = Volume.create("lifecycle-vol")
assert vol.name == "lifecycle-vol"
# Get info
info = Volume.get_info(vol.volume_id)
assert info.name == "lifecycle-vol"
# List
volumes = Volume.list()
assert len(volumes) == 1
assert volumes[0].volume_id == vol.volume_id
# Destroy
destroyed = Volume.destroy(vol.volume_id)
assert destroyed is True
# List again
volumes = Volume.list()
assert len(volumes) == 0
@@ -0,0 +1,68 @@
import httpx
import pytest
from e2b import Volume
from e2b.exceptions import NotFoundException
import e2b.volume.volume_sync as volume_sync_mod
_files = {
"hello.txt": b"hello world",
"empty.txt": b"",
}
def _handler(request: httpx.Request) -> httpx.Response:
path = request.url.params.get("path")
content = _files.get(path)
if content is None:
return httpx.Response(404)
return httpx.Response(200, content=content)
@pytest.fixture
def volume(monkeypatch) -> Volume:
real_get_api_client = volume_sync_mod.get_volume_api_client
def mock_get_api_client(config, **kwargs):
client = real_get_api_client(config, **kwargs)
client.set_httpx_client(
httpx.Client(
base_url=config.api_url,
transport=httpx.MockTransport(_handler),
)
)
return client
monkeypatch.setattr(volume_sync_mod, "get_volume_api_client", mock_get_api_client)
return Volume(volume_id="vol-1", name="test-volume", token="vol-token")
def test_read_file_stream_yields_content(volume: Volume):
stream = volume.read_file("hello.txt", format="stream")
assert b"".join(stream) == b"hello world"
def test_read_file_stream_raises_for_missing_path(volume: Volume):
with pytest.raises(NotFoundException):
for _ in volume.read_file("missing.txt", format="stream"):
pass
def test_read_file_stream_of_empty_file(volume: Volume):
stream = volume.read_file("empty.txt", format="stream")
assert b"".join(stream) == b""
def test_read_file_text_and_bytes(volume: Volume):
assert volume.read_file("hello.txt") == "hello world"
assert volume.read_file("hello.txt", format="bytes") == b"hello world"
def test_read_file_text_and_bytes_of_empty_file(volume: Volume):
assert volume.read_file("empty.txt") == ""
assert volume.read_file("empty.txt", format="bytes") == b""
def test_read_file_missing_path_raises(volume: Volume):
with pytest.raises(NotFoundException):
volume.read_file("missing.txt")