From 1fac50dc42ab4a70808bdac437f6442ba34921f2 Mon Sep 17 00:00:00 2001 From: Jaagup Averin Date: Mon, 1 Dec 2025 12:06:56 +0200 Subject: [PATCH 1/4] chore: Cleanup validation error message. Additionally fixes an issue where `ValidationError(error)` constructor would re-raise an exception because it requires additional arguments. --- smpclient/__init__.py | 65 ++++++++++++++++++++++++++++++++--------- smpclient/exceptions.py | 16 ++++++---- 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/smpclient/__init__.py b/smpclient/__init__.py index 4bba672..d1d0aa6 100644 --- a/smpclient/__init__.py +++ b/smpclient/__init__.py @@ -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 @@ -52,6 +52,46 @@ logger = logging.getLogger(__name__) +def _hexdump_ascii(data: bytes) -> str: + """Python 3.12+ has builtint hexdump, prior to that we need to reinvent the wheel.""" + 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']})") + return "\n".join(lines) + + +def _create_smp_validation_exception( + header: smpheader.Header, + frame: bytes, + errs: dict[type[smpmsg.Response], ValidationError], +) -> SMPValidationException: + msg: str = ( + 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{_prettify_validation_error(exc)}\n" + + return SMPValidationException(msg=msg, details=details) + + class SMPClient: """Create a client to the SMP server `address`, using `transport`. @@ -120,7 +160,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: @@ -177,23 +217,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 + exc = _create_smp_validation_exception(header, frame, errs) + logger.error(exc.msg + exc.details) + raise exc from None async def upload( self, diff --git a/smpclient/exceptions.py b/smpclient/exceptions.py index 39b973c..31d013c 100644 --- a/smpclient/exceptions.py +++ b/smpclient/exceptions.py @@ -1,13 +1,17 @@ """`smpclient` module exceptions.""" -class SMPClientException(Exception): - ... +class SMPClientException(Exception): ... -class SMPBadSequence(SMPClientException): - ... +class SMPBadSequence(SMPClientException): ... -class SMPUploadError(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) From d006da9b8a347a12f665603077edd3497719dc6a Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 17 Dec 2025 15:48:44 -0800 Subject: [PATCH 2/4] envr: add --diff to black check --- envr-default | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/envr-default b/envr-default index 15617a9..a695c36 100644 --- a/envr-default +++ b/envr-default @@ -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 \ No newline at end of file From d7c9398a1538ecd3e8cd8a2f0d46cc8df5cd25eb Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 17 Dec 2025 15:48:49 -0800 Subject: [PATCH 3/4] black --- smpclient/exceptions.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/smpclient/exceptions.py b/smpclient/exceptions.py index 31d013c..c6700a1 100644 --- a/smpclient/exceptions.py +++ b/smpclient/exceptions.py @@ -1,13 +1,16 @@ """`smpclient` module exceptions.""" -class SMPClientException(Exception): ... +class SMPClientException(Exception): + ... -class SMPBadSequence(SMPClientException): ... +class SMPBadSequence(SMPClientException): + ... -class SMPUploadError(SMPClientException): ... +class SMPUploadError(SMPClientException): + ... class SMPValidationException(SMPClientException): From 10674f7933e8c43cb19dca5a948321adfe286e14 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 17 Dec 2025 16:00:24 -0800 Subject: [PATCH 4/4] lint: fixes --- smpclient/__init__.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/smpclient/__init__.py b/smpclient/__init__.py index d1d0aa6..abfb276 100644 --- a/smpclient/__init__.py +++ b/smpclient/__init__.py @@ -53,7 +53,7 @@ def _hexdump_ascii(data: bytes) -> str: - """Python 3.12+ has builtint hexdump, prior to that we need to reinvent the wheel.""" + """Python 3.12+ has builtin hexdump, prior to that we need to reinvent the wheel.""" lines = [] for i in range(0, len(data), 16): chunk = data[i : i + 16] @@ -73,23 +73,24 @@ def _prettify_validation_error(exc: ValidationError) -> str: return "\n".join(lines) -def _create_smp_validation_exception( +def _smp_validation_error_message( header: smpheader.Header, frame: bytes, errs: dict[type[smpmsg.Response], ValidationError], -) -> SMPValidationException: - msg: str = ( - f"\nFrame could not be parsed as any of:\n\t{[str(t.__name__) for t in errs.keys()]}\n" - ) +) -> 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{_prettify_validation_error(exc)}\n" + details += ( + f"\tCould not be parsed as {cls.__name__} because {len(exc.errors())} errors:\n" + f"{_prettify_validation_error(exc)}\n" + ) - return SMPValidationException(msg=msg, details=details) + return msg, details class SMPClient: @@ -230,9 +231,9 @@ async def request( return request._ErrorV2.loads(frame) except ValidationError as e: errs[request._ErrorV2] = e - exc = _create_smp_validation_exception(header, frame, errs) - logger.error(exc.msg + exc.details) - raise exc from None + msg, details = _smp_validation_error_message(header, frame, errs) + logger.error(msg + details) + raise SMPValidationException(msg, details) async def upload( self,