Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/file-read-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'e2b': minor
'@e2b/python-sdk': minor
---

feat(sdks): support reading a byte range from `files.read` via `start` / `end` options (HTTP `Range`-style, inclusive end). When set, the SDKs add a `Range: bytes=<start>-<end>` header to `GET /files`, which envd already serves via `http.ServeContent`. Negative values and inverted ranges raise `InvalidArgumentError` / `InvalidArgumentException`.
40 changes: 40 additions & 0 deletions packages/js-sdk/src/sandbox/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,41 @@ export interface FilesystemReadOpts extends FilesystemRequestOpts {
* When true, the download will request gzip-encoded responses.
*/
gzip?: boolean
/**
* First byte to read (inclusive, zero-based). When set without `end`,
* the read continues to the end of the file.
*
* Sent as an HTTP `Range` header — ranges operate on bytes, so when reading
* with `format: 'text'` a range that splits a multi-byte UTF-8 codepoint
* will produce a malformed string. Read as `bytes` and decode yourself if
* you need precise text slicing.
*/
start?: number
/**
* Last byte to read (inclusive, zero-based, matching HTTP `Range` semantics
* — `start: 0, end: 9` returns 10 bytes). When set without `start`, the
* read starts at byte 0.
*/
end?: number
}

function buildRangeHeader(
start: number | undefined,
end: number | undefined
): string | undefined {
if (start === undefined && end === undefined) return undefined

if (start !== undefined && (!Number.isInteger(start) || start < 0)) {
throw new InvalidArgumentError('start must be a non-negative integer')
}
if (end !== undefined && (!Number.isInteger(end) || end < 0)) {
throw new InvalidArgumentError('end must be a non-negative integer')
}
if (start !== undefined && end !== undefined && start > end) {
throw new InvalidArgumentError('start must be less than or equal to end')
}

return `bytes=${start ?? 0}-${end ?? ''}`
}

