diff --git a/.changeset/file-read-range.md b/.changeset/file-read-range.md new file mode 100644 index 0000000000..0788bb67c7 --- /dev/null +++ b/.changeset/file-read-range.md @@ -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=-` header to `GET /files`, which envd already serves via `http.ServeContent`. Negative values and inverted ranges raise `InvalidArgumentError` / `InvalidArgumentException`. diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index 14e7a2e944..7cfe1ef03b 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -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 { @@ -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: { diff --git a/packages/js-sdk/tests/sandbox/files/read.test.ts b/packages/js-sdk/tests/sandbox/files/read.test.ts index 4dad2fb33c..8e392f9092 100644 --- a/packages/js-sdk/tests/sandbox/files/read.test.ts +++ b/packages/js-sdk/tests/sandbox/files/read.test.ts @@ -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 }) => { @@ -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) +}) diff --git a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py index 1cee7327a5..6ca601b193 100644 --- a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py @@ -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 ''}" diff --git a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py index 309f4f6169..f9515ae0cd 100644 --- a/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_async/filesystem/filesystem.py @@ -33,6 +33,7 @@ EntryInfo, WriteEntry, WriteInfo, + build_range_header, map_file_type, to_upload_body, ) @@ -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`. @@ -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` """ @@ -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`. @@ -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` """ @@ -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]`. @@ -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]` """ @@ -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: @@ -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, diff --git a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py index b145200e41..588517501a 100644 --- a/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox_sync/filesystem/filesystem.py @@ -33,6 +33,7 @@ EntryInfo, WriteEntry, WriteInfo, + build_range_header, map_file_type, to_upload_body, ) @@ -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`. @@ -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` """ @@ -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`. @@ -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` """ @@ -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]`. @@ -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]` """ @@ -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: @@ -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, diff --git a/packages/python-sdk/tests/async/sandbox_async/files/test_read.py b/packages/python-sdk/tests/async/sandbox_async/files/test_read.py index 6bb871e470..b7a83822b2 100644 --- a/packages/python-sdk/tests/async/sandbox_async/files/test_read.py +++ b/packages/python-sdk/tests/async/sandbox_async/files/test_read.py @@ -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): @@ -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) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/files/test_read.py b/packages/python-sdk/tests/sync/sandbox_sync/files/test_read.py index f5dc3a32e3..2d46de0160 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/files/test_read.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/files/test_read.py @@ -1,5 +1,5 @@ import pytest -from e2b import FileNotFoundException, NotFoundException +from e2b import FileNotFoundException, InvalidArgumentException, NotFoundException def test_read_file(sandbox): @@ -32,3 +32,38 @@ def test_read_empty_file(sandbox): sandbox.commands.run(f"touch {filename}") read_content = sandbox.files.read(filename) assert read_content == content + + +def test_read_with_start_and_end(sandbox): + filename = "test_read_range.txt" + sandbox.files.write(filename, "Hello, world!") + assert sandbox.files.read(filename, start=7, end=11) == "world" + + +def test_read_with_start_only(sandbox): + filename = "test_read_start.txt" + sandbox.files.write(filename, "Hello, world!") + assert sandbox.files.read(filename, start=7) == "world!" + + +def test_read_with_end_only(sandbox): + filename = "test_read_end.txt" + sandbox.files.write(filename, "Hello, world!") + assert sandbox.files.read(filename, end=4) == "Hello" + + +def test_read_range_as_bytes(sandbox): + filename = "test_read_range_bytes.txt" + sandbox.files.write(filename, "Hello, world!") + sliced = sandbox.files.read(filename, format="bytes", start=7, end=11) + assert bytes(sliced).decode("utf-8") == "world" + + +def test_read_with_invalid_range_rejects(sandbox): + filename = "test_read_invalid_range.txt" + sandbox.files.write(filename, "data") + + with pytest.raises(InvalidArgumentException): + sandbox.files.read(filename, start=-1) + with pytest.raises(InvalidArgumentException): + sandbox.files.read(filename, start=5, end=2)