diff --git a/CHANGELOG.md b/CHANGELOG.md index def7f7c..fa98e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.0 (2026-08-12) + +- `CreateLogEntries` now accepts a payload that is already encoded as JSON `bytes` and sends those bytes + as the request body unchanged. Callers that serialize an entry for their own reasons, such as validating + it before upload, can pass the result through instead of encoding the same payload twice. + ## 1.0.0 (2026-07-31) - Use `msgspec` for all JSON encoding and decoding, with a `ujson` fallback for types `msgspec` cannot serialize natively (such as `numpy` scalars and arrays). diff --git a/python/mujintestwebstackclient/test_webstackclient.py b/python/mujintestwebstackclient/test_webstackclient.py index 48fae7b..95f876a 100644 --- a/python/mujintestwebstackclient/test_webstackclient.py +++ b/python/mujintestwebstackclient/test_webstackclient.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import msgspec import pytest import requests_mock import random @@ -7,6 +8,8 @@ import copy import graphql +from unittest import mock + from mujinwebstackclient.webstackclient import WebstackClient from mujinwebstackclient.webstackclientutils import QueryIterator, GetMaximumQueryLimit from mujinwebstackclient.webstackgraphclientutils import GraphQueryIterator @@ -489,3 +492,32 @@ def test_LazyQueryStandardListOperations(): del scenes[start:end] del expectedScenes[start:end] assert scenes == expectedScenes + + +def test_CreateLogEntriesAcceptsPreEncodedPayloads(): + """A payload handed over as JSON bytes must reach the request body unchanged. + + Callers that already serialized an entry, such as to validate it before uploading, rely on this to avoid + encoding a second copy of every payload they send. + """ + webstackclient = WebstackClient.__new__(WebstackClient) + webstackclient._webclient = mock.Mock() + webstackclient._webclient.EncodeJSON.side_effect = msgspec.json.Encoder().encode + + logEntry = { + 'occurredAt': '2026-08-12T00:00:00Z', + 'version': 1, + 'soukoExecutionTask': {'taskId': 'aeon_cycleCount:C6E3KZP5K1LQMQL5', 'taskType': 'aeon_cycleCount'}, + } + + webstackclient.CreateLogEntries(logEntries=[('SoukoExecutionTask', logEntry, {})]) + filesFromDict = webstackclient._webclient.APICall.call_args.kwargs['files'] + + webstackclient._webclient.reset_mock() + webstackclient.CreateLogEntries(logEntries=[('SoukoExecutionTask', msgspec.json.encode(logEntry), {})]) + filesFromBytes = webstackclient._webclient.APICall.call_args.kwargs['files'] + + # Both forms put the same bytes on the wire, so a caller can switch to the encoded form freely. + assert filesFromBytes == filesFromDict + # The encoded payload went out as given rather than being run through the encoder again. + webstackclient._webclient.EncodeJSON.assert_not_called() diff --git a/python/mujinwebstackclient/version.py b/python/mujinwebstackclient/version.py index ce4357f..0308f96 100644 --- a/python/mujinwebstackclient/version.py +++ b/python/mujinwebstackclient/version.py @@ -1,3 +1,3 @@ -__version__ = '1.0.0' +__version__ = '1.1.0' # Do not forget to update CHANGELOG.md diff --git a/python/mujinwebstackclient/webstackclient.py b/python/mujinwebstackclient/webstackclient.py index 88d197a..78ce824 100644 --- a/python/mujinwebstackclient/webstackclient.py +++ b/python/mujinwebstackclient/webstackclient.py @@ -11,7 +11,7 @@ from email.utils import parsedate import six -from typing import List, Tuple, Any, Dict # noqa: F401 +from typing import List, Tuple, Any, Dict, Union # noqa: F401 # Mujin imports from . import WebstackClientError @@ -675,10 +675,17 @@ def DeleteJobs(self, timeout=5): # def CreateLogEntries(self, logEntries, timeout=5): - # type: (List[Tuple[str, Any, Dict[str, bytes]]], float) -> Any + # type: (List[Tuple[str, Union[bytes, Any], Dict[str, bytes]]], float) -> Any + """Uploads log entries given as (log type, payload, attachments) tuples. + + A payload may be passed as the JSON bytes of an already-encoded entry. + Callers may have already serialized it to e.g. ensure that it serializes correctly, + and if they have, it makes sense to re-use that already serialized data. + """ files = [] for logType, logEntry, attachments in logEntries: - files.append(('logEntry/%s' % logType, ('', self._webclient.EncodeJSON(logEntry), 'application/json'))) + body = logEntry if isinstance(logEntry, bytes) else self._webclient.EncodeJSON(logEntry) + files.append(('logEntry/%s' % logType, ('', body, 'application/json'))) if attachments is not None: for attachmentName, attachmentData in six.iteritems(attachments): files.append(('attachment', (attachmentName, attachmentData)))