export interface FilesystemListOpts extends FilesystemRequestOpts {
Expand Down Expand Up @@ -318,6 +353,11 @@ export class Filesystem {
headers['Accept-Encoding'] = 'gzip'
}

const range = buildRangeHeader(opts?.start, opts?.end)
if (range) {
headers['Range'] = range
}

const res = await this.envdApi.api.GET('/files', {
params: {
query: {
Expand Down
58 changes: 57 additions & 1 deletion packages/js-sdk/tests/sandbox/files/read.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { expect, assert } from 'vitest'

import { FileNotFoundError, NotFoundError } from '../../../src'
import {
FileNotFoundError,
InvalidArgumentError,
NotFoundError,
} from '../../../src'
import { sandboxTest } from '../../setup.js'

sandboxTest('read file', async ({ sandbox }) => {
Expand Down Expand Up @@ -38,3 +42,55 @@ sandboxTest('empty file', async ({ sandbox }) => {
const content = await sandbox.files.read(filename)
expect(content).toBe('')
})

sandboxTest('read with start and end', async ({ sandbox }) => {
const filename = 'test_read_range.txt'
const content = 'Hello, world!'

await sandbox.files.write(filename, content)
const sliced = await sandbox.files.read(filename, { start: 7, end: 11 })
assert.equal(sliced, 'world')
})

sandboxTest('read with start only', async ({ sandbox }) => {
const filename = 'test_read_start.txt'
const content = 'Hello, world!'

await sandbox.files.write(filename, content)
const sliced = await sandbox.files.read(filename, { start: 7 })
assert.equal(sliced, 'world!')
})

sandboxTest('read with end only', async ({ sandbox }) => {
const filename = 'test_read_end.txt'
const content = 'Hello, world!'

await sandbox.files.write(filename, content)
const sliced = await sandbox.files.read(filename, { end: 4 })
assert.equal(sliced, 'Hello')
})

sandboxTest('read range as bytes', async ({ sandbox }) => {
const filename = 'test_read_range_bytes.txt'
const content = 'Hello, world!'

await sandbox.files.write(filename, content)
const sliced = await sandbox.files.read(filename, {
format: 'bytes',
start: 7,
end: 11,
})
expect(new TextDecoder().decode(sliced)).toBe('world')
})

sandboxTest('read with invalid range rejects', async ({ sandbox }) => {
const filename = 'test_read_invalid_range.txt'
await sandbox.files.write(filename, 'data')

await expect(
sandbox.files.read(filename, { start: -1 })
).rejects.toThrowError(InvalidArgumentError)
await expect(
sandbox.files.read(filename, { start: 5, end: 2 })
).rejects.toThrowError(InvalidArgumentError)
})
18 changes: 18 additions & 0 deletions packages/python-sdk/e2b/sandbox/filesystem/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,21 @@ def to_upload_body(
raise InvalidArgumentException(f"Unsupported data type: {type(data)}")

return gzip.compress(raw) if use_gzip else raw


def build_range_header(
start: Optional[int],
end: Optional[int],
) -> Optional[str]:
"""Build an HTTP Range header value (`bytes=start-end`, inclusive) or return None."""
if start is None and end is None:
return None

if start is not None and (not isinstance(start, int) or start < 0):
raise InvalidArgumentException("start must be a non-negative integer")
if end is not None and (not isinstance(end, int) or end < 0):
raise InvalidArgumentException("end must be a non-negative integer")
if start is not None and end is not None and start > end:
raise InvalidArgumentException("start must be less than or equal to end")

return f"bytes={start if start is not None else 0}-{end if end is not None else ''}"
26 changes: 26 additions & 0 deletions packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
EntryInfo,
WriteEntry,
WriteInfo,
build_range_header,
map_file_type,
to_upload_body,
)
Expand Down Expand Up @@ -94,6 +95,8 @@ async def read(
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
start: Optional[int] = None,
end: Optional[int] = None,
) -> str:
"""
Read file content as a `str`.
Expand All @@ -103,6 +106,15 @@ async def read(
:param format: Format of the file content—`text` by default
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the request
:param start: First byte to read (inclusive, zero-based). When set
without ``end``, the read continues to the end of the file.
Sent as an HTTP ``Range`` header — ranges operate on bytes, so
with ``format="text"`` a range that splits a multi-byte UTF-8
codepoint will produce a malformed string. Read as ``bytes`` and
decode yourself if you need precise text slicing.
:param end: Last byte to read (inclusive, zero-based, matching HTTP
``Range`` semantics — ``start=0, end=9`` returns 10 bytes). When
set without ``start``, the read starts at byte 0.

:return: File content as a `str`
"""
Expand All @@ -116,6 +128,8 @@ async def read(
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
start: Optional[int] = None,
end: Optional[int] = None,
) -> bytearray:
"""
Read file content as a `bytearray`.
Expand All @@ -125,6 +139,8 @@ async def read(
:param format: Format of the file content—`bytes`
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the request
:param start: First byte to read (inclusive). See the ``text`` overload for details.
:param end: Last byte to read (inclusive). See the ``text`` overload for details.

:return: File content as a `bytearray`
"""
Expand All @@ -138,6 +154,8 @@ async def read(
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
start: Optional[int] = None,
end: Optional[int] = None,
) -> AsyncIterator[bytes]:
"""
Read file content as a `AsyncIterator[bytes]`.
Expand All @@ -147,6 +165,8 @@ async def read(
:param format: Format of the file content—`stream`
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the request
:param start: First byte to read (inclusive). See the ``text`` overload for details.
:param end: Last byte to read (inclusive). See the ``text`` overload for details.

:return: File content as an `AsyncIterator[bytes]`
"""
Expand All @@ -159,6 +179,8 @@ async def read(
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
start: Optional[int] = None,
end: Optional[int] = None,
):
username = user
if username is None and self._envd_version < ENVD_DEFAULT_USER:
Expand All @@ -172,6 +194,10 @@ async def read(
if gzip:
headers["Accept-Encoding"] = "gzip"

range_header = build_range_header(start, end)
if range_header:
headers["Range"] = range_header

r = await self._envd_api.get(
ENVD_API_FILES_ROUTE,
params=params,
Expand Down
26 changes: 26 additions & 0 deletions packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
EntryInfo,
WriteEntry,
WriteInfo,
build_range_header,
map_file_type,
to_upload_body,
)
Expand Down Expand Up @@ -92,6 +93,8 @@ def read(
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
start: Optional[int] = None,
end: Optional[int] = None,
) -> str:
"""
Read file content as a `str`.
Expand All @@ -101,6 +104,15 @@ def read(
:param format: Format of the file content—`text` by default
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the request
:param start: First byte to read (inclusive, zero-based). When set
without ``end``, the read continues to the end of the file.
Sent as an HTTP ``Range`` header — ranges operate on bytes, so
with ``format="text"`` a range that splits a multi-byte UTF-8
codepoint will produce a malformed string. Read as ``bytes`` and
decode yourself if you need precise text slicing.
:param end: Last byte to read (inclusive, zero-based, matching HTTP
``Range`` semantics — ``start=0, end=9`` returns 10 bytes). When
set without ``start``, the read starts at byte 0.

:return: File content as a `str`
"""
Expand All @@ -114,6 +126,8 @@ def read(
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
start: Optional[int] = None,
end: Optional[int] = None,
) -> bytearray:
"""
Read file content as a `bytearray`.
Expand All @@ -123,6 +137,8 @@ def read(
:param format: Format of the file content—`bytes`
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the request
:param start: First byte to read (inclusive). See the ``text`` overload for details.
:param end: Last byte to read (inclusive). See the ``text`` overload for details.

:return: File content as a `bytearray`
"""
Expand All @@ -136,6 +152,8 @@ def read(
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
start: Optional[int] = None,
end: Optional[int] = None,
) -> Iterator[bytes]:
"""
Read file content as a `Iterator[bytes]`.
Expand All @@ -145,6 +163,8 @@ def read(
:param format: Format of the file content—`stream`
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the request
:param start: First byte to read (inclusive). See the ``text`` overload for details.
:param end: Last byte to read (inclusive). See the ``text`` overload for details.

:return: File content as an `Iterator[bytes]`
"""
Expand All @@ -157,6 +177,8 @@ def read(
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
start: Optional[int] = None,
end: Optional[int] = None,
):
username = user
if username is None and self._envd_version < ENVD_DEFAULT_USER:
Expand All @@ -170,6 +192,10 @@ def read(
if gzip:
headers["Accept-Encoding"] = "gzip"

range_header = build_range_header(start, end)
if range_header:
headers["Range"] = range_header

r = self._envd_api.get(
ENVD_API_FILES_ROUTE,
params=params,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import pytest

from e2b import FileNotFoundException, NotFoundException, AsyncSandbox
from e2b import (
AsyncSandbox,
FileNotFoundException,
InvalidArgumentException,
NotFoundException,
)


async def test_read_file(async_sandbox: AsyncSandbox):
Expand Down Expand Up @@ -35,3 +40,38 @@ async def test_read_empty_file(async_sandbox: AsyncSandbox):
await async_sandbox.commands.run(f"touch {filename}")
read_content = await async_sandbox.files.read(filename)
assert read_content == content


async def test_read_with_start_and_end(async_sandbox: AsyncSandbox):
filename = "test_read_range.txt"
await async_sandbox.files.write(filename, "Hello, world!")
assert await async_sandbox.files.read(filename, start=7, end=11) == "world"


async def test_read_with_start_only(async_sandbox: AsyncSandbox):
filename = "test_read_start.txt"
await async_sandbox.files.write(filename, "Hello, world!")
assert await async_sandbox.files.read(filename, start=7) == "world!"


async def test_read_with_end_only(async_sandbox: AsyncSandbox):
filename = "test_read_end.txt"
await async_sandbox.files.write(filename, "Hello, world!")
assert await async_sandbox.files.read(filename, end=4) == "Hello"


async def test_read_range_as_bytes(async_sandbox: AsyncSandbox):
filename = "test_read_range_bytes.txt"
await async_sandbox.files.write(filename, "Hello, world!")
sliced = await async_sandbox.files.read(filename, format="bytes", start=7, end=11)
assert bytes(sliced).decode("utf-8") == "world"


async def test_read_with_invalid_range_rejects(async_sandbox: AsyncSandbox):
filename = "test_read_invalid_range.txt"
await async_sandbox.files.write(filename, "data")

with pytest.raises(InvalidArgumentException):
await async_sandbox.files.read(filename, start=-1)
with pytest.raises(InvalidArgumentException):
await async_sandbox.files.read(filename, start=5, end=2)
Loading
Loading