Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.
Merged
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
35 changes: 7 additions & 28 deletions docs/source/api-reference/drivers/tftp.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,6 @@ export:
.. autoclass:: jumpstarter_driver_tftp.driver.ServerNotRunning
:members:
:show-inheritance:

.. autoclass:: jumpstarter_driver_tftp.driver.FileNotFound
:members:
:show-inheritance:
```

## Examples
Expand All @@ -53,37 +49,20 @@ export:
>>> import tempfile
>>> import os
>>> from jumpstarter_driver_tftp.driver import Tftp
>>> from jumpstarter.common.utils import serve
>>> with tempfile.TemporaryDirectory() as tmp_dir:
... # Create a test file
... test_file = os.path.join(tmp_dir, "test.txt")
... with open(test_file, "w") as f:
... _ = f.write("hello")
...
... # Start TFTP server
... tftp = Tftp(root_dir=tmp_dir, host="127.0.0.1", port=6969)
... tftp.start()
... with serve(Tftp(root_dir=tmp_dir, host="127.0.0.1", port=6969)) as tftp:
... tftp.start()
...
... # List files
... files = tftp.list_files()
... assert "test.txt" in files
... # List files
... files = list(tftp.storage.list("/"))
... assert "test.txt" in files
...
... tftp.stop()
```

```{testsetup} *
import tempfile
import os
from jumpstarter_driver_tftp.driver import Tftp
from jumpstarter.common.utils import serve

# Create a persistent temp dir that won't be removed by the example
TEST_DIR = tempfile.mkdtemp(prefix='tftp-test-')
instance = serve(Tftp(root_dir=TEST_DIR, host="127.0.0.1"))
client = instance.__enter__()
```

```{testcleanup} *
instance.__exit__(None, None, None)
import shutil
shutil.rmtree(TEST_DIR, ignore_errors=True)
... tftp.stop()
```
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydantic import ConfigDict, validate_call

from .adapter import OpendalAdapter
from .common import Capability, Metadata, Mode, PresignedRequest
from .common import Capability, HashAlgo, Metadata, Mode, PathBuf, PresignedRequest
from jumpstarter.client import DriverClient


Expand All @@ -30,7 +30,7 @@ def __read(self, handle):
return self.client.call("file_read", self.fd, handle)

@validate_call(validate_return=True, config=ConfigDict(arbitrary_types_allowed=True))
def write(self, path: str, operator: Operator | None = None):
def write(self, path: PathBuf, operator: Operator | None = None):
"""
Write into remote file with content from local file
"""
Expand All @@ -41,7 +41,7 @@ def write(self, path: str, operator: Operator | None = None):
return self.__write(handle)

@validate_call(validate_return=True, config=ConfigDict(arbitrary_types_allowed=True))
def read(self, path: str, operator: Operator | None = None):
def read(self, path: PathBuf, operator: Operator | None = None):
"""
Read content from remote file into local file
"""
Expand Down Expand Up @@ -114,42 +114,49 @@ def writable(self) -> bool:

class OpendalClient(DriverClient):
@validate_call
def open(self, /, path: str, mode: Mode) -> OpendalFile:
def open(self, /, path: PathBuf, mode: Mode) -> OpendalFile:
"""
Open a file-like reader for the given path
"""
return OpendalFile(client=self, fd=self.call("open", path, mode))

@validate_call(validate_return=True)
def stat(self, /, path: str) -> Metadata:
def stat(self, /, path: PathBuf) -> Metadata:
"""
Get current path's metadata
"""
return self.call("stat", path)

@validate_call(validate_return=True)
def copy(self, /, source: str, target: str):
def hash(self, /, path: PathBuf, algo: HashAlgo = "sha256") -> str:
"""
Get current path's hash
"""
return self.call("hash", path, algo)

@validate_call(validate_return=True)
def copy(self, /, source: PathBuf, target: PathBuf):
"""
Copy source to target
"""
self.call("copy", source, target)

@validate_call(validate_return=True)
def rename(self, /, source: str, target: str):
def rename(self, /, source: PathBuf, target: PathBuf):
"""
Rename source to target
"""
self.call("rename", source, target)

@validate_call(validate_return=True)
def remove_all(self, /, path: str):
def remove_all(self, /, path: PathBuf):
"""
Remove all file under path
"""
self.call("remove_all", path)

@validate_call(validate_return=True)
def create_dir(self, /, path: str):
def create_dir(self, /, path: PathBuf):
"""
Create a dir at given path

Expand All @@ -161,7 +168,7 @@ def create_dir(self, /, path: str):
self.call("create_dir", path)

@validate_call(validate_return=True)
def delete(self, /, path: str):
def delete(self, /, path: PathBuf):
"""
Delete given path

Expand All @@ -170,42 +177,42 @@ def delete(self, /, path: str):
self.call("delete", path)

@validate_call(validate_return=True)
def exists(self, /, path: str) -> bool:
def exists(self, /, path: PathBuf) -> bool:
"""
Check if given path exists
"""
return self.call("exists", path)

@validate_call
def list(self, /, path: str) -> Generator[str, None, None]:
def list(self, /, path: PathBuf) -> Generator[str, None, None]:
"""
List files and directories under given path
"""
yield from self.streamingcall("list", path)

@validate_call
def scan(self, /, path: str) -> Generator[str, None, None]:
def scan(self, /, path: PathBuf) -> Generator[str, None, None]:
"""
List files and directories under given path recursively
"""
yield from self.streamingcall("scan", path)

