Skip to content

Commit cdfb294

Browse files
committed
fix(examples): address file RPC review feedback
1 parent b59e0e0 commit cdfb294

4 files changed

Lines changed: 84 additions & 73 deletions

File tree

examples/file_client.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,29 +33,42 @@
3333

3434

3535
@dataclass(frozen=True)
36-
class FileReadResponse:
37-
error: int
38-
data: bytes
39-
36+
class FileReadRequest:
37+
_file_path: str
38+
_read_offset: int
4039

41-
def _encode_request(file_path: str, read_offset: int) -> bytes:
42-
encoded_path = file_path.encode("utf8")
43-
if len(encoded_path) > PATH_MAX_LEN:
44-
raise ValueError(f"File path length {len(encoded_path)} is too long")
45-
return struct.pack(REQUEST_HEADER_FORMAT, read_offset, len(encoded_path)) + encoded_path
40+
def serialize(self) -> bytes:
41+
encoded_path = self._file_path.encode("utf8")
42+
if len(encoded_path) > PATH_MAX_LEN:
43+
raise ValueError(f"File path length {len(encoded_path)} is too long")
44+
return struct.pack(REQUEST_HEADER_FORMAT, self._read_offset, len(encoded_path)) + encoded_path
4645

4746

48-
def _decode_response(payload: bytes) -> FileReadResponse | None:
49-
if len(payload) < RESPONSE_HEADER_SIZE:
50-
return None
51-
error, data_len = struct.unpack_from(RESPONSE_HEADER_FORMAT, payload)
52-
if data_len > DATA_MAX:
53-
return None
54-
data_start = RESPONSE_HEADER_SIZE
55-
data_end = data_start + data_len
56-
if len(payload) != data_end:
57-
return None
58-
return FileReadResponse(error=error, data=payload[data_start:data_end])
47+
@dataclass(frozen=True)
48+
class FileReadResponse:
49+
_error: int
50+
_data: bytes
51+
52+
@property
53+
def error(self) -> int:
54+
return self._error
55+
56+
@property
57+
def data(self) -> bytes:
58+
return self._data
59+
60+
@staticmethod
61+
def deserialize(payload: bytes) -> FileReadResponse | None:
62+
if len(payload) < RESPONSE_HEADER_SIZE:
63+
return None
64+
error, data_len = struct.unpack_from(RESPONSE_HEADER_FORMAT, payload)
65+
if data_len > DATA_MAX:
66+
return None
67+
data_start = RESPONSE_HEADER_SIZE
68+
data_end = data_start + data_len
69+
if len(payload) != data_end:
70+
return None
71+
return FileReadResponse(error, payload[data_start:data_end])
5972

6073

6174
def _format_remote_error(error: int) -> str:
@@ -75,7 +88,7 @@ async def _receive_response(stream: ResponseStream, expected_server_id: int | No
7588
expected_server_id,
7689
)
7790
continue
78-
decoded = _decode_response(response.message)
91+
decoded = FileReadResponse.deserialize(response.message)
7992
if decoded is None:
8093
_logger.debug("dropping malformed response from %016x seq=%d", response.remote_id, response.seqno)
8194
continue
@@ -104,7 +117,7 @@ async def run(file_path: str) -> int:
104117
_logger.info("requesting offset %d", read_offset)
105118
stream: ResponseStream | None = None
106119
try:
107-
request = _encode_request(file_path, read_offset)
120+
request = FileReadRequest(file_path, read_offset).serialize()
108121
stream = await pub.request(Instant.now() + REQUEST_DELIVERY_TIMEOUT, RESPONSE_TIMEOUT, request)
109122
server_id, response = await _receive_valid_response(stream, discovered_server_id, RESPONSE_TIMEOUT)
110123
except ValueError as ex:

examples/file_server.py

Lines changed: 44 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import logging
1313
import struct
1414
from dataclasses import dataclass
15-
from functools import partial
1615
from pathlib import Path
1716

1817
from pycyphal2 import Arrival, DeliveryError, NackError, Node, SendError
@@ -32,36 +31,51 @@
3231

3332
@dataclass(frozen=True)
3433
class FileReadRequest:
35-
read_offset: int
36-
file_path: str
34+
_read_offset: int
35+
_file_path: str
36+
37+
@property
38+
def read_offset(self) -> int:
39+
return self._read_offset
40+
41+
@property
42+
def file_path(self) -> str:
43+
return self._file_path
44+
45+
@staticmethod
46+
def deserialize(payload: bytes) -> FileReadRequest | None:
47+
if len(payload) < REQUEST_HEADER_SIZE:
48+
return None
49+
read_offset, path_len = struct.unpack_from(REQUEST_HEADER_FORMAT, payload)
50+
if path_len == 0 or path_len > PATH_MAX_LEN:
51+
return None
52+
path_end = REQUEST_HEADER_SIZE + path_len
53+
if len(payload) != path_end:
54+
return None
55+
try:
56+
file_path = payload[REQUEST_HEADER_SIZE:path_end].decode("utf8")
57+
except UnicodeDecodeError:
58+
return None
59+
return FileReadRequest(read_offset, file_path)
3760

3861

