diff --git a/examples/08_directory_operations.py b/examples/08_directory_operations.py index e9c7b589..2ca11beb 100644 --- a/examples/08_directory_operations.py +++ b/examples/08_directory_operations.py @@ -26,16 +26,16 @@ def main(): # Create directory fs.mkdir("/tmp/my_project") - # Create nested directories - fs.mkdir("/tmp/my_project/src/utils", recursive=True) + # Create nested directories (API creates parents automatically) + fs.mkdir("/tmp/my_project/src/utils") # List directory contents = fs.list_dir("/tmp/my_project") print(f"Contents: {contents}") # Create project structure - fs.mkdir("/tmp/my_project/src", recursive=True) - fs.mkdir("/tmp/my_project/tests", recursive=True) + fs.mkdir("/tmp/my_project/src") + fs.mkdir("/tmp/my_project/tests") fs.write_file("/tmp/my_project/src/main.py", "print('Hello')") fs.write_file("/tmp/my_project/README.md", "# My Project") diff --git a/examples/08_directory_operations_async.py b/examples/08_directory_operations_async.py index fd646b69..91e92ca2 100644 --- a/examples/08_directory_operations_async.py +++ b/examples/08_directory_operations_async.py @@ -27,16 +27,16 @@ async def main(): # Create directory await fs.mkdir("/tmp/my_project") - # Create nested directories - await fs.mkdir("/tmp/my_project/src/utils", recursive=True) + # Create nested directories (API creates parents automatically) + await fs.mkdir("/tmp/my_project/src/utils") # List directory contents = await fs.list_dir("/tmp/my_project") print(f"Contents: {contents}") # Create project structure - await fs.mkdir("/tmp/my_project/src", recursive=True) - await fs.mkdir("/tmp/my_project/tests", recursive=True) + await fs.mkdir("/tmp/my_project/src") + await fs.mkdir("/tmp/my_project/tests") await fs.write_file("/tmp/my_project/src/main.py", "print('Hello')") await fs.write_file("/tmp/my_project/README.md", "# My Project") diff --git a/examples/09_binary_files.py b/examples/09_binary_files.py index fa83f0e6..375a8de4 100644 --- a/examples/09_binary_files.py +++ b/examples/09_binary_files.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """Binary file operations""" -import base64 import os from koyeb import Sandbox @@ -24,17 +23,15 @@ def main(): fs = sandbox.filesystem - # Write binary data + # Write binary data (encoding="base64" handles the encoding automatically) binary_data = b"Binary data: \x00\x01\x02\x03\xff\xfe\xfd" - base64_data = base64.b64encode(binary_data).decode("utf-8") - fs.write_file("/tmp/binary.bin", base64_data, encoding="base64") + fs.write_file("/tmp/binary.bin", binary_data, encoding="base64") - # Read binary data + # Read binary data (encoding="base64" decodes and returns bytes) file_info = fs.read_file("/tmp/binary.bin", encoding="base64") - decoded = base64.b64decode(file_info.content) print(f"Original: {binary_data}") - print(f"Decoded: {decoded}") - assert binary_data == decoded + print(f"Read back: {file_info.content}") + assert binary_data == file_info.content except Exception as e: print(f"Error: {e}") diff --git a/examples/09_binary_files_async.py b/examples/09_binary_files_async.py index 2de90ae5..1d451039 100644 --- a/examples/09_binary_files_async.py +++ b/examples/09_binary_files_async.py @@ -2,7 +2,6 @@ """Binary file operations (async variant)""" import asyncio -import base64 import os from koyeb import AsyncSandbox @@ -25,17 +24,15 @@ async def main(): fs = sandbox.filesystem - # Write binary data + # Write binary data (encoding="base64" handles the encoding automatically) binary_data = b"Binary data: \x00\x01\x02\x03\xff\xfe\xfd" - base64_data = base64.b64encode(binary_data).decode("utf-8") - await fs.write_file("/tmp/binary.bin", base64_data, encoding="base64") + await fs.write_file("/tmp/binary.bin", binary_data, encoding="base64") - # Read binary data + # Read binary data (encoding="base64" decodes and returns bytes) file_info = await fs.read_file("/tmp/binary.bin", encoding="base64") - decoded = base64.b64decode(file_info.content) print(f"Original: {binary_data}") - print(f"Decoded: {decoded}") - assert binary_data == decoded + print(f"Read back: {file_info.content}") + assert binary_data == file_info.content except Exception as e: print(f"Error: {e}") diff --git a/examples/10_batch_operations.py b/examples/10_batch_operations.py index c987e0c0..075c472d 100644 --- a/examples/10_batch_operations.py +++ b/examples/10_batch_operations.py @@ -45,7 +45,7 @@ def main(): {"path": "/tmp/project/README.md", "content": "# My Project"}, ] - fs.mkdir("/tmp/project", recursive=True) + fs.mkdir("/tmp/project") fs.write_files(project_files) print("Created project structure") diff --git a/examples/10_batch_operations_async.py b/examples/10_batch_operations_async.py index e0b26176..2b8ae99d 100644 --- a/examples/10_batch_operations_async.py +++ b/examples/10_batch_operations_async.py @@ -46,7 +46,7 @@ async def main(): {"path": "/tmp/project/README.md", "content": "# My Project"}, ] - await fs.mkdir("/tmp/project", recursive=True) + await fs.mkdir("/tmp/project") await fs.write_files(project_files) print("Created project structure") diff --git a/examples/12_file_manipulation.py b/examples/12_file_manipulation.py index 6268706d..3443caa5 100644 --- a/examples/12_file_manipulation.py +++ b/examples/12_file_manipulation.py @@ -26,7 +26,7 @@ def main(): # Setup fs.write_file("/tmp/file1.txt", "Content of file 1") fs.write_file("/tmp/file2.txt", "Content of file 2") - fs.mkdir("/tmp/test_dir", recursive=True) + fs.mkdir("/tmp/test_dir") # Rename file fs.rename_file("/tmp/file1.txt", "/tmp/renamed_file.txt") diff --git a/examples/12_file_manipulation_async.py b/examples/12_file_manipulation_async.py index 47871fa6..520cb8aa 100644 --- a/examples/12_file_manipulation_async.py +++ b/examples/12_file_manipulation_async.py @@ -27,7 +27,7 @@ async def main(): # Setup await fs.write_file("/tmp/file1.txt", "Content of file 1") await fs.write_file("/tmp/file2.txt", "Content of file 2") - await fs.mkdir("/tmp/test_dir", recursive=True) + await fs.mkdir("/tmp/test_dir") # Rename file await fs.rename_file("/tmp/file1.txt", "/tmp/renamed_file.txt") diff --git a/koyeb/sandbox/filesystem.py b/koyeb/sandbox/filesystem.py index ae072dae..b3a7c380 100644 --- a/koyeb/sandbox/filesystem.py +++ b/koyeb/sandbox/filesystem.py @@ -42,7 +42,7 @@ class SandboxFileExistsError(SandboxFilesystemError): class FileInfo: """File information""" - content: str + content: Union[str, bytes] encoding: str @@ -87,10 +87,15 @@ def write_file( content: Content to write (string or bytes) encoding: File encoding (default: "utf-8"). Use "base64" for binary data. """ + import base64 + client = self._get_client() if isinstance(content, bytes): - content_str = content.decode("utf-8") + if encoding == "base64": + content_str = base64.b64encode(content).decode("ascii") + else: + content_str = content.decode(encoding) else: content_str = content @@ -110,11 +115,14 @@ def read_file(self, path: str, encoding: str = "utf-8") -> FileInfo: Args: path: Absolute path to the file - encoding: File encoding (default: "utf-8"). Use "base64" for binary data. + encoding: File encoding (default: "utf-8"). Use "base64" for binary data, + which will decode the base64 content and return bytes. Returns: - FileInfo: Object with content and encoding + FileInfo: Object with content (str or bytes if base64) and encoding """ + import base64 as base64_module + client = self._get_client() try: @@ -124,7 +132,11 @@ def read_file(self, path: str, encoding: str = "utf-8") -> FileInfo: if check_error_message(error_msg, "NO_SUCH_FILE"): raise SandboxFileNotFoundError(f"File not found: {path}") raise SandboxFilesystemError(f"Failed to read file: {error_msg}") - content = response.get("content", "") + content_str = response.get("content", "") + if encoding == "base64": + content: Union[str, bytes] = base64_module.b64decode(content_str) + else: + content = content_str return FileInfo(content=content, encoding=encoding) except (SandboxFileNotFoundError, SandboxFilesystemError): raise @@ -134,13 +146,14 @@ def read_file(self, path: str, encoding: str = "utf-8") -> FileInfo: raise SandboxFileNotFoundError(f"File not found: {path}") from e raise SandboxFilesystemError(f"Failed to read file: {error_msg}") from e - def mkdir(self, path: str, recursive: bool = False) -> None: + def mkdir(self, path: str) -> None: """ Create a directory synchronously. + Note: Parent directories are always created automatically by the API. + Args: path: Absolute path to the directory - recursive: Create parent directories if needed (default: False, not used - API always creates parents) """ client = self._get_client() @@ -341,23 +354,7 @@ def upload_file( with open(local_path, "rb") as f: content_bytes = f.read() - if encoding == "base64": - import base64 - - content = base64.b64encode(content_bytes).decode("ascii") - self.write_file(remote_path, content, encoding="base64") - else: - try: - content = content_bytes.decode(encoding) - self.write_file(remote_path, content, encoding=encoding) - except UnicodeDecodeError as e: - raise UnicodeDecodeError( - e.encoding, - e.object, - e.start, - e.end, - f"Cannot decode file as {encoding}. Use encoding='base64' for binary files.", - ) from e + self.write_file(remote_path, content_bytes, encoding=encoding) def download_file( self, remote_path: str, local_path: str, encoding: str = "utf-8" @@ -375,10 +372,8 @@ def download_file( """ file_info = self.read_file(remote_path, encoding=encoding) - if encoding == "base64": - import base64 - - content_bytes = base64.b64decode(file_info.content) + if isinstance(file_info.content, bytes): + content_bytes = file_info.content else: content_bytes = file_info.content.encode(encoding) @@ -418,18 +413,21 @@ def rm(self, path: str, recursive: bool = False) -> None: raise SandboxFileNotFoundError(f"File not found: {path}") raise SandboxFilesystemError(f"Failed to remove: {result.stderr}") - def open(self, path: str, mode: str = "r") -> SandboxFileIO: + def open( + self, path: str, mode: str = "r", encoding: str = "utf-8" + ) -> SandboxFileIO: """ Open a file in the sandbox synchronously. Args: path: Path to the file mode: Open mode ('r', 'w', 'a', etc.) + encoding: File encoding (default: "utf-8"). Use "base64" for binary data. Returns: SandboxFileIO: File handle """ - return SandboxFileIO(self, path, mode) + return SandboxFileIO(self, path, mode, encoding) class AsyncSandboxFilesystem(SandboxFilesystem): @@ -473,21 +471,23 @@ async def read_file(self, path: str, encoding: str = "utf-8") -> FileInfo: Args: path: Absolute path to the file - encoding: File encoding (default: "utf-8"). Use "base64" for binary data. + encoding: File encoding (default: "utf-8"). Use "base64" for binary data, + which will decode the base64 content and return bytes. Returns: - FileInfo: Object with content and encoding + FileInfo: Object with content (str or bytes if base64) and encoding """ pass @async_wrapper("mkdir") - async def mkdir(self, path: str, recursive: bool = False) -> None: + async def mkdir(self, path: str) -> None: """ Create a directory asynchronously. + Note: Parent directories are always created automatically by the API. + Args: path: Absolute path to the directory - recursive: Create parent directories if needed (default: False, not used - API always creates parents) """ pass @@ -625,30 +625,40 @@ async def rm(self, path: str, recursive: bool = False) -> None: """ pass - def open(self, path: str, mode: str = "r") -> AsyncSandboxFileIO: + def open( + self, path: str, mode: str = "r", encoding: str = "utf-8" + ) -> AsyncSandboxFileIO: """ Open a file in the sandbox asynchronously. Args: path: Path to the file mode: Open mode ('r', 'w', 'a', etc.) + encoding: File encoding (default: "utf-8"). Use "base64" for binary data. Returns: AsyncSandboxFileIO: Async file handle """ - return AsyncSandboxFileIO(self, path, mode) + return AsyncSandboxFileIO(self, path, mode, encoding) class SandboxFileIO: """Synchronous file I/O handle for sandbox files""" - def __init__(self, filesystem: SandboxFilesystem, path: str, mode: str): + def __init__( + self, + filesystem: SandboxFilesystem, + path: str, + mode: str, + encoding: str = "utf-8", + ): self.filesystem = filesystem self.path = path self.mode = mode + self.encoding = encoding self._closed = False - def read(self) -> str: + def read(self) -> Union[str, bytes]: """Read file content synchronously""" if "r" not in self.mode: raise ValueError("File not opened for reading") @@ -656,10 +666,10 @@ def read(self) -> str: if self._closed: raise ValueError("File is closed") - file_info = self.filesystem.read_file(self.path) + file_info = self.filesystem.read_file(self.path, encoding=self.encoding) return file_info.content - def write(self, content: str) -> None: + def write(self, content: Union[str, bytes]) -> None: """Write content to file synchronously""" if "w" not in self.mode and "a" not in self.mode: raise ValueError("File not opened for writing") @@ -669,12 +679,17 @@ def write(self, content: str) -> None: if "a" in self.mode: try: - existing = self.filesystem.read_file(self.path) - content = existing.content + content + existing = self.filesystem.read_file(self.path, encoding=self.encoding) + if isinstance(existing.content, bytes) and isinstance(content, bytes): + content = existing.content + content + elif isinstance(existing.content, str) and isinstance(content, str): + content = existing.content + content + else: + raise TypeError("Cannot mix bytes and str content in append mode") except SandboxFileNotFoundError: pass - self.filesystem.write_file(self.path, content) + self.filesystem.write_file(self.path, content, encoding=self.encoding) def close(self) -> None: """Close the file""" @@ -690,13 +705,20 @@ def __exit__(self, exc_type, exc_val, exc_tb): class AsyncSandboxFileIO: """Async file I/O handle for sandbox files""" - def __init__(self, filesystem: AsyncSandboxFilesystem, path: str, mode: str): + def __init__( + self, + filesystem: AsyncSandboxFilesystem, + path: str, + mode: str, + encoding: str = "utf-8", + ): self.filesystem = filesystem self.path = path self.mode = mode + self.encoding = encoding self._closed = False - async def read(self) -> str: + async def read(self) -> Union[str, bytes]: """Read file content asynchronously""" if "r" not in self.mode: raise ValueError("File not opened for reading") @@ -704,10 +726,10 @@ async def read(self) -> str: if self._closed: raise ValueError("File is closed") - file_info = await self.filesystem.read_file(self.path) + file_info = await self.filesystem.read_file(self.path, encoding=self.encoding) return file_info.content - async def write(self, content: str) -> None: + async def write(self, content: Union[str, bytes]) -> None: """Write content to file asynchronously""" if "w" not in self.mode and "a" not in self.mode: raise ValueError("File not opened for writing") @@ -717,12 +739,19 @@ async def write(self, content: str) -> None: if "a" in self.mode: try: - existing = await self.filesystem.read_file(self.path) - content = existing.content + content + existing = await self.filesystem.read_file( + self.path, encoding=self.encoding + ) + if isinstance(existing.content, bytes) and isinstance(content, bytes): + content = existing.content + content + elif isinstance(existing.content, str) and isinstance(content, str): + content = existing.content + content + else: + raise TypeError("Cannot mix bytes and str content in append mode") except SandboxFileNotFoundError: pass - await self.filesystem.write_file(self.path, content) + await self.filesystem.write_file(self.path, content, encoding=self.encoding) def close(self) -> None: """Close the file"""