@validate_call(validate_return=True)
def presign_stat(self, /, path: str, expire_second: int) -> PresignedRequest:
def presign_stat(self, /, path: PathBuf, expire_second: int) -> PresignedRequest:
"""
Presign an operation for stat (HEAD) which expires after expire_second seconds
"""
return self.call("presign_stat", path, expire_second)

@validate_call(validate_return=True)
def presign_read(self, /, path: str, expire_second: int) -> PresignedRequest:
def presign_read(self, /, path: PathBuf, expire_second: int) -> PresignedRequest:
"""
Presign an operation for read (GET) which expires after expire_second seconds
"""
return self.call("presign_read", path, expire_second)

@validate_call(validate_return=True)
def presign_write(self, /, path: str, expire_second: int) -> PresignedRequest:
def presign_write(self, /, path: PathBuf, expire_second: int) -> PresignedRequest:
"""
Presign an operation for write (PUT) which expires after expire_second seconds
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Reference: https://github.com/apache/opendal/blob/main/bindings/python/python/opendal/__init__.pyi

from os import PathLike
from typing import Any, Literal, Optional

import opendal
from pydantic import BaseModel, model_validator

Mode = Literal["rb", "wb"]
HashAlgo = Literal["md5", "sha256"]
PathBuf = str | PathLike


class EntryMode(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import hashlib
from abc import ABCMeta, abstractmethod
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
Expand All @@ -10,7 +11,7 @@
from pydantic import validate_call

from .adapter import AsyncFileStream
from .common import Capability, Metadata, Mode, PresignedRequest
from .common import Capability, HashAlgo, Metadata, Mode, PresignedRequest
from jumpstarter.driver import Driver, export


Expand Down Expand Up @@ -98,6 +99,23 @@ async def file_writable(self, /, fd: UUID) -> bool:
async def stat(self, /, path: str) -> Metadata:
return Metadata.model_validate(await self._operator.stat(path), from_attributes=True)

@export
@validate_call(validate_return=True)
async def hash(self, /, path: str, algo: HashAlgo = "sha256") -> str:
match algo:
case "md5":
m = hashlib.md5()
case "sha256":
m = hashlib.sha256()
async with await self._operator.open(path, "rb") as f:
while True:
data = await f.read(size=65536)
if len(data) == 0:
break
m.update(data)

return m.hexdigest()

@export
@validate_call(validate_return=True)
async def copy(self, /, source: str, target: str):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_drivers_opendal(tmp_path):
assert test_file.writable()

(tmp_path / "src").write_text("hello")
test_file.write(str(tmp_path / "src"))
test_file.write(tmp_path / "src")

test_file.close()
assert test_file.closed
Expand All @@ -48,7 +48,13 @@ def test_drivers_opendal(tmp_path):
assert test_file.tell() == 0
assert test_file.seek(2) == 2

test_file.read(str(tmp_path / "dst"))
assert client.hash("test_dir/test_file", "md5") == "5d41402abc4b2a76b9719d911017c592"
assert (
client.hash("test_dir/test_file", "sha256")
== "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
)

test_file.read(tmp_path / "dst")
assert (tmp_path / "dst").read_text() == "llo"

assert client.stat("dst").content_length == 3
Expand Down
65 changes: 2 additions & 63 deletions packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/client.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import hashlib
from dataclasses import dataclass
from pathlib import Path

from jumpstarter_driver_opendal.adapter import OpendalAdapter
from opendal import Operator

from . import CHUNK_SIZE
from jumpstarter.client import DriverClient
from jumpstarter_driver_composite.client import CompositeClient


@dataclass(kw_only=True)
class TftpServerClient(DriverClient):
class TftpServerClient(CompositeClient):
"""
Client interface for TFTP Server driver

Expand Down Expand Up @@ -38,54 +32,6 @@ def stop(self):
"""
self.call("stop")

def list_files(self) -> list[str]:
"""
List files in the TFTP server root directory

Returns:
list[str]: A list of filenames present in the TFTP server's root directory
"""
return self.call("list_files")

def put_file(self, operator: Operator, path: str):
filename = Path(path).name
client_checksum = self._compute_checksum(operator, path)

if self.call("check_file_checksum", filename, client_checksum):
self.logger.info(f"Skipping upload of identical file: {filename}")
return filename

with OpendalAdapter(client=self, operator=operator, path=path, mode="rb") as handle:
return self.call("put_file", filename, handle, client_checksum)

def put_local_file(self, filepath: str):
absolute = Path(filepath).resolve()
filename = absolute.name

operator = Operator("fs", root="/")
client_checksum = self._compute_checksum(operator, str(absolute))

if self.call("check_file_checksum", filename, client_checksum):
self.logger.info(f"Skipping upload of identical file: {filename}")
return filename

self.logger.info(f"checksum: {client_checksum}")
with OpendalAdapter(client=self, operator=operator, path=str(absolute), mode="rb") as handle:
return self.call("put_file", filename, handle, client_checksum)

def delete_file(self, filename: str):
"""
Delete a file from the TFTP server

Args:
filename (str): Name of the file to delete

Raises:
FileNotFound: If the specified file doesn't exist
TftpError: If deletion fails for other reasons
"""
return self.call("delete_file", filename)

def get_host(self) -> str:
"""
Get the host address the TFTP server is listening on
Expand All @@ -103,10 +49,3 @@ def get_port(self) -> int:
int: The port number (default is 69)
"""
return self.call("get_port")

def _compute_checksum(self, operator: Operator, path: str) -> str:
hasher = hashlib.sha256()
with operator.open(path, "rb") as f:
while chunk := f.read(CHUNK_SIZE):
hasher.update(chunk)
return hasher.hexdigest()
Loading