3962
@dataclass(frozen=True)
4063
class FileReadResponse:
41-
error: int
42-
data: bytes
43-
44-
45-
def _decode_request(payload: bytes) -> FileReadRequest | None:
46-
if len(payload) < REQUEST_HEADER_SIZE:
47-
return None
48-
read_offset, path_len = struct.unpack_from(REQUEST_HEADER_FORMAT, payload)
49-
if path_len == 0 or path_len > PATH_MAX_LEN:
50-
return None
51-
path_end = REQUEST_HEADER_SIZE + path_len
52-
if len(payload) != path_end:
53-
return None
54-
try:
55-
file_path = payload[REQUEST_HEADER_SIZE:path_end].decode("utf8")
56-
except UnicodeDecodeError:
57-
return None
58-
return FileReadRequest(read_offset=read_offset, file_path=file_path)
64+
_error: int
65+
_data: bytes
66+
67+
@property
68+
def error(self) -> int:
69+
return self._error
5970

71+
@property
72+
def data(self) -> bytes:
73+
return self._data
6074

61-
def _encode_response(response: FileReadResponse) -> bytes:
62-
if len(response.data) > DATA_MAX:
63-
raise ValueError(f"Response data is too large: {len(response.data)}")
64-
return struct.pack(RESPONSE_HEADER_FORMAT, response.error, len(response.data)) + response.data
75+
def serialize(self) -> bytes:
76+
if len(self._data) > DATA_MAX:
77+
raise ValueError(f"Response data is too large: {len(self._data)}")
78+
return struct.pack(RESPONSE_HEADER_FORMAT, self._error, len(self._data)) + self._data
6579

6680

6781
def _errno_from_exception(ex: BaseException) -> int:
@@ -78,13 +92,13 @@ def _read_chunk(file_path: str, offset: int) -> FileReadResponse:
7892
file.seek(offset)
7993
data = file.read(DATA_MAX)
8094
except (OSError, ValueError, OverflowError) as ex:
81-
return FileReadResponse(error=_errno_from_exception(ex), data=b"")
82-
return FileReadResponse(error=0, data=data)
95+
return FileReadResponse(_errno_from_exception(ex), b"")
96+
return FileReadResponse(0, data)
8397

8498

8599
async def _serve_request(arrival: Arrival, request: FileReadRequest) -> None:
86100
response = _read_chunk(request.file_path, request.read_offset)
87-
payload = _encode_response(response)
101+
payload = response.serialize()
88102
_logger.info(
89103
"responding: file=%r offset=%d size=%d error=%d",
90104
request.file_path,
@@ -104,36 +118,20 @@ async def _serve_request(arrival: Arrival, request: FileReadRequest) -> None:
104118
_logger.warning("response send failed: remote=%016x error=%s", arrival.breadcrumb.remote_id, ex)
105119

106120

107-
def _on_task_done(tasks: set[asyncio.Task[None]], task: asyncio.Task[None]) -> None:
108-
tasks.discard(task)
109-
if task.cancelled():
110-
return
111-
exc = task.exception()
112-
if exc is not None:
113-
_logger.error("file request task failed: %s", exc)
114-
115-
116121
async def run() -> None:
117122
transport = UDPTransport.new()
118123
node = Node.new(transport, NAME)
119124
sub = node.subscribe(TOPIC)
120-
tasks: set[asyncio.Task[None]] = set()
121125
_logger.info("file server ready on %r via %s", TOPIC, transport)
122126
try:
123127
async for arrival in sub:
124-
request = _decode_request(arrival.message)
128+
request = FileReadRequest.deserialize(arrival.message)
125129
if request is None:
126130
_logger.debug("dropping malformed request of size %d", len(arrival.message))
127131
continue
128-
task = asyncio.create_task(_serve_request(arrival, request), name=f"file:{arrival.breadcrumb.tag}")
129-
tasks.add(task)
130-
task.add_done_callback(partial(_on_task_done, tasks))
132+
await _serve_request(arrival, request)
131133
finally:
132134
sub.close()
133-
for task in list(tasks):
134-
task.cancel()
135-
if tasks:
136-
await asyncio.gather(*tasks, return_exceptions=True)
137135
node.close()
138136
transport.close()
139137

src/pycyphal2/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ async def main():
155155
from ._transport import Transport as Transport
156156
from ._transport import TransportArrival as TransportArrival
157157

158-
__version__ = "2.0.0.dev6"
158+
__version__ = "2.0.0.dev7"
159159

160160
# pdoc needs __all__ to display re-exported members.
161161
__all__ = [

tests/test_examples.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66

77
import pytest
88

9-
from examples.file_client import _decode_response, _format_remote_error, _receive_valid_response
10-
from examples.file_server import _decode_request, _read_chunk
9+
from examples.file_client import FileReadResponse, _format_remote_error, _receive_valid_response
10+
from examples.file_server import FileReadRequest, _read_chunk
1111
from pycyphal2 import Instant, LivenessError, Response, ResponseStream
1212

1313

1414
def test_file_example_decoders_reject_trailing_garbage() -> None:
15-
assert _decode_request(struct.pack("<QH", 0, 1) + b"x" + b"y") is None
16-
assert _decode_response(struct.pack("<IH", 0, 0) + b"x") is None
15+
assert FileReadRequest.deserialize(struct.pack("<QH", 0, 1) + b"x" + b"y") is None
16+
assert FileReadResponse.deserialize(struct.pack("<IH", 0, 0) + b"x") is None
1717

1818

1919
def test_file_server_read_chunk_maps_invalid_inputs_to_errors() -> None:

0 commit comments

Comments
 (0)