Skip to content
Draft
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
2 changes: 1 addition & 1 deletion envr-default
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ PYTHON_VENV=.venv
[ADD_TO_PATH]

[ALIASES]
lint=black --check . && isort --check-only --diff . && flake8 . && pydoclint smpclient && mypy .
lint=black --check --diff . && isort --check-only --diff . && flake8 . && pydoclint smpclient && mypy .
test=coverage erase && pytest --cov --maxfail=1
66 changes: 53 additions & 13 deletions smpclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from smp import message as smpmsg
from typing_extensions import assert_never

from smpclient.exceptions import SMPBadSequence, SMPUploadError
from smpclient.exceptions import SMPBadSequence, SMPUploadError, SMPValidationException
from smpclient.generics import SMPRequest, TEr1, TEr2, TRep, error, success
from smpclient.requests.file_management import FileDownload, FileUpload
from smpclient.requests.image_management import ImageUploadWrite
Expand All @@ -52,6 +52,47 @@
logger = logging.getLogger(__name__)


def _hexdump_ascii(data: bytes) -> str:
"""Python 3.12+ has builtin hexdump, prior to that we need to reinvent the wheel."""
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _hexdump_ascii docstring claims Python 3.12+ has a built-in hexdump; the stdlib doesn't provide a general-purpose bytes hexdump helper. Consider updating the docstring to reflect why this helper exists (e.g., for readable debug logging) without referencing a non-existent built-in.

Suggested change
"""Python 3.12+ has builtin hexdump, prior to that we need to reinvent the wheel."""
"""Return a human-readable hex + ASCII dump of the given bytes for debug logging."""

Copilot uses AI. Check for mistakes.
lines = []
for i in range(0, len(data), 16):
chunk = data[i : i + 16]
hexpart = " ".join(f"{b:02x}" for b in chunk)
ascpart = "".join(chr(b) if 32 <= b <= 126 else "." for b in chunk)
lines.append(f"\t{i:04x} {hexpart:<47} {ascpart}")
return "\n".join(lines)


def _prettify_validation_error(exc: ValidationError) -> str:
lines: list[str] = []
for err in exc.errors():
err_type = err["type"]
msg = err["msg"]
loc = ".".join(str(x) for x in err["loc"])
lines.append(f"\t\t[{err_type}] {msg}: {loc}; input: {err['input']})")
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formatted error line ends with an unmatched closing parenthesis ("... input: ...)"). This will produce confusing log output; remove the trailing ")" or add a corresponding opening delimiter if you intended to wrap the input value.

Suggested change
lines.append(f"\t\t[{err_type}] {msg}: {loc}; input: {err['input']})")
lines.append(f"\t\t[{err_type}] {msg}: {loc}; input: {err['input']}")

Copilot uses AI. Check for mistakes.
return "\n".join(lines)


def _smp_validation_error_message(
header: smpheader.Header,
frame: bytes,
errs: dict[type[smpmsg.Response], ValidationError],
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type annotations for errs are inconsistent: _smp_validation_error_message() declares errs: dict[type[smpmsg.Response], ValidationError], but request() builds errs: dict[Type, ValidationError] and includes error classes (_ErrorV1/_ErrorV2). This is likely to fail mypy due to dict invariance. Align the types (e.g., make both use dict[type[object] | type[Any], ValidationError], or tighten request() to the exact common base type).

Suggested change
errs: dict[type[smpmsg.Response], ValidationError],
errs: dict[Type, ValidationError],

Copilot uses AI. Check for mistakes.
) -> tuple[str, str]:
msg = f"\nFrame could not be parsed as any of:\n\t{[str(t.__name__) for t in errs.keys()]}\n"

details = ""
details += f"Header:\n\t{header}\n"
details += f"Frame:\n{_hexdump_ascii(frame)}\n"
details += "Errors:\n"
for cls, exc in errs.items():
details += (
f"\tCould not be parsed as {cls.__name__} because {len(exc.errors())} errors:\n"
f"{_prettify_validation_error(exc)}\n"
)

return msg, details


class SMPClient:
"""Create a client to the SMP server `address`, using `transport`.

Expand Down Expand Up @@ -120,7 +161,7 @@ async def request(
Raises:
TimeoutError: if the request times out
SMPBadSequence: if the response sequence does not match the request sequence
ValidationError: if the response cannot be parsed as a Response or Error
SMPValidationException: if the response cannot be parsed as a Response or Error

Examples:

Expand Down Expand Up @@ -177,23 +218,22 @@ async def request(
f"Bad sequence {header.sequence}, expected {request.header.sequence}"
)

errs: dict[Type, ValidationError] = {}
try:
return request._Response.loads(frame) # type: ignore
except ValidationError:
pass
except ValidationError as e:
errs[request._Response] = e
try:
return request._ErrorV1.loads(frame)
except ValidationError:
pass
except ValidationError as e:
errs[request._ErrorV1] = e
try:
return request._ErrorV2.loads(frame)
except ValidationError:
error_message = (
f"Response could not by parsed as one of {request._Response}, "
f"{request._ErrorV1}, or {request._ErrorV2}. {header=} {frame=}"
)
logger.error(error_message)
raise ValidationError(error_message)
except ValidationError as e:
errs[request._ErrorV2] = e
msg, details = _smp_validation_error_message(header, frame, errs)
logger.error(msg + details)
raise SMPValidationException(msg, details)
Comment on lines +234 to +236
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request() now raises SMPValidationException instead of bubbling a pydantic.ValidationError when the frame can't be parsed. There are existing SMPClient request tests, but none covering this new failure mode; please add/adjust a test to assert the new exception type and (at minimum) that details contains header/frame context.

Copilot uses AI. Check for mistakes.

async def upload(
self,
Expand Down
7 changes: 7 additions & 0 deletions smpclient/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ class SMPBadSequence(SMPClientException):

class SMPUploadError(SMPClientException):
...


class SMPValidationException(SMPClientException):
def __init__(self, msg: str, details: str) -> None:
self.msg: str = msg
self.details: str = details
super().__init__(msg)