From 3892a587bccf2ec9e0370ee5793bb9723ba665b0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 12:11:14 +0200 Subject: [PATCH 01/95] Add generated TUS protocol canary --- tests/generated_protocol_contract.py | 332 ++++++++++++++++++++++ tests/test_generated_protocol_contract.py | 152 ++++++++++ tusclient/protocol_generated.py | 5 + tusclient/uploader/baseuploader.py | 3 +- 4 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 tests/generated_protocol_contract.py create mode 100644 tests/test_generated_protocol_contract.py create mode 100644 tusclient/protocol_generated.py diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py new file mode 100644 index 0000000..8b85dc5 --- /dev/null +++ b/tests/generated_protocol_contract.py @@ -0,0 +1,332 @@ +# Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +# If it looks wrong, please report the issue instead of editing this file by hand; +# the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +TUS_WIRE_VERSIONS = [ + { + 'default': True, + 'value': '1.0.0', + }, +] + +TUS_PROTOCOL_OPERATIONS = [ + { + 'operationId': 'discoverTusCapabilities', + 'role': 'capability-discovery', + 'method': 'OPTIONS', + 'path': '/resumable/files/', + 'request': { + 'bodyKind': 'empty', + 'contentType': None, + 'headerVariants': [], + }, + 'responses': [ + { + 'statusCode': 200, + 'bodyKind': 'empty', + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Extension', + 'name': 'tus-extension', + 'required': True, + }, + { + 'displayName': 'Tus-Max-Size', + 'name': 'tus-max-size', + 'required': True, + }, + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + { + 'displayName': 'Tus-Version', + 'name': 'tus-version', + 'required': True, + }, + ], + }, + ], + }, + ], + }, + { + 'operationId': 'createTusUpload', + 'role': 'creation', + 'method': 'POST', + 'path': '/resumable/files/', + 'request': { + 'bodyKind': 'empty', + 'contentType': None, + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + { + 'displayName': 'Upload-Length', + 'name': 'upload-length', + 'required': True, + }, + { + 'displayName': 'Upload-Metadata', + 'name': 'upload-metadata', + 'required': True, + }, + ], + }, + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + { + 'displayName': 'Upload-Defer-Length', + 'name': 'upload-defer-length', + 'required': True, + }, + { + 'displayName': 'Upload-Metadata', + 'name': 'upload-metadata', + 'required': True, + }, + ], + }, + ], + }, + 'responses': [ + { + 'statusCode': 201, + 'bodyKind': 'empty', + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Location', + 'name': 'location', + 'required': True, + }, + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + ], + }, + ], + }, + ], + }, + { + 'operationId': 'getTusUploadOffset', + 'role': 'offset-discovery', + 'method': 'HEAD', + 'path': '/resumable/files/{upload_id}', + 'request': { + 'bodyKind': 'empty', + 'contentType': None, + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + ], + }, + ], + }, + 'responses': [ + { + 'statusCode': 200, + 'bodyKind': 'empty', + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + { + 'displayName': 'Upload-Length', + 'name': 'upload-length', + 'required': True, + }, + { + 'displayName': 'Upload-Offset', + 'name': 'upload-offset', + 'required': True, + }, + ], + }, + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + { + 'displayName': 'Upload-Defer-Length', + 'name': 'upload-defer-length', + 'required': True, + }, + { + 'displayName': 'Upload-Offset', + 'name': 'upload-offset', + 'required': True, + }, + ], + }, + ], + }, + ], + }, + { + 'operationId': 'patchTusUpload', + 'role': 'upload-chunk', + 'method': 'PATCH', + 'path': '/resumable/files/{upload_id}', + 'request': { + 'bodyKind': 'binary', + 'contentType': 'application/offset+octet-stream', + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Content-Type', + 'name': 'content-type', + 'required': True, + }, + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + { + 'displayName': 'Upload-Offset', + 'name': 'upload-offset', + 'required': True, + }, + ], + }, + ], + }, + 'responses': [ + { + 'statusCode': 204, + 'bodyKind': 'empty', + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + { + 'displayName': 'Upload-Offset', + 'name': 'upload-offset', + 'required': True, + }, + ], + }, + ], + }, + ], + }, + { + 'operationId': 'terminateTusUpload', + 'role': 'termination', + 'method': 'DELETE', + 'path': '/resumable/files/{upload_id}', + 'request': { + 'bodyKind': 'empty', + 'contentType': None, + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + ], + }, + ], + }, + 'responses': [ + { + 'statusCode': 204, + 'bodyKind': 'empty', + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + ], + }, + ], + }, + ], + }, + { + 'operationId': 'downloadTusUpload', + 'role': 'download', + 'method': 'GET', + 'path': '/resumable/files/{upload_id}', + 'request': { + 'bodyKind': 'empty', + 'contentType': None, + 'headerVariants': [], + }, + 'responses': [ + { + 'statusCode': 200, + 'bodyKind': 'binary', + 'headerVariants': [], + }, + ], + }, +] + +TUS_CLIENT_FEATURES = [ + { + 'featureId': 'singleUploadLifecycle', + 'operationIds': [ + 'createTusUpload', + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'open-input-source', + 'fingerprint-input', + 'store-resume-url', + 'retry-with-backoff', + 'emit-progress', + 'abort-current-request', + ], + }, + { + 'featureId': 'terminateUpload', + 'operationIds': [ + 'terminateTusUpload', + ], + 'primitives': [ + 'retry-with-backoff', + ], + }, +] diff --git a/tests/test_generated_protocol_contract.py b/tests/test_generated_protocol_contract.py new file mode 100644 index 0000000..1822a3f --- /dev/null +++ b/tests/test_generated_protocol_contract.py @@ -0,0 +1,152 @@ +import io +import unittest +from urllib.parse import urljoin + +import responses + +from tests.generated_protocol_contract import ( + TUS_CLIENT_FEATURES, + TUS_PROTOCOL_OPERATIONS, + TUS_WIRE_VERSIONS, +) +from tusclient.client import TusClient +from tusclient.protocol_generated import DEFAULT_PROTOCOL_VERSION + + +def default_wire_version(): + versions = [version for version in TUS_WIRE_VERSIONS if version["default"]] + if len(versions) != 1: + raise AssertionError("Generated TUS protocol contract must have one default wire version") + return versions[0]["value"] + + +def protocol_operation(operation_id): + for operation in TUS_PROTOCOL_OPERATIONS: + if operation["operationId"] == operation_id: + return operation + raise AssertionError("Missing generated TUS protocol operation: {}".format(operation_id)) + + +def client_feature(feature_id): + for feature in TUS_CLIENT_FEATURES: + if feature["featureId"] == feature_id: + return feature + raise AssertionError("Missing generated TUS client feature: {}".format(feature_id)) + + +def response_for(operation, status_code): + for response in operation["responses"]: + if response["statusCode"] == status_code: + return response + raise AssertionError( + "Missing generated response status {} for {}".format( + status_code, + operation["operationId"], + ) + ) + + +def response_headers_for(response, overrides): + headers = {} + variant = response["headerVariants"][0] + for field in variant["fields"]: + if not field["required"]: + continue + headers[field["displayName"]] = overrides.get( + field["displayName"], + default_wire_version(), + ) + return headers + + +def request_header(request, field): + return request.headers.get(field["displayName"]) or request.headers.get(field["name"]) + + +class GeneratedProtocolContractTest(unittest.TestCase): + @responses.activate + def test_drives_create_and_patch_lifecycle_assertions_from_generated_contract(self): + self.assertEqual(DEFAULT_PROTOCOL_VERSION, default_wire_version()) + + lifecycle = client_feature("singleUploadLifecycle") + create_operation = protocol_operation(lifecycle["operationIds"][0]) + patch_operation = protocol_operation(lifecycle["operationIds"][2]) + client = TusClient("http://tusd.tusdemo.net/files/") + upload_url = urljoin(client.url, "generated-contract") + + create_response = response_for(create_operation, 201) + responses.add( + create_operation["method"], + client.url, + adding_headers=response_headers_for( + create_response, + {"Location": upload_url}, + ), + status=create_response["statusCode"], + ) + + patch_response = response_for(patch_operation, 204) + responses.add( + patch_operation["method"], + upload_url, + adding_headers=response_headers_for( + patch_response, + {"Upload-Offset": "5"}, + ), + status=patch_response["statusCode"], + ) + + uploader = client.uploader( + file_stream=io.BytesIO(b"hello"), + chunk_size=5, + metadata={"filename": "hello.txt"}, + ) + uploader.upload() + + create_request = responses.calls[0].request + self.assertEqual(create_request.method, create_operation["method"]) + for field in create_operation["request"]["headerVariants"][0]["fields"]: + self.assertIsNotNone(request_header(create_request, field)) + self.assertEqual( + request_header( + create_request, + {"displayName": "Tus-Resumable", "name": "tus-resumable"}, + ), + default_wire_version(), + ) + self.assertEqual( + request_header( + create_request, + {"displayName": "Upload-Length", "name": "upload-length"}, + ), + "5", + ) + self.assertEqual( + request_header( + create_request, + {"displayName": "Upload-Metadata", "name": "upload-metadata"}, + ), + "filename aGVsbG8udHh0", + ) + + patch_request = responses.calls[1].request + self.assertEqual(patch_request.method, patch_operation["method"]) + for field in patch_operation["request"]["headerVariants"][0]["fields"]: + self.assertIsNotNone(request_header(patch_request, field)) + self.assertEqual( + request_header( + patch_request, + {"displayName": "Content-Type", "name": "content-type"}, + ), + patch_operation["request"]["contentType"], + ) + self.assertEqual( + request_header( + patch_request, + {"displayName": "Upload-Offset", "name": "upload-offset"}, + ), + "0", + ) + self.assertEqual(patch_request.body, b"hello") + self.assertEqual(uploader.url, upload_url) + self.assertEqual(uploader.offset, 5) diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py new file mode 100644 index 0000000..4be9352 --- /dev/null +++ b/tusclient/protocol_generated.py @@ -0,0 +1,5 @@ +# Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +# If it looks wrong, please report the issue instead of editing this file by hand; +# the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +DEFAULT_PROTOCOL_VERSION = '1.0.0' diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index e5f0379..6f5d99b 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -10,6 +10,7 @@ from tusclient.exceptions import TusCommunicationError from tusclient.request import TusRequest, catch_requests_error from tusclient.fingerprint import fingerprint, interface +from tusclient.protocol_generated import DEFAULT_PROTOCOL_VERSION from tusclient.storage.interface import Storage if TYPE_CHECKING: @@ -96,7 +97,7 @@ class BaseUploader: - upload_length_deferred (Optional[bool]) """ - DEFAULT_HEADERS = {"Tus-Resumable": "1.0.0"} + DEFAULT_HEADERS = {"Tus-Resumable": DEFAULT_PROTOCOL_VERSION} DEFAULT_CHUNK_SIZE = MAXSIZE CHECKSUM_ALGORITHM_PAIR = ( "sha1", From f4e81cde4699e934e24481a8bf4b71d4e6cf2a06 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 12:19:43 +0200 Subject: [PATCH 02/95] Fetch LFS fixtures in CI --- .github/workflows/CI.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 8bfc92e..8db1fcd 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -13,6 +13,8 @@ jobs: steps: - uses: actions/checkout@v6 + with: + lfs: true - name: Set up Python uses: actions/setup-python@v6 From dea66fafd8454066189dcf64087176482c41d6af Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 12:23:33 +0200 Subject: [PATCH 03/95] Make URL storage test portable --- tests/storage_file | 1 - tests/test_uploader.py | 12 ++++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) delete mode 100644 tests/storage_file diff --git a/tests/storage_file b/tests/storage_file deleted file mode 100644 index dfe2bda..0000000 --- a/tests/storage_file +++ /dev/null @@ -1 +0,0 @@ -{"_default": {"1": {"key": "size:1082--md5:cbb679e9dbcf82224fe3bc5fdc881f06", "url": "http://tusd.tusdemo.net/files/foo_bar"}, "2": {"key": "size:49588--md5:ae275d47f1ef9aed4902b0251455e627", "url": "http://tusd.tusdemo.net/files/foo_bar"}}} \ No newline at end of file diff --git a/tests/test_uploader.py b/tests/test_uploader.py index 9196c06..aaec290 100644 --- a/tests/test_uploader.py +++ b/tests/test_uploader.py @@ -10,6 +10,7 @@ import pytest from tusclient import exceptions +from tusclient.fingerprint import fingerprint from tusclient.storage import filestorage from tests import mixin @@ -85,9 +86,16 @@ def test_url(self, filename: str): # test for stored urls responses.add(responses.HEAD, 'http://tusd.tusdemo.net/files/foo_bar', adding_headers={"upload-offset": "10"}) - storage_path = '{}/storage_file'.format(os.path.dirname(os.path.abspath(__file__))) + temp_fp = tempfile.NamedTemporaryFile(delete=False) + temp_fp.close() + storage = filestorage.FileStorage(temp_fp.name) + self.addCleanup(lambda: os.path.exists(temp_fp.name) and os.remove(temp_fp.name)) + self.addCleanup(storage.close) + with open(filename, "rb") as stream: + key = fingerprint.Fingerprint().get_fingerprint(stream) + storage.set_item(key, "http://tusd.tusdemo.net/files/foo_bar") resumable_uploader = self.client.uploader( - file_path=filename, store_url=True, url_storage=filestorage.FileStorage(storage_path) + file_path=filename, store_url=True, url_storage=storage ) self.assertEqual(resumable_uploader.url, "http://tusd.tusdemo.net/files/foo_bar") self.assertEqual(resumable_uploader.offset, 10) From e6d60295fb34a8cc3298198b45b8d1979e7e8244 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 21:18:33 +0200 Subject: [PATCH 04/95] Regenerate TUS protocol contract fixture --- tests/generated_protocol_contract.py | 110 +++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 8b85dc5..a3965f9 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -100,6 +100,49 @@ }, ], }, + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + { + 'displayName': 'Upload-Concat', + 'name': 'upload-concat', + 'required': True, + }, + { + 'displayName': 'Upload-Length', + 'name': 'upload-length', + 'required': True, + }, + { + 'displayName': 'Upload-Metadata', + 'name': 'upload-metadata', + 'required': False, + }, + ], + }, + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + { + 'displayName': 'Upload-Concat', + 'name': 'upload-concat', + 'required': True, + }, + { + 'displayName': 'Upload-Metadata', + 'name': 'upload-metadata', + 'required': False, + }, + ], + }, ], }, 'responses': [ @@ -320,12 +363,79 @@ 'abort-current-request', ], }, + { + 'featureId': 'resumeUpload', + 'operationIds': [ + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'fingerprint-input', + 'resume-from-previous-upload', + 'store-resume-url', + ], + }, + { + 'featureId': 'deferredLengthUpload', + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'defer-upload-length', + 'emit-progress', + ], + }, + { + 'featureId': 'creationWithUpload', + 'operationIds': [ + 'createTusUpload', + ], + 'primitives': [ + 'upload-during-creation', + 'emit-progress', + ], + }, + { + 'featureId': 'overridePatchMethod', + 'operationIds': [ + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'override-patch-method', + ], + }, + { + 'featureId': 'parallelUploadConcat', + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'concatenate-partial-uploads', + 'emit-progress', + ], + }, + { + 'featureId': 'retryOffsetRecovery', + 'operationIds': [ + 'createTusUpload', + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'retry-with-backoff', + 'recover-offset-after-error', + ], + }, { 'featureId': 'terminateUpload', 'operationIds': [ 'terminateTusUpload', ], 'primitives': [ + 'terminate-upload', 'retry-with-backoff', ], }, From e45afe68bc7cbe0eca95d7c502dc7b459b7d1c08 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 22:12:27 +0200 Subject: [PATCH 05/95] Regenerate TUS feature contract fixture --- tests/generated_protocol_contract.py | 433 +++++++++++++++++++++++++++ 1 file changed, 433 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index a3965f9..8a635fc 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -348,7 +348,31 @@ TUS_CLIENT_FEATURES = [ { + 'conformance': { + 'scenarioIds': [ + 'singleUploadLifecycle', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Create an upload, store its URL, upload bytes, and finish successfully.', 'featureId': 'singleUploadLifecycle', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'open-input-source', + 'summary': 'Open the caller input as a sliceable source.', + }, + { + 'kind': 'operation', + 'operationId': 'createTusUpload', + 'summary': 'Create the remote upload resource.', + }, + { + 'kind': 'operation', + 'operationId': 'patchTusUpload', + 'summary': 'Upload bytes until the accepted offset reaches the known length.', + }, + ], 'operationIds': [ 'createTusUpload', 'getTusUploadOffset', @@ -364,7 +388,31 @@ ], }, { + 'conformance': { + 'scenarioIds': [ + 'resumeFromPreviousUpload', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Resume a stored upload URL by discovering the remote offset before patching.', 'featureId': 'resumeUpload', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'resume-from-previous-upload', + 'summary': 'Load a stored upload URL selected by fingerprint.', + }, + { + 'kind': 'operation', + 'operationId': 'getTusUploadOffset', + 'summary': 'Read the server offset for the stored upload URL.', + }, + { + 'kind': 'operation', + 'operationId': 'patchTusUpload', + 'summary': 'Continue uploading from the discovered offset.', + }, + ], 'operationIds': [ 'getTusUploadOffset', 'patchTusUpload', @@ -376,7 +424,31 @@ ], }, { + 'conformance': { + 'scenarioIds': [ + 'deferredLengthUpload', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Create an upload without a known length and declare the length on final PATCH.', 'featureId': 'deferredLengthUpload', + 'flow': [ + { + 'kind': 'operation', + 'operationId': 'createTusUpload', + 'summary': 'Create the upload with deferred length.', + }, + { + 'kind': 'primitive', + 'primitive': 'defer-upload-length', + 'summary': 'Track the source until the final chunk reveals the total size.', + }, + { + 'kind': 'operation', + 'operationId': 'patchTusUpload', + 'summary': 'Declare Upload-Length on the final chunk request.', + }, + ], 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -387,7 +459,26 @@ ], }, { + 'conformance': { + 'scenarioIds': [ + 'creationWithUpload', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Send the first bytes on the creation request when the server/client support it.', 'featureId': 'creationWithUpload', + 'flow': [ + { + 'kind': 'operation', + 'operationId': 'createTusUpload', + 'summary': 'Create the upload while streaming the initial body.', + }, + { + 'kind': 'primitive', + 'primitive': 'upload-during-creation', + 'summary': 'Interpret the creation response as an accepted offset.', + }, + ], 'operationIds': [ 'createTusUpload', ], @@ -397,7 +488,31 @@ ], }, { + 'conformance': { + 'scenarioIds': [ + 'overridePatchMethod', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Tunnel PATCH through POST with the method-override header.', 'featureId': 'overridePatchMethod', + 'flow': [ + { + 'kind': 'operation', + 'operationId': 'getTusUploadOffset', + 'summary': 'Resume from the upload URL before sending bytes.', + }, + { + 'kind': 'primitive', + 'primitive': 'override-patch-method', + 'summary': 'Replace PATCH with POST while preserving the protocol operation intent.', + }, + { + 'kind': 'operation', + 'operationId': 'patchTusUpload', + 'summary': 'Upload bytes through the overridden request.', + }, + ], 'operationIds': [ 'getTusUploadOffset', 'patchTusUpload', @@ -407,7 +522,31 @@ ], }, { + 'conformance': { + 'scenarioIds': [ + 'parallelUploadConcat', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Split one input into partial uploads and concatenate their upload URLs.', 'featureId': 'parallelUploadConcat', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'split-parallel-upload-boundaries', + 'summary': 'Split the input into stable byte ranges.', + }, + { + 'kind': 'operation', + 'operationId': 'createTusUpload', + 'summary': 'Create partial uploads for each range.', + }, + { + 'kind': 'primitive', + 'primitive': 'concatenate-partial-uploads', + 'summary': 'Create the final upload from completed partial upload URLs.', + }, + ], 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -415,10 +554,35 @@ 'primitives': [ 'concatenate-partial-uploads', 'emit-progress', + 'split-parallel-upload-boundaries', ], }, { + 'conformance': { + 'scenarioIds': [ + 'retryPatchAfterOffsetRecovery', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Recover from a failed chunk by reading the server offset before retrying.', 'featureId': 'retryOffsetRecovery', + 'flow': [ + { + 'kind': 'operation', + 'operationId': 'patchTusUpload', + 'summary': 'Attempt the chunk upload.', + }, + { + 'kind': 'primitive', + 'primitive': 'recover-offset-after-error', + 'summary': 'Discover the accepted offset after a retryable failure.', + }, + { + 'kind': 'operation', + 'operationId': 'getTusUploadOffset', + 'summary': 'Use HEAD to recover the offset before retrying PATCH.', + }, + ], 'operationIds': [ 'createTusUpload', 'getTusUploadOffset', @@ -430,7 +594,26 @@ ], }, { + 'conformance': { + 'scenarioIds': [ + 'terminateWithRetry', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Terminate an upload resource and retry retryable termination failures.', 'featureId': 'terminateUpload', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'terminate-upload', + 'summary': 'Choose server-side termination for an upload URL.', + }, + { + 'kind': 'operation', + 'operationId': 'terminateTusUpload', + 'summary': 'Delete the upload resource.', + }, + ], 'operationIds': [ 'terminateTusUpload', ], @@ -439,4 +622,254 @@ 'retry-with-backoff', ], }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Abort the active request, pending retry timer, and any partial uploads.', + 'featureId': 'abortUpload', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'abort-current-request', + 'summary': 'Cancel in-flight transport work without emitting user callbacks after abort.', + }, + ], + 'operationIds': [], + 'primitives': [ + 'abort-current-request', + ], + }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Expose progress and accepted-chunk callbacks from runtime upload activity.', + 'featureId': 'uploadCallbacks', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'emit-progress', + 'summary': 'Report bytes sent against known or deferred length.', + }, + { + 'kind': 'primitive', + 'primitive': 'emit-chunk-complete', + 'summary': 'Report chunk size, accepted offset, and total size after server acceptance.', + }, + { + 'kind': 'primitive', + 'primitive': 'emit-upload-url', + 'summary': 'Notify once a usable upload URL is known.', + }, + ], + 'operationIds': [], + 'primitives': [ + 'emit-progress', + 'emit-chunk-complete', + 'emit-upload-url', + ], + }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Run before-request, after-response, and custom retry hooks around transport.', + 'featureId': 'requestLifecycleHooks', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'run-request-hooks', + 'summary': 'Call user hooks around each HTTP request/response pair.', + }, + { + 'kind': 'primitive', + 'primitive': 'customize-retry', + 'summary': 'Let user retry policy override default retry decisions.', + }, + ], + 'operationIds': [], + 'primitives': [ + 'customize-retry', + 'run-request-hooks', + ], + }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Persist, find, resume, and optionally remove upload URLs by fingerprint.', + 'featureId': 'resumeUrlStorage', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'fingerprint-input', + 'summary': 'Derive a stable key for the input when possible.', + }, + { + 'kind': 'primitive', + 'primitive': 'store-resume-url', + 'summary': 'Persist upload URLs and partial-upload URLs for future resumption.', + }, + { + 'kind': 'primitive', + 'primitive': 'remove-stored-url-on-success', + 'summary': 'Remove stored upload URLs when configured after success or invalidation.', + }, + ], + 'operationIds': [], + 'primitives': [ + 'fingerprint-input', + 'store-resume-url', + 'remove-stored-url-on-success', + ], + }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Support the reference client input/source families across runtimes.', + 'featureId': 'inputSources', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'read-browser-file', + 'summary': 'Read browser Blob/File and ArrayBuffer-family inputs.', + }, + { + 'kind': 'primitive', + 'primitive': 'read-node-stream', + 'summary': 'Read Node streams when size and chunk constraints are satisfied.', + }, + { + 'kind': 'primitive', + 'primitive': 'read-web-stream', + 'summary': 'Read Web Streams with deferred or configured size.', + }, + { + 'kind': 'primitive', + 'primitive': 'read-node-file', + 'summary': 'Read filesystem paths and fs streams, including parallel ranges.', + }, + ], + 'operationIds': [], + 'primitives': [ + 'read-browser-file', + 'read-node-file', + 'read-node-stream', + 'read-web-stream', + ], + }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Support browser and file-backed URL storage implementations.', + 'featureId': 'urlStorageBackends', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'store-browser-url', + 'summary': 'Persist upload records in browser localStorage.', + }, + { + 'kind': 'primitive', + 'primitive': 'store-file-url', + 'summary': 'Persist upload records in the Node file store.', + }, + ], + 'operationIds': [], + 'primitives': [ + 'store-browser-url', + 'store-file-url', + ], + }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Select between tus v1 and supported IETF draft client protocol modes.', + 'featureId': 'protocolVersionSelection', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'select-client-protocol', + 'summary': 'Choose request headers and response expectations for the selected protocol.', + }, + ], + 'operationIds': [ + 'createTusUpload', + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'select-client-protocol', + ], + }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Normalize relative Location headers against the request endpoint.', + 'featureId': 'relativeLocationResolution', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'resolve-relative-location', + 'summary': 'Resolve server Location headers with the creation endpoint as origin.', + }, + ], + 'operationIds': [ + 'createTusUpload', + ], + 'primitives': [ + 'resolve-relative-location', + ], + }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Validate option combinations before starting runtime work.', + 'featureId': 'startOptionValidation', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'validate-start-options', + 'summary': 'Reject missing inputs and incompatible parallel/deferred/resume options.', + }, + ], + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + }, + { + 'conformance': { + 'scenarioIds': [], + 'status': 'needs-generated-scenario', + }, + 'description': 'Attach request, response, status, body, and request ID context to errors.', + 'featureId': 'detailedErrors', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'report-detailed-errors', + 'summary': 'Return user-facing errors with enough transport context for debugging.', + }, + ], + 'operationIds': [], + 'primitives': [ + 'report-detailed-errors', + ], + }, ] From 3104c0500a651fcbfea08290d443db0d4968cde5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 11:35:33 +0200 Subject: [PATCH 06/95] Regenerate upload body protocol fixture --- tests/generated_protocol_contract.py | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 8a635fc..a8c1d10 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -487,6 +487,35 @@ 'emit-progress', ], }, + { + 'conformance': { + 'scenarioIds': [ + 'uploadBodyHeaders', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Send protocol-specific upload body headers whenever the client transmits file bytes.', + 'featureId': 'uploadBodyHeaders', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'send-upload-body-headers', + 'summary': 'Attach the protocol-specific upload body content type when a request has bytes.', + }, + { + 'kind': 'operation', + 'operationId': 'patchTusUpload', + 'summary': 'Upload bytes with the protocol-specific body headers.', + }, + ], + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'send-upload-body-headers', + ], + }, { 'conformance': { 'scenarioIds': [ From 329e11657ca0471804713ad0dee8db0f5e939642 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 22:39:50 +0200 Subject: [PATCH 07/95] Assert generated TUS upload events --- tests/generated_protocol_contract.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index a8c1d10..d2bd16d 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -672,8 +672,12 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'singleUploadLifecycle', + 'creationWithUpload', + 'resumeFromPreviousUpload', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Expose progress and accepted-chunk callbacks from runtime upload activity.', 'featureId': 'uploadCallbacks', From 89a509966d920b185a79eb044e82e73dda2df976 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 23:19:53 +0200 Subject: [PATCH 08/95] Cover TUS request lifecycle conformance --- tests/generated_protocol_contract.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index d2bd16d..b7840f6 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -707,8 +707,11 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'requestLifecycleHooks', + 'retryPatchAfterOffsetRecovery', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Run before-request, after-response, and custom retry hooks around transport.', 'featureId': 'requestLifecycleHooks', From 54c7990a145f4c836bcf99f00aa7f26a54f6d995 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:06:10 +0200 Subject: [PATCH 09/95] Cover TUS abort conformance --- tests/generated_protocol_contract.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index b7840f6..db31b0c 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -653,8 +653,10 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'abortUpload', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Abort the active request, pending retry timer, and any partial uploads.', 'featureId': 'abortUpload', From bdf01804e0f7bb013cefeb2cc5939efe6e0ee9cb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:14:09 +0200 Subject: [PATCH 10/95] Cover TUS URL storage conformance --- tests/generated_protocol_contract.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index db31b0c..4617318 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -737,8 +737,11 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'singleUploadLifecycle', + 'resumeFromPreviousUpload', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Persist, find, resume, and optionally remove upload URLs by fingerprint.', 'featureId': 'resumeUrlStorage', From 562998b5af4167a555c7fe4c65b75190f9318381 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:19:28 +0200 Subject: [PATCH 11/95] Cover TUS relative Location conformance --- tests/generated_protocol_contract.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 4617318..0221e6c 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -856,8 +856,10 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'relativeLocationResolution', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Normalize relative Location headers against the request endpoint.', 'featureId': 'relativeLocationResolution', From 744fffab8b1920b10ca801055583b99ad70bea60 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:48:41 +0200 Subject: [PATCH 12/95] Refresh TUS input source contract --- tests/generated_protocol_contract.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 0221e6c..b114fb4 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -771,8 +771,14 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'arrayBufferInput', + 'arrayBufferViewInput', + 'webReadableStreamInput', + 'nodeReadableStreamInput', + 'nodePathInput', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Support the reference client input/source families across runtimes.', 'featureId': 'inputSources', From d9ddd68f1b4211ae91c046f05d19dbf70972a448 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 18:10:38 +0200 Subject: [PATCH 13/95] Refresh TUS retry state contract --- tests/generated_protocol_contract.py | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index b114fb4..62fbb3a 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -622,6 +622,37 @@ 'recover-offset-after-error', ], }, + { + 'conformance': { + 'scenarioIds': [ + 'retryPatchAfterOffsetRecovery', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Schedule retry timers and reset retry attempts after accepted progress.', + 'featureId': 'retryStateTransitions', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'schedule-retry-timer', + 'summary': 'Consume the current retry delay and restart the upload after that timer fires.', + }, + { + 'kind': 'primitive', + 'primitive': 'reset-retry-attempt-after-progress', + 'summary': 'Reset retry attempts once a later retry observes server-side offset progress.', + }, + ], + 'operationIds': [ + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'retry-with-backoff', + 'schedule-retry-timer', + 'reset-retry-attempt-after-progress', + ], + }, { 'conformance': { 'scenarioIds': [ From beae40cb0a4a06e93407da29e668560001b5e5fd Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 20:09:08 +0200 Subject: [PATCH 14/95] Refresh TUS URL storage contract --- tests/generated_protocol_contract.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 62fbb3a..ed37f03 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -845,8 +845,11 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'webStorageUrlStorageBackend', + 'fileUrlStorageBackend', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Support browser and file-backed URL storage implementations.', 'featureId': 'urlStorageBackends', From 2450c801bcb8cb307e5866ef347bcbe40edf3e9a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 21:06:25 +0200 Subject: [PATCH 15/95] Refresh TUS protocol selection contract --- tests/generated_protocol_contract.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index ed37f03..5849069 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -873,8 +873,11 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'ietfDraft05CreationWithUpload', + 'ietfDraft03ResumeWithoutKnownLength', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Select between tus v1 and supported IETF draft client protocol modes.', 'featureId': 'protocolVersionSelection', From 42eed310d011c390363a7e9c2c134a6ad7ff92e8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 22:26:26 +0200 Subject: [PATCH 16/95] Refresh TUS start validation contract --- tests/generated_protocol_contract.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 5849069..42306cf 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -922,8 +922,18 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'startValidationMissingInput', + 'startValidationMissingEndpointOrUploadUrl', + 'startValidationUnsupportedProtocol', + 'startValidationRetryDelaysNotArray', + 'startValidationParallelUploadsWithUploadUrl', + 'startValidationParallelUploadsWithUploadSize', + 'startValidationParallelUploadsWithDeferredLength', + 'startValidationParallelBoundariesWithoutParallelUploads', + 'startValidationParallelBoundariesLengthMismatch', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Validate option combinations before starting runtime work.', 'featureId': 'startOptionValidation', From f4a1e15f7c96d7f45a00dd5cdf4266cba39c798d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 23:10:16 +0200 Subject: [PATCH 17/95] Update detailed error conformance --- tests/generated_protocol_contract.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 42306cf..de292d6 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -951,8 +951,11 @@ }, { 'conformance': { - 'scenarioIds': [], - 'status': 'needs-generated-scenario', + 'scenarioIds': [ + 'detailedCreateResponseError', + 'detailedCreateRequestError', + ], + 'status': 'covered-by-generated-scenario', }, 'description': 'Attach request, response, status, body, and request ID context to errors.', 'featureId': 'detailedErrors', From 4daa6dc5f9b8cae4320a9618db4c7ff1253ec8ff Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 13:41:05 +0200 Subject: [PATCH 18/95] Expose generated conformance scenarios --- tests/generated_protocol_contract.py | 2324 ++++++++++++++++++++- tests/test_generated_protocol_contract.py | 22 + 2 files changed, 2344 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index de292d6..7fda19f 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -462,6 +462,7 @@ 'conformance': { 'scenarioIds': [ 'creationWithUpload', + 'creationWithUploadPartialChunk', ], 'status': 'covered-by-generated-scenario', }, @@ -481,6 +482,7 @@ ], 'operationIds': [ 'createTusUpload', + 'patchTusUpload', ], 'primitives': [ 'upload-during-creation', @@ -516,6 +518,40 @@ 'send-upload-body-headers', ], }, + { + 'conformance': { + 'scenarioIds': [ + 'customRequestHeaders', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Apply user-provided request headers to every upload request.', + 'featureId': 'customRequestHeaders', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'apply-custom-request-headers', + 'summary': 'Merge user-provided headers after protocol headers are prepared.', + }, + { + 'kind': 'operation', + 'operationId': 'createTusUpload', + 'summary': 'Create uploads with the configured custom headers.', + }, + { + 'kind': 'operation', + 'operationId': 'patchTusUpload', + 'summary': 'Upload bytes with the configured custom headers.', + }, + ], + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'apply-custom-request-headers', + ], + }, { 'conformance': { 'scenarioIds': [ @@ -554,10 +590,11 @@ 'conformance': { 'scenarioIds': [ 'parallelUploadConcat', + 'parallelUploadAbortCleanup', ], 'status': 'covered-by-generated-scenario', }, - 'description': 'Split one input into partial uploads and concatenate their upload URLs.', + 'description': 'Split one input into partial uploads, run the parts concurrently, clean up aborted parts, and concatenate their upload URLs.', 'featureId': 'parallelUploadConcat', 'flow': [ { @@ -584,6 +621,7 @@ 'concatenate-partial-uploads', 'emit-progress', 'split-parallel-upload-boundaries', + 'terminate-upload', ], }, { @@ -686,6 +724,7 @@ 'conformance': { 'scenarioIds': [ 'abortUpload', + 'abortUploadAfterStoredUrl', ], 'status': 'covered-by-generated-scenario', }, @@ -698,9 +737,12 @@ 'summary': 'Cancel in-flight transport work without emitting user callbacks after abort.', }, ], - 'operationIds': [], + 'operationIds': [ + 'terminateTusUpload', + ], 'primitives': [ 'abort-current-request', + 'terminate-upload', ], }, { @@ -930,6 +972,7 @@ 'startValidationParallelUploadsWithUploadUrl', 'startValidationParallelUploadsWithUploadSize', 'startValidationParallelUploadsWithDeferredLength', + 'startValidationParallelUploadsWithUploadDataDuringCreation', 'startValidationParallelBoundariesWithoutParallelUploads', 'startValidationParallelBoundariesLengthMismatch', ], @@ -972,3 +1015,2280 @@ ], }, ] + +TUS_CLIENT_CONFORMANCE_SCENARIOS = [ + { + 'behavior': 'single-upload-lifecycle', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/generated-contract', + }, + 'featureId': 'singleUploadLifecycle', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'fingerprint': 'contract-single-fingerprint', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'open-input-source', + 'fingerprint-input', + 'store-resume-url', + 'retry-with-backoff', + 'emit-progress', + 'abort-current-request', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/generated-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'singleUploadLifecycle', + 'events': [ + { + 'fingerprint': 'contract-single-fingerprint', + 'kind': 'fingerprint', + 'key': 'fingerprint:contract-single-fingerprint', + }, + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'fingerprint': 'contract-single-fingerprint', + 'kind': 'url-storage-add', + 'uploadUrl': 'https://tus.io/uploads/generated-contract', + 'key': 'url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract', + }, + { + 'bytesSent': 0, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:0:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'bytesAccepted': 11, + 'bytesTotal': 11, + 'chunkSize': 11, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:11:11:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'creation-with-upload', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-contract', + }, + 'featureId': 'creationWithUpload', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + 'uploadDataDuringCreation': True, + }, + 'operationIds': [ + 'createTusUpload', + ], + 'primitives': [ + 'upload-during-creation', + 'emit-progress', + ], + 'requests': [ + { + 'bodySize': 11, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/creation-with-upload-contract', + 'Upload-Offset': '11', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + ], + 'scenarioId': 'creationWithUpload', + 'events': [ + { + 'bytesSent': 0, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:0:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'creation-with-upload-partial-chunk', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', + }, + 'featureId': 'creationWithUpload', + 'input': { + 'chunkSize': 5, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + 'uploadDataDuringCreation': True, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'upload-during-creation', + 'emit-progress', + ], + 'requests': [ + { + 'bodySize': 5, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/creation-with-upload-partial-contract', + 'Upload-Offset': '5', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 5, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '10', + }, + 'statusCode': 204, + }, + 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', + 'url': 'upload', + }, + { + 'bodySize': 1, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '10', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', + 'url': 'upload', + }, + ], + 'scenarioId': 'creationWithUploadPartialChunk', + 'events': [ + { + 'bytesSent': 0, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:0:11', + }, + { + 'bytesSent': 5, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:5:11', + }, + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'bytesSent': 5, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:5:11', + }, + { + 'bytesSent': 10, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:10:11', + }, + { + 'bytesAccepted': 10, + 'bytesTotal': 11, + 'chunkSize': 5, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:5:10:11', + }, + { + 'bytesSent': 10, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:10:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'bytesAccepted': 11, + 'bytesTotal': 11, + 'chunkSize': 1, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:1:11:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'creation-with-upload', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-contract', + }, + 'featureId': 'protocolVersionSelection', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + 'protocol': 'ietf-draft-05', + 'uploadDataDuringCreation': True, + }, + 'operationIds': [ + 'createTusUpload', + ], + 'primitives': [ + 'select-client-protocol', + ], + 'requests': [ + { + 'absentHeaders': [ + 'Tus-Resumable', + ], + 'bodySize': 11, + 'headerMode': 'exact', + 'headers': { + 'Content-Type': 'application/partial-upload', + 'Upload-Complete': '?1', + 'Upload-Draft-Interop-Version': '6', + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headerMode': 'exact', + 'headers': { + 'Location': 'https://tus.io/uploads/ietf-draft-05-contract', + 'Upload-Offset': '11', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + ], + 'scenarioId': 'ietfDraft05CreationWithUpload', + 'events': [ + { + 'bytesSent': 0, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:0:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'upload-body-headers', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', + }, + 'featureId': 'protocolVersionSelection', + 'input': { + 'chunkSize': 6, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'protocol': 'ietf-draft-03', + 'uploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', + }, + 'operationIds': [ + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'select-client-protocol', + ], + 'requests': [ + { + 'absentHeaders': [ + 'Tus-Resumable', + ], + 'headerMode': 'exact', + 'headers': { + 'Upload-Draft-Interop-Version': '5', + }, + 'operationId': 'getTusUploadOffset', + 'response': { + 'headerMode': 'exact', + 'headers': { + 'Upload-Offset': '5', + }, + 'statusCode': 200, + }, + 'url': 'upload', + }, + { + 'absentHeaders': [ + 'Content-Type', + 'Tus-Resumable', + ], + 'bodySize': 6, + 'headerMode': 'exact', + 'headers': { + 'Upload-Complete': '?1', + 'Upload-Draft-Interop-Version': '5', + 'Upload-Offset': '5', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headerMode': 'exact', + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'ietfDraft03ResumeWithoutKnownLength', + 'events': [ + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'bytesSent': 5, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:5:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'bytesAccepted': 11, + 'bytesTotal': 11, + 'chunkSize': 6, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:6:11:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: no file or stream to upload provided', + 'reason': 'missingInput', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': '', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'none', + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationMissingInput', + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: neither an endpoint or an upload URL is provided', + 'reason': 'missingEndpointOrUploadUrl', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': 'hello world', + 'kind': 'blob', + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationMissingEndpointOrUploadUrl', + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: unsupported protocol tus-v9', + 'reason': 'unsupportedProtocol', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'protocol': 'tus-v9', + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationUnsupportedProtocol', + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: the `retryDelays` option must either be an array or null', + 'reason': 'retryDelaysNotArray', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'rawOptions': { + 'retryDelays': 44, + }, + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationRetryDelaysNotArray', + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + 'reason': 'parallelUploadsWithUploadUrl', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'parallelUploads': 2, + 'uploadUrl': 'https://tus.io/uploads/start-validation-upload-url', + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationParallelUploadsWithUploadUrl', + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', + 'reason': 'parallelUploadsWithUploadSize', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'parallelUploads': 2, + 'uploadSize': 11, + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationParallelUploadsWithUploadSize', + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', + 'reason': 'parallelUploadsWithDeferredLength', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'parallelUploads': 2, + 'uploadLengthDeferred': True, + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationParallelUploadsWithDeferredLength', + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', + 'reason': 'parallelUploadsWithUploadDataDuringCreation', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'parallelUploads': 2, + 'uploadDataDuringCreation': True, + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationParallelUploadsWithUploadDataDuringCreation', + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', + 'reason': 'parallelBoundariesWithoutParallelUploads', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'parallelUploadBoundaries': [ + { + 'end': 5, + 'start': 0, + }, + ], + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationParallelBoundariesWithoutParallelUploads', + }, + { + 'behavior': 'start-option-validation', + 'completion': { + 'kind': 'error', + 'message': 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', + 'reason': 'parallelBoundariesLengthMismatch', + }, + 'featureId': 'startOptionValidation', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'parallelUploadBoundaries': [ + { + 'end': 5, + 'start': 0, + }, + ], + 'parallelUploads': 2, + }, + 'operationIds': [], + 'primitives': [ + 'validate-start-options', + ], + 'requests': [], + 'scenarioId': 'startValidationParallelBoundariesLengthMismatch', + }, + { + 'behavior': 'detailed-error', + 'completion': { + 'kind': 'error', + 'message': 'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)', + 'reason': 'unexpectedCreateResponse', + }, + 'featureId': 'detailedErrors', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'headers': { + 'X-Request-ID': 'contract-request-id', + }, + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + 'rawOptions': { + 'retryDelays': None, + }, + }, + 'operationIds': [ + 'createTusUpload', + ], + 'primitives': [ + 'report-detailed-errors', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + 'X-Request-ID': 'contract-request-id', + }, + 'operationId': 'createTusUpload', + 'response': { + 'body': 'server_error', + 'statusCode': 500, + }, + 'url': 'endpoint', + }, + ], + 'scenarioId': 'detailedCreateResponseError', + }, + { + 'behavior': 'detailed-error', + 'completion': { + 'kind': 'error', + 'message': 'tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)', + 'reason': 'createUploadRequestFailed', + }, + 'featureId': 'detailedErrors', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'headers': { + 'X-Request-ID': 'contract-request-id', + }, + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + 'rawOptions': { + 'retryDelays': None, + }, + }, + 'operationIds': [ + 'createTusUpload', + ], + 'primitives': [ + 'report-detailed-errors', + ], + 'requests': [ + { + 'error': { + 'message': 'socket down', + }, + 'headers': { + 'Upload-Length': '11', + 'X-Request-ID': 'contract-request-id', + }, + 'operationId': 'createTusUpload', + 'url': 'endpoint', + }, + ], + 'scenarioId': 'detailedCreateRequestError', + }, + { + 'behavior': 'upload-body-headers', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/upload-body-headers-contract', + }, + 'featureId': 'uploadBodyHeaders', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'send-upload-body-headers', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/upload-body-headers-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'uploadBodyHeaders', + }, + { + 'behavior': 'custom-request-headers', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/custom-headers-contract', + }, + 'featureId': 'customRequestHeaders', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'headers': { + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'apply-custom-request-headers', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/custom-headers-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'customRequestHeaders', + }, + { + 'behavior': 'resume-from-previous-upload', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/resume-contract', + }, + 'featureId': 'resumeUpload', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'removeFingerprintOnSuccess': True, + 'storedUpload': { + 'fingerprint': 'contract-resume-fingerprint', + 'uploadUrl': 'https://tus.io/uploads/resume-contract', + 'urlStorageKey': 'tus::contract-resume-fingerprint::1337', + }, + }, + 'operationIds': [ + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'fingerprint-input', + 'resume-from-previous-upload', + 'store-resume-url', + ], + 'requests': [ + { + 'operationId': 'getTusUploadOffset', + 'response': { + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '5', + }, + 'statusCode': 200, + }, + 'url': 'upload', + }, + { + 'bodySize': 6, + 'headers': { + 'Upload-Offset': '5', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'resumeFromPreviousUpload', + 'events': [ + { + 'fingerprint': 'contract-resume-fingerprint', + 'kind': 'fingerprint', + 'key': 'fingerprint:contract-resume-fingerprint', + }, + { + 'count': 1, + 'fingerprint': 'contract-resume-fingerprint', + 'kind': 'url-storage-find', + 'key': 'url-storage-find:contract-resume-fingerprint:1', + }, + { + 'fingerprint': 'contract-resume-fingerprint', + 'kind': 'fingerprint', + 'key': 'fingerprint:contract-resume-fingerprint', + }, + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'bytesSent': 5, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:5:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'bytesAccepted': 11, + 'bytesTotal': 11, + 'chunkSize': 6, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:6:11:11', + }, + { + 'kind': 'url-storage-remove', + 'urlStorageKey': 'tus::contract-resume-fingerprint::1337', + 'key': 'url-storage-remove:tus::contract-resume-fingerprint::1337', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'relative-location-resolution', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/files/relative-contract', + }, + 'featureId': 'relativeLocationResolution', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/files/', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'resolve-relative-location', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'relative-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'relativeLocationResolution', + 'events': [ + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'bytesSent': 0, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:0:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'bytesAccepted': 11, + 'bytesTotal': 11, + 'chunkSize': 11, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:11:11:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'array-buffer-input', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/array-buffer-contract', + }, + 'featureId': 'inputSources', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'array-buffer', + 'metadata': { + 'filename': 'hello.txt', + }, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'read-browser-file', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/array-buffer-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'arrayBufferInput', + 'events': [ + { + 'inputKind': 'array-buffer', + 'kind': 'source-open', + 'size': 11, + 'key': 'source-open:array-buffer:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'array-buffer-view-input', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/array-buffer-view-contract', + }, + 'featureId': 'inputSources', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'array-buffer-view', + 'metadata': { + 'filename': 'hello.txt', + }, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'read-browser-file', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/array-buffer-view-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'arrayBufferViewInput', + 'events': [ + { + 'inputKind': 'array-buffer-view', + 'kind': 'source-open', + 'size': 11, + 'key': 'source-open:array-buffer-view:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'web-readable-stream-input', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/web-stream-contract', + }, + 'featureId': 'inputSources', + 'input': { + 'chunkSize': 100, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'web-readable-stream', + 'metadata': { + 'filename': 'hello.txt', + }, + 'uploadLengthDeferred': True, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'read-web-stream', + ], + 'requests': [ + { + 'absentHeaders': [ + 'Upload-Length', + ], + 'headers': { + 'Upload-Defer-Length': '1', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/web-stream-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'webReadableStreamInput', + 'events': [ + { + 'inputKind': 'web-readable-stream', + 'kind': 'source-open', + 'size': None, + 'key': 'source-open:web-readable-stream:null', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'node-readable-stream-input', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/node-stream-contract', + }, + 'featureId': 'inputSources', + 'input': { + 'chunkSize': 100, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'node-readable-stream', + 'metadata': { + 'filename': 'hello.txt', + }, + 'uploadLengthDeferred': True, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'read-node-stream', + ], + 'requests': [ + { + 'absentHeaders': [ + 'Upload-Length', + ], + 'headers': { + 'Upload-Defer-Length': '1', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/node-stream-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'runtimes': [ + 'node', + ], + 'scenarioId': 'nodeReadableStreamInput', + 'events': [ + { + 'inputKind': 'node-readable-stream', + 'kind': 'source-open', + 'size': None, + 'key': 'source-open:node-readable-stream:null', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'node-path-input', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/node-path-contract', + }, + 'featureId': 'inputSources', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'node-path-reference', + 'metadata': { + 'filename': 'hello.txt', + }, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'read-node-file', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/node-path-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'runtimes': [ + 'node', + ], + 'scenarioId': 'nodePathInput', + 'events': [ + { + 'inputKind': 'node-path-reference', + 'kind': 'source-open', + 'size': 11, + 'key': 'source-open:node-path-reference:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'deferred-length-upload', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/deferred-contract', + }, + 'featureId': 'deferredLengthUpload', + 'input': { + 'chunkSize': 100, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'web-readable-stream', + 'metadata': { + 'filename': 'hello.txt', + }, + 'uploadLengthDeferred': True, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'defer-upload-length', + 'emit-progress', + ], + 'requests': [ + { + 'absentHeaders': [ + 'Upload-Length', + ], + 'headers': { + 'Upload-Defer-Length': '1', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/deferred-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'deferredLengthUpload', + 'events': [ + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'bytesSent': 0, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:0:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'bytesAccepted': 11, + 'bytesTotal': 11, + 'chunkSize': 11, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:11:11:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'override-patch-method', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/override-contract', + }, + 'featureId': 'overridePatchMethod', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'overridePatchMethod': True, + 'uploadUrl': 'https://tus.io/uploads/override-contract', + }, + 'operationIds': [ + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'override-patch-method', + ], + 'requests': [ + { + 'operationId': 'getTusUploadOffset', + 'response': { + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '3', + }, + 'statusCode': 200, + }, + 'uploadUrl': 'https://tus.io/uploads/override-contract', + 'url': 'upload', + }, + { + 'bodySize': 8, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '3', + 'X-HTTP-Method-Override': 'PATCH', + }, + 'method': 'POST', + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'uploadUrl': 'https://tus.io/uploads/override-contract', + 'url': 'upload', + }, + ], + 'scenarioId': 'overridePatchMethod', + }, + { + 'behavior': 'parallel-upload-concat', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/parallel-final', + }, + 'featureId': 'parallelUploadConcat', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'metadata': { + 'foo': 'hello', + }, + 'metadataForPartialUploads': { + 'test': 'world', + }, + 'parallelUploads': 2, + }, + 'operationIds': [ + 'createTusUpload', + 'createTusUpload', + 'patchTusUpload', + 'patchTusUpload', + 'createTusUpload', + ], + 'primitives': [ + 'concatenate-partial-uploads', + 'emit-progress', + ], + 'requests': [ + { + 'headers': { + 'Upload-Concat': 'partial', + 'Upload-Length': '5', + 'Upload-Metadata': 'test d29ybGQ=', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/parallel-part-1', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'headers': { + 'Upload-Concat': 'partial', + 'Upload-Length': '6', + 'Upload-Metadata': 'test d29ybGQ=', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/parallel-part-2', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 5, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '5', + }, + 'statusCode': 204, + }, + 'uploadUrl': 'https://tus.io/uploads/parallel-part-1', + 'url': 'upload', + }, + { + 'bodySize': 6, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '6', + }, + 'statusCode': 204, + }, + 'uploadUrl': 'https://tus.io/uploads/parallel-part-2', + 'url': 'upload', + }, + { + 'absentHeaders': [ + 'Upload-Length', + ], + 'headers': { + 'Upload-Concat': 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', + 'Upload-Metadata': 'foo aGVsbG8=', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/parallel-final', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + ], + 'scenarioId': 'parallelUploadConcat', + 'events': [ + { + 'bytesSent': 5, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:5:11', + }, + { + 'bytesAccepted': 5, + 'bytesTotal': 11, + 'chunkSize': 5, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:5:5:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'bytesAccepted': 11, + 'bytesTotal': 11, + 'chunkSize': 6, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:6:11:11', + }, + ], + }, + { + 'behavior': 'parallel-upload-abort-cleanup', + 'completion': { + 'kind': 'aborted', + }, + 'featureId': 'parallelUploadConcat', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'fingerprint': 'contract-parallel-cleanup-fingerprint', + 'headers': { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + 'kind': 'blob', + 'metadataForPartialUploads': { + 'test': 'world', + }, + 'overridePatchMethod': True, + 'parallelUploads': 2, + 'terminateUploadOnAbort': True, + }, + 'operationIds': [ + 'createTusUpload', + 'createTusUpload', + 'patchTusUpload', + 'patchTusUpload', + 'terminateTusUpload', + 'terminateTusUpload', + ], + 'primitives': [ + 'abort-current-request', + 'terminate-upload', + 'concatenate-partial-uploads', + ], + 'requests': [ + { + 'headers': { + 'Upload-Concat': 'partial', + 'Upload-Length': '5', + 'Upload-Metadata': 'test d29ybGQ=', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/parallel-cleanup-part-1', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'headers': { + 'Upload-Concat': 'partial', + 'Upload-Length': '6', + 'Upload-Metadata': 'test d29ybGQ=', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/parallel-cleanup-part-2', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 5, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-HTTP-Method-Override': 'PATCH', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + 'method': 'POST', + 'operationId': 'patchTusUpload', + 'response': { + 'statusCode': 500, + }, + 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-1', + 'url': 'upload', + }, + { + 'abort': True, + 'bodySize': 6, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-HTTP-Method-Override': 'PATCH', + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + 'method': 'POST', + 'operationId': 'patchTusUpload', + 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-2', + 'url': 'upload', + }, + { + 'headers': { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + 'operationId': 'terminateTusUpload', + 'response': { + 'statusCode': 204, + }, + 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-1', + 'url': 'upload', + }, + { + 'headers': { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + 'operationId': 'terminateTusUpload', + 'response': { + 'statusCode': 204, + }, + 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-2', + 'url': 'upload', + }, + ], + 'scenarioId': 'parallelUploadAbortCleanup', + 'events': [ + { + 'kind': 'request-abort', + 'requestIndex': 3, + 'key': 'request-abort:3', + }, + ], + }, + { + 'behavior': 'retry-patch-after-offset-recovery', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/retry-contract', + }, + 'featureId': 'retryOffsetRecovery', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + 'retryDelays': [ + 0, + ], + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + 'getTusUploadOffset', + 'patchTusUpload', + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'retry-with-backoff', + 'recover-offset-after-error', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/retry-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 11, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'statusCode': 500, + }, + 'url': 'upload', + }, + { + 'operationId': 'getTusUploadOffset', + 'response': { + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '5', + }, + 'statusCode': 200, + }, + 'url': 'upload', + }, + { + 'bodySize': 6, + 'headers': { + 'Upload-Offset': '5', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'statusCode': 500, + }, + 'url': 'upload', + }, + { + 'operationId': 'getTusUploadOffset', + 'response': { + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '5', + }, + 'statusCode': 200, + }, + 'url': 'upload', + }, + { + 'bodySize': 6, + 'headers': { + 'Upload-Offset': '5', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'retryPatchAfterOffsetRecovery', + 'events': [ + { + 'decision': True, + 'kind': 'should-retry', + 'retryAttempt': 0, + 'key': 'should-retry:0:true', + }, + { + 'delay': 0, + 'kind': 'retry-schedule', + 'key': 'retry-schedule:0', + }, + { + 'decision': True, + 'kind': 'should-retry', + 'retryAttempt': 0, + 'key': 'should-retry:0:true', + }, + { + 'delay': 0, + 'kind': 'retry-schedule', + 'key': 'retry-schedule:0', + }, + ], + }, + { + 'behavior': 'request-lifecycle-hooks', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/request-hooks-contract', + }, + 'featureId': 'requestLifecycleHooks', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'uploadUrl': 'https://tus.io/uploads/request-hooks-contract', + }, + 'operationIds': [ + 'getTusUploadOffset', + ], + 'primitives': [ + 'run-request-hooks', + ], + 'requests': [ + { + 'operationId': 'getTusUploadOffset', + 'response': { + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '11', + }, + 'statusCode': 200, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'requestLifecycleHooks', + 'events': [ + { + 'kind': 'before-request', + 'requestIndex': 0, + 'key': 'before-request:0', + }, + { + 'kind': 'after-response', + 'requestIndex': 0, + 'key': 'after-response:0', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, + { + 'behavior': 'abort-upload', + 'completion': { + 'kind': 'aborted', + }, + 'featureId': 'abortUpload', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + }, + 'operationIds': [ + 'createTusUpload', + ], + 'primitives': [ + 'abort-current-request', + ], + 'requests': [ + { + 'abort': True, + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'url': 'endpoint', + }, + ], + 'scenarioId': 'abortUpload', + 'events': [ + { + 'kind': 'request-abort', + 'requestIndex': 0, + 'key': 'request-abort:0', + }, + ], + }, + { + 'behavior': 'abort-upload-after-stored-url', + 'completion': { + 'kind': 'aborted', + 'uploadUrl': 'https://tus.io/uploads/abort-terminate-contract', + }, + 'featureId': 'abortUpload', + 'input': { + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'fingerprint': 'contract-abort-terminate-fingerprint', + 'headers': { + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + 'overridePatchMethod': True, + 'terminateUploadOnAbort': True, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + 'terminateTusUpload', + ], + 'primitives': [ + 'abort-current-request', + 'terminate-upload', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/abort-terminate-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'abort': True, + 'bodySize': 11, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + 'X-HTTP-Method-Override': 'PATCH', + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + 'method': 'POST', + 'operationId': 'patchTusUpload', + 'url': 'upload', + }, + { + 'headers': { + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + 'operationId': 'terminateTusUpload', + 'response': { + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'abortUploadAfterStoredUrl', + 'events': [ + { + 'kind': 'request-abort', + 'requestIndex': 1, + 'key': 'request-abort:1', + }, + ], + }, + { + 'behavior': 'terminate-with-retry', + 'completion': { + 'kind': 'terminated', + 'uploadUrl': 'https://tus.io/uploads/terminate-contract', + }, + 'featureId': 'terminateUpload', + 'input': { + 'chunkSize': 5, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + 'retryDelays': [ + 0, + 0, + ], + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + 'terminateTusUpload', + 'terminateTusUpload', + ], + 'primitives': [ + 'terminate-upload', + 'retry-with-backoff', + ], + 'requests': [ + { + 'headers': { + 'Upload-Length': '11', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/terminate-contract', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 5, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '5', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + { + 'operationId': 'terminateTusUpload', + 'response': { + 'statusCode': 423, + }, + 'url': 'upload', + }, + { + 'operationId': 'terminateTusUpload', + 'response': { + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'scenarioId': 'terminateWithRetry', + }, +] diff --git a/tests/test_generated_protocol_contract.py b/tests/test_generated_protocol_contract.py index 1822a3f..ef1d91f 100644 --- a/tests/test_generated_protocol_contract.py +++ b/tests/test_generated_protocol_contract.py @@ -5,6 +5,7 @@ import responses from tests.generated_protocol_contract import ( + TUS_CLIENT_CONFORMANCE_SCENARIOS, TUS_CLIENT_FEATURES, TUS_PROTOCOL_OPERATIONS, TUS_WIRE_VERSIONS, @@ -34,6 +35,13 @@ def client_feature(feature_id): raise AssertionError("Missing generated TUS client feature: {}".format(feature_id)) +def client_scenario(scenario_id): + for scenario in TUS_CLIENT_CONFORMANCE_SCENARIOS: + if scenario["scenarioId"] == scenario_id: + return scenario + raise AssertionError("Missing generated TUS client scenario: {}".format(scenario_id)) + + def response_for(operation, status_code): for response in operation["responses"]: if response["statusCode"] == status_code: @@ -150,3 +158,17 @@ def test_drives_create_and_patch_lifecycle_assertions_from_generated_contract(se self.assertEqual(patch_request.body, b"hello") self.assertEqual(uploader.url, upload_url) self.assertEqual(uploader.offset, 5) + + def test_conformance_scenarios_include_projected_event_keys(self): + feature = client_feature("creationWithUpload") + scenario = client_scenario("creationWithUploadPartialChunk") + event_keys = [event["key"] for event in scenario["events"]] + + self.assertIn(scenario["scenarioId"], feature["conformance"]["scenarioIds"]) + self.assertEqual(scenario["behavior"], "creation-with-upload-partial-chunk") + self.assertEqual(scenario["completion"]["kind"], "success") + self.assertIn("createTusUpload", scenario["operationIds"]) + self.assertIn("patchTusUpload", scenario["operationIds"]) + self.assertIn("upload-during-creation", scenario["primitives"]) + self.assertIn("chunk-complete:5:10:11", event_keys) + self.assertIn("chunk-complete:1:11:11", event_keys) From bc8ec4a8d3d91c388b9650a2ce3838c024adc926 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 13:55:53 +0200 Subject: [PATCH 19/95] Add generated conformance event canary --- tests/test_generated_conformance_events.py | 243 +++++++++++++++++++++ tests/test_generated_protocol_contract.py | 22 -- 2 files changed, 243 insertions(+), 22 deletions(-) create mode 100644 tests/test_generated_conformance_events.py diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py new file mode 100644 index 0000000..ddddc34 --- /dev/null +++ b/tests/test_generated_conformance_events.py @@ -0,0 +1,243 @@ +# Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +# If it looks wrong, please report the issue instead of editing this file by hand; +# the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +import unittest + +from tests.generated_protocol_contract import ( + TUS_CLIENT_CONFORMANCE_SCENARIOS, + TUS_CLIENT_FEATURES, +) + + +CASES = [ + { + 'eventKeys': [ + 'fingerprint:contract-single-fingerprint', + 'upload-url-available', + 'url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], + 'featureId': 'singleUploadLifecycle', + 'scenarioId': 'singleUploadLifecycle', + }, + { + 'eventKeys': [ + 'progress:0:11', + 'progress:11:11', + 'upload-url-available', + 'success', + 'source-close', + ], + 'featureId': 'creationWithUpload', + 'scenarioId': 'creationWithUpload', + }, + { + 'eventKeys': [ + 'progress:0:11', + 'progress:5:11', + 'upload-url-available', + 'progress:5:11', + 'progress:10:11', + 'chunk-complete:5:10:11', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + 'success', + 'source-close', + ], + 'featureId': 'creationWithUpload', + 'scenarioId': 'creationWithUploadPartialChunk', + }, + { + 'eventKeys': [ + 'progress:0:11', + 'progress:11:11', + 'upload-url-available', + 'success', + 'source-close', + ], + 'featureId': 'protocolVersionSelection', + 'scenarioId': 'ietfDraft05CreationWithUpload', + }, + { + 'eventKeys': [ + 'upload-url-available', + 'progress:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + 'success', + 'source-close', + ], + 'featureId': 'protocolVersionSelection', + 'scenarioId': 'ietfDraft03ResumeWithoutKnownLength', + }, + { + 'eventKeys': [ + 'fingerprint:contract-resume-fingerprint', + 'url-storage-find:contract-resume-fingerprint:1', + 'fingerprint:contract-resume-fingerprint', + 'upload-url-available', + 'progress:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + 'url-storage-remove:tus::contract-resume-fingerprint::1337', + 'success', + 'source-close', + ], + 'featureId': 'resumeUpload', + 'scenarioId': 'resumeFromPreviousUpload', + }, + { + 'eventKeys': [ + 'upload-url-available', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], + 'featureId': 'relativeLocationResolution', + 'scenarioId': 'relativeLocationResolution', + }, + { + 'eventKeys': [ + 'source-open:array-buffer:11', + 'success', + 'source-close', + ], + 'featureId': 'inputSources', + 'scenarioId': 'arrayBufferInput', + }, + { + 'eventKeys': [ + 'source-open:array-buffer-view:11', + 'success', + 'source-close', + ], + 'featureId': 'inputSources', + 'scenarioId': 'arrayBufferViewInput', + }, + { + 'eventKeys': [ + 'source-open:web-readable-stream:null', + 'success', + 'source-close', + ], + 'featureId': 'inputSources', + 'scenarioId': 'webReadableStreamInput', + }, + { + 'eventKeys': [ + 'source-open:node-readable-stream:null', + 'success', + 'source-close', + ], + 'featureId': 'inputSources', + 'scenarioId': 'nodeReadableStreamInput', + }, + { + 'eventKeys': [ + 'source-open:node-path-reference:11', + 'success', + 'source-close', + ], + 'featureId': 'inputSources', + 'scenarioId': 'nodePathInput', + }, + { + 'eventKeys': [ + 'upload-url-available', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], + 'featureId': 'deferredLengthUpload', + 'scenarioId': 'deferredLengthUpload', + }, + { + 'eventKeys': [ + 'progress:5:11', + 'chunk-complete:5:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + ], + 'featureId': 'parallelUploadConcat', + 'scenarioId': 'parallelUploadConcat', + }, + { + 'eventKeys': [ + 'request-abort:3', + ], + 'featureId': 'parallelUploadConcat', + 'scenarioId': 'parallelUploadAbortCleanup', + }, + { + 'eventKeys': [ + 'should-retry:0:true', + 'retry-schedule:0', + 'should-retry:0:true', + 'retry-schedule:0', + ], + 'featureId': 'retryOffsetRecovery', + 'scenarioId': 'retryPatchAfterOffsetRecovery', + }, + { + 'eventKeys': [ + 'before-request:0', + 'after-response:0', + 'success', + 'source-close', + ], + 'featureId': 'requestLifecycleHooks', + 'scenarioId': 'requestLifecycleHooks', + }, + { + 'eventKeys': [ + 'request-abort:0', + ], + 'featureId': 'abortUpload', + 'scenarioId': 'abortUpload', + }, + { + 'eventKeys': [ + 'request-abort:1', + ], + 'featureId': 'abortUpload', + 'scenarioId': 'abortUploadAfterStoredUrl', + }, +] + + +def client_feature(feature_id): + for feature in TUS_CLIENT_FEATURES: + if feature["featureId"] == feature_id: + return feature + raise AssertionError("Missing generated TUS client feature: {}".format(feature_id)) + + +def client_scenario(scenario_id): + for scenario in TUS_CLIENT_CONFORMANCE_SCENARIOS: + if scenario["scenarioId"] == scenario_id: + return scenario + raise AssertionError("Missing generated TUS client scenario: {}".format(scenario_id)) + + +class GeneratedTusConformanceEventsTest(unittest.TestCase): + def test_generated_scenario_event_keys(self): + for case in CASES: + scenario = client_scenario(case["scenarioId"]) + feature = client_feature(case["featureId"]) + + self.assertEqual(scenario["featureId"], case["featureId"]) + self.assertIn(scenario["scenarioId"], feature["conformance"]["scenarioIds"]) + self.assertEqual( + [event["key"] for event in scenario["events"]], + case["eventKeys"], + ) diff --git a/tests/test_generated_protocol_contract.py b/tests/test_generated_protocol_contract.py index ef1d91f..1822a3f 100644 --- a/tests/test_generated_protocol_contract.py +++ b/tests/test_generated_protocol_contract.py @@ -5,7 +5,6 @@ import responses from tests.generated_protocol_contract import ( - TUS_CLIENT_CONFORMANCE_SCENARIOS, TUS_CLIENT_FEATURES, TUS_PROTOCOL_OPERATIONS, TUS_WIRE_VERSIONS, @@ -35,13 +34,6 @@ def client_feature(feature_id): raise AssertionError("Missing generated TUS client feature: {}".format(feature_id)) -def client_scenario(scenario_id): - for scenario in TUS_CLIENT_CONFORMANCE_SCENARIOS: - if scenario["scenarioId"] == scenario_id: - return scenario - raise AssertionError("Missing generated TUS client scenario: {}".format(scenario_id)) - - def response_for(operation, status_code): for response in operation["responses"]: if response["statusCode"] == status_code: @@ -158,17 +150,3 @@ def test_drives_create_and_patch_lifecycle_assertions_from_generated_contract(se self.assertEqual(patch_request.body, b"hello") self.assertEqual(uploader.url, upload_url) self.assertEqual(uploader.offset, 5) - - def test_conformance_scenarios_include_projected_event_keys(self): - feature = client_feature("creationWithUpload") - scenario = client_scenario("creationWithUploadPartialChunk") - event_keys = [event["key"] for event in scenario["events"]] - - self.assertIn(scenario["scenarioId"], feature["conformance"]["scenarioIds"]) - self.assertEqual(scenario["behavior"], "creation-with-upload-partial-chunk") - self.assertEqual(scenario["completion"]["kind"], "success") - self.assertIn("createTusUpload", scenario["operationIds"]) - self.assertIn("patchTusUpload", scenario["operationIds"]) - self.assertIn("upload-during-creation", scenario["primitives"]) - self.assertIn("chunk-complete:5:10:11", event_keys) - self.assertIn("chunk-complete:1:11:11", event_keys) From 1f636082b8239245380402cd808c662c53aa03ef Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 19:08:53 +0200 Subject: [PATCH 20/95] Emit generated TUS runtime progress events --- tests/test_generated_runtime_events.py | 132 +++++++++++++++++++++++++ tusclient/uploader/baseuploader.py | 18 +++- tusclient/uploader/uploader.py | 10 ++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/test_generated_runtime_events.py diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py new file mode 100644 index 0000000..c918859 --- /dev/null +++ b/tests/test_generated_runtime_events.py @@ -0,0 +1,132 @@ +# Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +# If it looks wrong, please report the issue instead of editing this file by hand; +# the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +import io +import unittest + +import responses + +from tusclient.client import TusClient + + +CASES = [ + { + 'chunkSize': 11, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'eventKeys': [ + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + ], + 'metadata': { + 'filename': 'hello.txt', + }, + 'requests': [ + { + 'method': 'POST', + 'responseHeaders': { + 'Location': 'https://tus.io/uploads/generated-contract', + }, + 'statusCode': 201, + 'url': 'endpoint', + }, + { + 'method': 'PATCH', + 'responseHeaders': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + 'url': 'upload', + }, + ], + 'scenarioId': 'singleUploadLifecycle', + 'uploadUrl': 'https://tus.io/uploads/generated-contract', + }, + { + 'chunkSize': 11, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/files/', + 'eventKeys': [ + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + ], + 'metadata': { + 'filename': 'hello.txt', + }, + 'requests': [ + { + 'method': 'POST', + 'responseHeaders': { + 'Location': 'relative-contract', + }, + 'statusCode': 201, + 'url': 'endpoint', + }, + { + 'method': 'PATCH', + 'responseHeaders': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + 'url': 'upload', + }, + ], + 'scenarioId': 'relativeLocationResolution', + 'uploadUrl': 'https://tus.io/files/relative-contract', + }, +] + + +def format_event_value(value): + return 'null' if value is None else str(value) + + +def record_progress(events): + def on_progress(bytes_sent, bytes_total): + events.append( + 'progress:{}:{}'.format(bytes_sent, format_event_value(bytes_total)) + ) + return on_progress + + +def record_chunk_complete(events): + def on_chunk_complete(chunk_size, bytes_accepted, bytes_total): + events.append( + 'chunk-complete:{}:{}:{}'.format( + chunk_size, + bytes_accepted, + format_event_value(bytes_total), + ) + ) + return on_chunk_complete + + +class GeneratedTusRuntimeEventsTest(unittest.TestCase): + @responses.activate + def test_sync_uploader_emits_generated_progress_and_chunk_events(self): + for case in CASES: + events = [] + client = TusClient(case['endpointUrl']) + + for request in case['requests']: + url = case['endpointUrl'] if request['url'] == 'endpoint' else case['uploadUrl'] + responses.add( + request['method'], + url, + adding_headers=request['responseHeaders'], + status=request['statusCode'], + ) + + uploader = client.uploader( + file_stream=io.BytesIO(case['content'].encode('utf-8')), + chunk_size=case['chunkSize'], + metadata=case['metadata'], + on_progress=record_progress(events), + on_chunk_complete=record_chunk_complete(events), + ) + uploader.upload() + + self.assertEqual(events, case['eventKeys'], case['scenarioId']) diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index 6f5d99b..812d6d9 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -1,4 +1,4 @@ -from typing import Optional, IO, Dict, Tuple, TYPE_CHECKING, Union +from typing import Callable, Optional, IO, Dict, Tuple, TYPE_CHECKING, Union import os import re from base64 import b64encode @@ -78,6 +78,10 @@ class BaseUploader: Whether or not to declare the upload length when finished reading the file stream instead of when the upload is started. This is useful when uploading from a streaming resource, where the total file size isn't available when the upload is created but only becomes known when the stream finishes. The server must support the `creation-defer-length` extension. + - on_progress (Optional[Callable]): + Callback invoked with bytes sent and total bytes after upload progress changes. + - on_chunk_complete (Optional[Callable]): + Callback invoked with chunk size, accepted offset, and total bytes after a chunk is accepted. :Constructor Args: - file_path (str) @@ -121,6 +125,8 @@ def __init__( fingerprinter: Optional[interface.Fingerprint] = None, upload_checksum=False, upload_length_deferred=False, + on_progress: Optional[Callable[[int, Optional[int]], None]] = None, + on_chunk_complete: Optional[Callable[[int, int, Optional[int]], None]] = None, ): if file_path is None and file_stream is None: raise ValueError("Either 'file_path' or 'file_stream' cannot be None.") @@ -154,6 +160,8 @@ def __init__( self.retry_delay = retry_delay self.upload_checksum = upload_checksum self.upload_length_deferred = upload_length_deferred + self.on_progress = on_progress + self.on_chunk_complete = on_chunk_complete ( self.__checksum_algorithm_name, self.__checksum_algorithm, @@ -283,6 +291,14 @@ def get_request_length(self): return self.chunk_size return min(self.chunk_size, self.stop_at - self.offset) + def notify_progress(self, bytes_sent: int): + if self.on_progress: + self.on_progress(bytes_sent, self.file_size) + + def notify_chunk_complete(self, chunk_size: int, bytes_accepted: int): + if self.on_chunk_complete: + self.on_chunk_complete(chunk_size, bytes_accepted, self.file_size) + def get_file_stream(self): """ Return a file stream instance of the upload. diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index 9b99bf3..17ecc48 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -57,10 +57,15 @@ def upload_chunk(self): self.set_url(self.create_url()) self.offset = 0 + previous_offset = self.offset + self.notify_progress(previous_offset) self._do_request() self.offset = int(self.request.response_headers.get("upload-offset")) if self.upload_length_deferred and self.request.stream_eof: + self.file_size = self.offset self.stop_at = self.offset + self.notify_progress(self.offset) + self.notify_chunk_complete(self.offset - previous_offset, self.offset) @catch_requests_error def create_url(self): @@ -143,10 +148,15 @@ async def upload_chunk(self): self.set_url(await self.create_url()) self.offset = 0 + previous_offset = self.offset + self.notify_progress(previous_offset) await self._do_request() self.offset = int(self.request.response_headers.get("upload-offset")) if self.upload_length_deferred and self.request.stream_eof: + self.file_size = self.offset self.stop_at = self.offset + self.notify_progress(self.offset) + self.notify_chunk_complete(self.offset - previous_offset, self.offset) async def create_url(self): """ From 7e45502dbb73f49237c2aa75a433c9a6e32bdf37 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 20:27:30 +0200 Subject: [PATCH 21/95] Support generated resume cleanup canary --- tests/test_generated_runtime_events.py | 106 +++++++++++++++++++++++++ tusclient/uploader/baseuploader.py | 24 +++++- tusclient/uploader/uploader.py | 2 + 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index c918859..12093fd 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -8,6 +8,8 @@ import responses from tusclient.client import TusClient +from tusclient.fingerprint.interface import Fingerprint +from tusclient.storage.interface import Storage CASES = [ @@ -23,6 +25,7 @@ 'metadata': { 'filename': 'hello.txt', }, + 'removeFingerprintOnSuccess': False, 'requests': [ { 'method': 'POST', @@ -42,8 +45,49 @@ }, ], 'scenarioId': 'singleUploadLifecycle', + 'storedUpload': None, + 'uploadLengthDeferred': False, 'uploadUrl': 'https://tus.io/uploads/generated-contract', }, + { + 'chunkSize': 6, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'eventKeys': [ + 'progress:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + ], + 'metadata': {}, + 'removeFingerprintOnSuccess': True, + 'requests': [ + { + 'method': 'HEAD', + 'responseHeaders': { + 'Upload-Length': '11', + 'Upload-Offset': '5', + }, + 'statusCode': 200, + 'url': 'upload', + }, + { + 'method': 'PATCH', + 'responseHeaders': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + 'url': 'upload', + }, + ], + 'scenarioId': 'resumeFromPreviousUpload', + 'storedUpload': { + 'fingerprint': 'contract-resume-fingerprint', + 'uploadUrl': 'https://tus.io/uploads/resume-contract', + 'urlStorageKey': 'tus::contract-resume-fingerprint::1337', + }, + 'uploadLengthDeferred': False, + 'uploadUrl': 'https://tus.io/uploads/resume-contract', + }, { 'chunkSize': 11, 'content': 'hello world', @@ -56,6 +100,7 @@ 'metadata': { 'filename': 'hello.txt', }, + 'removeFingerprintOnSuccess': False, 'requests': [ { 'method': 'POST', @@ -75,11 +120,35 @@ }, ], 'scenarioId': 'relativeLocationResolution', + 'storedUpload': None, + 'uploadLengthDeferred': False, 'uploadUrl': 'https://tus.io/files/relative-contract', }, ] +class GeneratedTusStorage(Storage): + def __init__(self, values): + self.values = dict(values) + + def get_item(self, key): + return self.values.get(key) + + def set_item(self, key, value): + self.values[key] = value + + def remove_item(self, key): + self.values.pop(key, None) + + +class GeneratedTusFingerprinter(Fingerprint): + def __init__(self, fingerprint): + self.fingerprint = fingerprint + + def get_fingerprint(self, fs): + return self.fingerprint + + def format_event_value(value): return 'null' if value is None else str(value) @@ -110,6 +179,7 @@ def test_sync_uploader_emits_generated_progress_and_chunk_events(self): for case in CASES: events = [] client = TusClient(case['endpointUrl']) + storage = storage_for(case) for request in case['requests']: url = case['endpointUrl'] if request['url'] == 'endpoint' else case['uploadUrl'] @@ -124,9 +194,45 @@ def test_sync_uploader_emits_generated_progress_and_chunk_events(self): file_stream=io.BytesIO(case['content'].encode('utf-8')), chunk_size=case['chunkSize'], metadata=case['metadata'], + store_url=case['storedUpload'] is not None, + url_storage=storage, + fingerprinter=fingerprinter_for(case), + remove_fingerprint_on_success=case['removeFingerprintOnSuccess'], on_progress=record_progress(events), on_chunk_complete=record_chunk_complete(events), ) uploader.upload() self.assertEqual(events, case['eventKeys'], case['scenarioId']) + assert_stored_upload_state(self, case, storage) + + +def storage_for(case): + if case['storedUpload'] is None: + return None + + return GeneratedTusStorage({ + case['storedUpload']['fingerprint']: case['storedUpload']['uploadUrl'], + }) + + +def fingerprinter_for(case): + if case['storedUpload'] is None: + return None + + return GeneratedTusFingerprinter(case['storedUpload']['fingerprint']) + + +def assert_stored_upload_state(test, case, storage): + if case['storedUpload'] is None: + return + + fingerprint = case['storedUpload']['fingerprint'] + if case['removeFingerprintOnSuccess']: + test.assertIsNone(storage.get_item(fingerprint), case['scenarioId']) + else: + test.assertEqual( + storage.get_item(fingerprint), + case['storedUpload']['uploadUrl'], + case['scenarioId'], + ) diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index 812d6d9..405c77d 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -62,6 +62,8 @@ class BaseUploader: If not specified, it defaults to True. - store_url (bool): Determines whether or not url should be stored, and uploads should be resumed. + - remove_fingerprint_on_success (bool): + Determines whether the stored upload URL should be removed after a successful upload. - url_storage (): An implementation of which is an API for URL storage. This value must be set if store_url is set to true. A ready to use implementation exists atbe used out of the box. But you can @@ -95,6 +97,7 @@ class BaseUploader: - retry_delay (Optional[int]) - verify_tls_cert (Optional[bool]) - store_url (Optional[bool]) + - remove_fingerprint_on_success (Optional[bool]) - url_storage (Optinal []) - fingerprinter (Optional []) - upload_checksum (Optional[bool]) @@ -121,6 +124,7 @@ def __init__( retry_delay: int = 30, verify_tls_cert: bool = True, store_url=False, + remove_fingerprint_on_success=False, url_storage: Optional[Storage] = None, fingerprinter: Optional[interface.Fingerprint] = None, upload_checksum=False, @@ -148,6 +152,7 @@ def __init__( self.metadata = metadata or {} self.metadata_encoding = metadata_encoding self.store_url = store_url + self.remove_fingerprint_on_success = remove_fingerprint_on_success self.url_storage = url_storage self.fingerprinter = fingerprinter or fingerprint.Fingerprint() self.offset = 0 @@ -273,8 +278,12 @@ def __init_url_and_offset(self, url: Optional[str] = None): raise error def _get_fingerprint(self): - with self.get_file_stream() as stream: + stream = self.get_file_stream() + try: return self.fingerprinter.get_fingerprint(stream) + finally: + if self.file_stream is None: + stream.close() def set_url(self, url: str): """Set the upload URL""" @@ -299,6 +308,19 @@ def notify_chunk_complete(self, chunk_size: int, bytes_accepted: int): if self.on_chunk_complete: self.on_chunk_complete(chunk_size, bytes_accepted, self.file_size) + def remove_url_on_success(self): + if not ( + self.store_url + and self.url_storage + and self.remove_fingerprint_on_success + ): + return + + if self.file_size is None or self.offset < self.file_size: + return + + self.url_storage.remove_item(self._get_fingerprint()) + def get_file_stream(self): """ Return a file stream instance of the upload. diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index 17ecc48..f38fa79 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -66,6 +66,7 @@ def upload_chunk(self): self.stop_at = self.offset self.notify_progress(self.offset) self.notify_chunk_complete(self.offset - previous_offset, self.offset) + self.remove_url_on_success() @catch_requests_error def create_url(self): @@ -157,6 +158,7 @@ async def upload_chunk(self): self.stop_at = self.offset self.notify_progress(self.offset) self.notify_chunk_complete(self.offset - previous_offset, self.offset) + self.remove_url_on_success() async def create_url(self): """ From eb384a5d8939c13f1e52b3ed83b52d63c457cd69 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 21:02:24 +0200 Subject: [PATCH 22/95] Cover generated deferred-length runtime events --- tests/test_generated_runtime_events.py | 37 ++++++++++++++++++++++++++ tusclient/uploader/uploader.py | 10 +++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 12093fd..3494c56 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -124,6 +124,42 @@ 'uploadLengthDeferred': False, 'uploadUrl': 'https://tus.io/files/relative-contract', }, + { + 'chunkSize': 100, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'eventKeys': [ + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + ], + 'metadata': { + 'filename': 'hello.txt', + }, + 'removeFingerprintOnSuccess': False, + 'requests': [ + { + 'method': 'POST', + 'responseHeaders': { + 'Location': 'https://tus.io/uploads/deferred-contract', + }, + 'statusCode': 201, + 'url': 'endpoint', + }, + { + 'method': 'PATCH', + 'responseHeaders': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + 'url': 'upload', + }, + ], + 'scenarioId': 'deferredLengthUpload', + 'storedUpload': None, + 'uploadLengthDeferred': True, + 'uploadUrl': 'https://tus.io/uploads/deferred-contract', + }, ] @@ -198,6 +234,7 @@ def test_sync_uploader_emits_generated_progress_and_chunk_events(self): url_storage=storage, fingerprinter=fingerprinter_for(case), remove_fingerprint_on_success=case['removeFingerprintOnSuccess'], + upload_length_deferred=case['uploadLengthDeferred'], on_progress=record_progress(events), on_chunk_complete=record_chunk_complete(events), ) diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index f38fa79..bbec2ac 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -58,12 +58,15 @@ def upload_chunk(self): self.offset = 0 previous_offset = self.offset - self.notify_progress(previous_offset) + if not self.upload_length_deferred: + self.notify_progress(previous_offset) self._do_request() self.offset = int(self.request.response_headers.get("upload-offset")) if self.upload_length_deferred and self.request.stream_eof: self.file_size = self.offset self.stop_at = self.offset + if self.upload_length_deferred: + self.notify_progress(previous_offset) self.notify_progress(self.offset) self.notify_chunk_complete(self.offset - previous_offset, self.offset) self.remove_url_on_success() @@ -150,12 +153,15 @@ async def upload_chunk(self): self.offset = 0 previous_offset = self.offset - self.notify_progress(previous_offset) + if not self.upload_length_deferred: + self.notify_progress(previous_offset) await self._do_request() self.offset = int(self.request.response_headers.get("upload-offset")) if self.upload_length_deferred and self.request.stream_eof: self.file_size = self.offset self.stop_at = self.offset + if self.upload_length_deferred: + self.notify_progress(previous_offset) self.notify_progress(self.offset) self.notify_chunk_complete(self.offset - previous_offset, self.offset) self.remove_url_on_success() From ce12bef412cc3e1cba3ea16e4902a1c915058fd7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 22:29:29 +0200 Subject: [PATCH 23/95] Assert generated runtime request headers --- tests/generated_protocol_contract.py | 1 + tests/test_generated_runtime_events.py | 46 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 7fda19f..4c75a8c 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -618,6 +618,7 @@ 'patchTusUpload', ], 'primitives': [ + 'abort-current-request', 'concatenate-partial-uploads', 'emit-progress', 'split-parallel-upload-boundaries', diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 3494c56..232fc74 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -28,6 +28,9 @@ 'removeFingerprintOnSuccess': False, 'requests': [ { + 'headers': { + 'Upload-Length': '11', + }, 'method': 'POST', 'responseHeaders': { 'Location': 'https://tus.io/uploads/generated-contract', @@ -36,6 +39,9 @@ 'url': 'endpoint', }, { + 'headers': { + 'Upload-Offset': '0', + }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', @@ -62,6 +68,7 @@ 'removeFingerprintOnSuccess': True, 'requests': [ { + 'headers': {}, 'method': 'HEAD', 'responseHeaders': { 'Upload-Length': '11', @@ -71,6 +78,9 @@ 'url': 'upload', }, { + 'headers': { + 'Upload-Offset': '5', + }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', @@ -103,6 +113,9 @@ 'removeFingerprintOnSuccess': False, 'requests': [ { + 'headers': { + 'Upload-Length': '11', + }, 'method': 'POST', 'responseHeaders': { 'Location': 'relative-contract', @@ -111,6 +124,9 @@ 'url': 'endpoint', }, { + 'headers': { + 'Upload-Offset': '0', + }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', @@ -139,6 +155,9 @@ 'removeFingerprintOnSuccess': False, 'requests': [ { + 'headers': { + 'Upload-Defer-Length': '1', + }, 'method': 'POST', 'responseHeaders': { 'Location': 'https://tus.io/uploads/deferred-contract', @@ -147,6 +166,10 @@ 'url': 'endpoint', }, { + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', @@ -216,6 +239,7 @@ def test_sync_uploader_emits_generated_progress_and_chunk_events(self): events = [] client = TusClient(case['endpointUrl']) storage = storage_for(case) + first_call_index = len(responses.calls) for request in case['requests']: url = case['endpointUrl'] if request['url'] == 'endpoint' else case['uploadUrl'] @@ -241,6 +265,7 @@ def test_sync_uploader_emits_generated_progress_and_chunk_events(self): uploader.upload() self.assertEqual(events, case['eventKeys'], case['scenarioId']) + assert_request_sequence(self, case, responses.calls[first_call_index:]) assert_stored_upload_state(self, case, storage) @@ -260,6 +285,27 @@ def fingerprinter_for(case): return GeneratedTusFingerprinter(case['storedUpload']['fingerprint']) +def request_header(request, name): + return request.headers.get(name) or request.headers.get(name.lower()) + + +def assert_request_sequence(test, case, calls): + test.assertEqual(len(calls), len(case['requests']), case['scenarioId']) + + for index, expected_request in enumerate(case['requests']): + actual_request = calls[index].request + expected_url = ( + case['endpointUrl'] + if expected_request['url'] == 'endpoint' + else case['uploadUrl'] + ) + + test.assertEqual(actual_request.method, expected_request['method'], case['scenarioId']) + test.assertEqual(actual_request.url, expected_url, case['scenarioId']) + for name, value in expected_request['headers'].items(): + test.assertEqual(request_header(actual_request, name), value, case['scenarioId']) + + def assert_stored_upload_state(test, case, storage): if case['storedUpload'] is None: return From da33ef14da8758d7ac955343b4d0c75bff77ff39 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 23:23:32 +0200 Subject: [PATCH 24/95] Regenerate TUS event policy fixture --- tests/generated_protocol_contract.py | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 4c75a8c..521c431 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1024,6 +1024,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/generated-contract', }, + 'eventPolicy': { + 'matching': 'ordered-subsequence', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'singleUploadLifecycle', 'input': { 'content': 'hello world', @@ -1127,6 +1132,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-contract', }, + 'eventPolicy': { + 'matching': 'ordered-subsequence', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'creationWithUpload', 'input': { 'content': 'hello world', @@ -1196,6 +1206,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', }, + 'eventPolicy': { + 'matching': 'ordered-subsequence', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'creationWithUpload', 'input': { 'chunkSize': 5, @@ -1337,6 +1352,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-contract', }, + 'eventPolicy': { + 'matching': 'ordered-subsequence', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'protocolVersionSelection', 'input': { 'content': 'hello world', @@ -1413,6 +1433,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, + 'eventPolicy': { + 'matching': 'ordered-subsequence', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'protocolVersionSelection', 'input': { 'chunkSize': 6, @@ -1939,6 +1964,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/resume-contract', }, + 'eventPolicy': { + 'matching': 'ordered-subsequence', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'resumeUpload', 'input': { 'content': 'hello world', @@ -2049,6 +2079,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/files/relative-contract', }, + 'eventPolicy': { + 'matching': 'ordered-subsequence', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'relativeLocationResolution', 'input': { 'content': 'hello world', @@ -2498,6 +2533,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/deferred-contract', }, + 'eventPolicy': { + 'matching': 'ordered-subsequence', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'deferredLengthUpload', 'input': { 'chunkSize': 100, @@ -2646,6 +2686,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/parallel-final', }, + 'eventPolicy': { + 'matching': 'ordered-subsequence', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'parallelUploadConcat', 'input': { 'content': 'hello world', From b635e657d743d415ce81183eb819a6c5779ac298 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 07:57:42 +0200 Subject: [PATCH 25/95] Regenerate TUS event contract --- tests/generated_protocol_contract.py | 25 ++++++++++++++-------- tests/test_generated_conformance_events.py | 1 + 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 521c431..a426a87 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1025,7 +1025,7 @@ 'uploadUrl': 'https://tus.io/uploads/generated-contract', }, 'eventPolicy': { - 'matching': 'ordered-subsequence', + 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -1133,7 +1133,7 @@ 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-contract', }, 'eventPolicy': { - 'matching': 'ordered-subsequence', + 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -1207,7 +1207,7 @@ 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', }, 'eventPolicy': { - 'matching': 'ordered-subsequence', + 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -1298,6 +1298,13 @@ 'kind': 'upload-url-available', 'key': 'upload-url-available', }, + { + 'bytesAccepted': 5, + 'bytesTotal': 11, + 'chunkSize': 5, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:5:5:11', + }, { 'bytesSent': 5, 'bytesTotal': 11, @@ -1353,7 +1360,7 @@ 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-contract', }, 'eventPolicy': { - 'matching': 'ordered-subsequence', + 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -1434,7 +1441,7 @@ 'uploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, 'eventPolicy': { - 'matching': 'ordered-subsequence', + 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -1965,7 +1972,7 @@ 'uploadUrl': 'https://tus.io/uploads/resume-contract', }, 'eventPolicy': { - 'matching': 'ordered-subsequence', + 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -2080,7 +2087,7 @@ 'uploadUrl': 'https://tus.io/files/relative-contract', }, 'eventPolicy': { - 'matching': 'ordered-subsequence', + 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -2534,7 +2541,7 @@ 'uploadUrl': 'https://tus.io/uploads/deferred-contract', }, 'eventPolicy': { - 'matching': 'ordered-subsequence', + 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -2687,7 +2694,7 @@ 'uploadUrl': 'https://tus.io/uploads/parallel-final', }, 'eventPolicy': { - 'matching': 'ordered-subsequence', + 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index ddddc34..1ffd549 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -41,6 +41,7 @@ 'progress:0:11', 'progress:5:11', 'upload-url-available', + 'chunk-complete:5:5:11', 'progress:5:11', 'progress:10:11', 'chunk-complete:5:10:11', From 79dc4d881d5cc3fc15ca3306d529f294f97c7934 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 08:18:22 +0200 Subject: [PATCH 26/95] Honor generated TUS event policy --- tests/test_generated_conformance_events.py | 79 ++++++++++++++++++++++ tests/test_generated_runtime_events.py | 68 ++++++++++++++++++- 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index 1ffd549..866ab31 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -22,6 +22,11 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'singleUploadLifecycle', 'scenarioId': 'singleUploadLifecycle', }, @@ -33,6 +38,11 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'creationWithUpload', 'scenarioId': 'creationWithUpload', }, @@ -51,6 +61,11 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'creationWithUpload', 'scenarioId': 'creationWithUploadPartialChunk', }, @@ -62,6 +77,11 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'protocolVersionSelection', 'scenarioId': 'ietfDraft05CreationWithUpload', }, @@ -74,6 +94,11 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'protocolVersionSelection', 'scenarioId': 'ietfDraft03ResumeWithoutKnownLength', }, @@ -90,6 +115,11 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'resumeUpload', 'scenarioId': 'resumeFromPreviousUpload', }, @@ -102,6 +132,11 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'relativeLocationResolution', 'scenarioId': 'relativeLocationResolution', }, @@ -111,6 +146,9 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'scenarioId': 'arrayBufferInput', }, @@ -120,6 +158,9 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'scenarioId': 'arrayBufferViewInput', }, @@ -129,6 +170,9 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'scenarioId': 'webReadableStreamInput', }, @@ -138,6 +182,9 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'scenarioId': 'nodeReadableStreamInput', }, @@ -147,6 +194,9 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'scenarioId': 'nodePathInput', }, @@ -159,6 +209,11 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'deferredLengthUpload', 'scenarioId': 'deferredLengthUpload', }, @@ -169,6 +224,11 @@ 'progress:11:11', 'chunk-complete:6:11:11', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'featureId': 'parallelUploadConcat', 'scenarioId': 'parallelUploadConcat', }, @@ -176,6 +236,9 @@ 'eventKeys': [ 'request-abort:3', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'parallelUploadConcat', 'scenarioId': 'parallelUploadAbortCleanup', }, @@ -186,6 +249,9 @@ 'should-retry:0:true', 'retry-schedule:0', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'retryOffsetRecovery', 'scenarioId': 'retryPatchAfterOffsetRecovery', }, @@ -196,6 +262,9 @@ 'success', 'source-close', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'requestLifecycleHooks', 'scenarioId': 'requestLifecycleHooks', }, @@ -203,6 +272,9 @@ 'eventKeys': [ 'request-abort:0', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'abortUpload', 'scenarioId': 'abortUpload', }, @@ -210,6 +282,9 @@ 'eventKeys': [ 'request-abort:1', ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'abortUpload', 'scenarioId': 'abortUploadAfterStoredUrl', }, @@ -242,3 +317,7 @@ def test_generated_scenario_event_keys(self): [event["key"] for event in scenario["events"]], case["eventKeys"], ) + self.assertEqual( + scenario.get("eventPolicy", {"matching": "exact"}), + case["eventPolicy"], + ) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 232fc74..7cac03d 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -22,6 +22,11 @@ 'progress:11:11', 'chunk-complete:11:11:11', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'metadata': { 'filename': 'hello.txt', }, @@ -64,6 +69,11 @@ 'progress:11:11', 'chunk-complete:6:11:11', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'metadata': {}, 'removeFingerprintOnSuccess': True, 'requests': [ @@ -107,6 +117,11 @@ 'progress:11:11', 'chunk-complete:11:11:11', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'metadata': { 'filename': 'hello.txt', }, @@ -149,6 +164,11 @@ 'progress:11:11', 'chunk-complete:11:11:11', ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, 'metadata': { 'filename': 'hello.txt', }, @@ -232,6 +252,52 @@ def on_chunk_complete(chunk_size, bytes_accepted, bytes_total): return on_chunk_complete +def is_progress_event_key(event_key): + return event_key.startswith('progress:') + + +def assert_events(test, case, events): + expected_events = case['eventKeys'] + event_policy = case.get('eventPolicy', {'matching': 'exact'}) + matching = event_policy['matching'] + + if matching == 'exact': + test.assertEqual(events, expected_events, case['scenarioId']) + return + + if matching == 'exact-except-extra-progress': + expected_index = 0 + for event in events: + if ( + expected_index < len(expected_events) + and event == expected_events[expected_index] + ): + expected_index += 1 + continue + + test.assertTrue( + is_progress_event_key(event), + '{} emitted an unexpected non-progress event {}; expected {}'.format( + case['scenarioId'], event, expected_events + ), + ) + + test.assertEqual( + expected_index, + len(expected_events), + '{} did not emit every expected non-extra event; observed {}; expected {}'.format( + case['scenarioId'], events, expected_events + ), + ) + return + + raise AssertionError( + '{} uses unsupported generated event policy {}'.format( + case['scenarioId'], event_policy + ) + ) + + class GeneratedTusRuntimeEventsTest(unittest.TestCase): @responses.activate def test_sync_uploader_emits_generated_progress_and_chunk_events(self): @@ -264,7 +330,7 @@ def test_sync_uploader_emits_generated_progress_and_chunk_events(self): ) uploader.upload() - self.assertEqual(events, case['eventKeys'], case['scenarioId']) + assert_events(self, case, events) assert_request_sequence(self, case, responses.calls[first_call_index:]) assert_stored_upload_state(self, case, storage) From e4873ee9fa6a917b7b0459ddb289f59aa8ca9804 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 09:25:16 +0200 Subject: [PATCH 27/95] Update generated TUS retry events --- tests/generated_protocol_contract.py | 13 +++++++++++++ tests/test_generated_conformance_events.py | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index a426a87..7993a88 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -3343,5 +3343,18 @@ }, ], 'scenarioId': 'terminateWithRetry', + 'events': [ + { + 'decision': True, + 'kind': 'should-retry', + 'retryAttempt': 0, + 'key': 'should-retry:0:true', + }, + { + 'delay': 0, + 'kind': 'retry-schedule', + 'key': 'retry-schedule:0', + }, + ], }, ] diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index 866ab31..fde05db 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -288,6 +288,17 @@ 'featureId': 'abortUpload', 'scenarioId': 'abortUploadAfterStoredUrl', }, + { + 'eventKeys': [ + 'should-retry:0:true', + 'retry-schedule:0', + ], + 'eventPolicy': { + 'matching': 'exact', + }, + 'featureId': 'terminateUpload', + 'scenarioId': 'terminateWithRetry', + }, ] From d4dd289f8da65b44f6d93a19149769149e685cb4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 09:58:03 +0200 Subject: [PATCH 28/95] Add generated TUS proof profile canaries --- tests/test_generated_conformance_events.py | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index fde05db..b1f88a1 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -301,6 +301,86 @@ }, ] +PROOF_CASES = [ + { + 'behavior': 'single-upload-lifecycle', + 'completionKind': 'success', + 'featureId': 'singleUploadLifecycle', + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'open-input-source', + 'fingerprint-input', + 'store-resume-url', + 'retry-with-backoff', + 'emit-progress', + 'abort-current-request', + ], + 'profile': 'urlStorageCreateFlow', + 'scenarioId': 'singleUploadLifecycle', + }, + { + 'behavior': 'custom-request-headers', + 'completionKind': 'success', + 'featureId': 'customRequestHeaders', + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'apply-custom-request-headers', + ], + 'profile': 'customRequestHeaders', + 'scenarioId': 'customRequestHeaders', + }, + { + 'behavior': 'override-patch-method', + 'completionKind': 'success', + 'featureId': 'overridePatchMethod', + 'operationIds': [ + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'override-patch-method', + ], + 'profile': 'overridePatchMethod', + 'scenarioId': 'overridePatchMethod', + }, + { + 'behavior': 'node-path-input', + 'completionKind': 'success', + 'featureId': 'inputSources', + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'read-node-file', + ], + 'profile': 'nodePathFileUpload', + 'scenarioId': 'nodePathInput', + }, + { + 'behavior': 'resume-from-previous-upload', + 'completionKind': 'success', + 'featureId': 'resumeUpload', + 'operationIds': [ + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'fingerprint-input', + 'resume-from-previous-upload', + 'store-resume-url', + ], + 'profile': 'resumeFromPreviousUpload', + 'scenarioId': 'resumeFromPreviousUpload', + }, +] + def client_feature(feature_id): for feature in TUS_CLIENT_FEATURES: @@ -332,3 +412,15 @@ def test_generated_scenario_event_keys(self): scenario.get("eventPolicy", {"matching": "exact"}), case["eventPolicy"], ) + + def test_generated_proof_profile_scenarios(self): + for case in PROOF_CASES: + scenario = client_scenario(case["scenarioId"]) + feature = client_feature(case["featureId"]) + + self.assertEqual(scenario["behavior"], case["behavior"]) + self.assertEqual(scenario["completion"]["kind"], case["completionKind"]) + self.assertEqual(scenario["featureId"], case["featureId"]) + self.assertIn(scenario["scenarioId"], feature["conformance"]["scenarioIds"]) + self.assertEqual(scenario["operationIds"], case["operationIds"]) + self.assertEqual(scenario["primitives"], case["primitives"]) From 7b035d52a867a1d6c76ac7989b9c6982f8ed65f7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 10:12:31 +0200 Subject: [PATCH 29/95] Update generated TUS execution hints --- tests/generated_protocol_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 7993a88..7b94e18 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1976,6 +1976,15 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'execution': { + 'beforeStart': [ + { + 'expectedPreviousUploadCount': 1, + 'kind': 'resume-from-previous-upload', + 'selectedPreviousUploadIndex': 0, + }, + ], + }, 'featureId': 'resumeUpload', 'input': { 'content': 'hello world', @@ -3275,6 +3284,14 @@ 'kind': 'terminated', 'uploadUrl': 'https://tus.io/uploads/terminate-contract', }, + 'execution': { + 'onChunkComplete': [ + { + 'kind': 'abort-upload', + 'terminateUpload': True, + }, + ], + }, 'featureId': 'terminateUpload', 'input': { 'chunkSize': 5, From 85402226dcfda4c39c67df094c10c6a2fee34c59 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 10:26:45 +0200 Subject: [PATCH 30/95] Use generated TUS execution hints in runtime tests --- tests/test_generated_runtime_events.py | 73 ++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 7cac03d..0df16f8 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -27,6 +27,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'execution': None, 'metadata': { 'filename': 'hello.txt', }, @@ -74,6 +75,15 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'execution': { + 'beforeStart': [ + { + 'expectedPreviousUploadCount': 1, + 'kind': 'resume-from-previous-upload', + 'selectedPreviousUploadIndex': 0, + }, + ], + }, 'metadata': {}, 'removeFingerprintOnSuccess': True, 'requests': [ @@ -122,6 +132,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'execution': None, 'metadata': { 'filename': 'hello.txt', }, @@ -169,6 +180,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'execution': None, 'metadata': { 'filename': 'hello.txt', }, @@ -256,6 +268,50 @@ def is_progress_event_key(event_key): return event_key.startswith('progress:') +def execution_actions(case, phase): + execution = case.get('execution') or {} + return execution.get(phase, []) + + +def resume_before_start_action(case): + action = None + for candidate in execution_actions(case, 'beforeStart'): + if candidate['kind'] != 'resume-from-previous-upload': + raise AssertionError( + '{} uses unsupported generated beforeStart action {}'.format( + case['scenarioId'], candidate['kind'] + ) + ) + + if action is not None: + raise AssertionError( + '{} defines more than one resume beforeStart action'.format( + case['scenarioId'] + ) + ) + + action = candidate + + return action + + +def assert_before_start_actions(test, case, storage): + action = resume_before_start_action(case) + if action is None: + return + + test.assertIsNotNone(storage, case['scenarioId']) + test.assertIsNotNone(case['storedUpload'], case['scenarioId']) + test.assertEqual(action['selectedPreviousUploadIndex'], 0, case['scenarioId']) + fingerprint = case['storedUpload']['fingerprint'] + stored_upload_count = 1 if storage.get_item(fingerprint) is not None else 0 + test.assertEqual( + stored_upload_count, + action['expectedPreviousUploadCount'], + case['scenarioId'], + ) + + def assert_events(test, case, events): expected_events = case['eventKeys'] event_policy = case.get('eventPolicy', {'matching': 'exact'}) @@ -305,6 +361,7 @@ def test_sync_uploader_emits_generated_progress_and_chunk_events(self): events = [] client = TusClient(case['endpointUrl']) storage = storage_for(case) + resume_action = resume_before_start_action(case) first_call_index = len(responses.calls) for request in case['requests']: @@ -316,13 +373,14 @@ def test_sync_uploader_emits_generated_progress_and_chunk_events(self): status=request['statusCode'], ) + assert_before_start_actions(self, case, storage) uploader = client.uploader( file_stream=io.BytesIO(case['content'].encode('utf-8')), chunk_size=case['chunkSize'], metadata=case['metadata'], - store_url=case['storedUpload'] is not None, + store_url=resume_action is not None, url_storage=storage, - fingerprinter=fingerprinter_for(case), + fingerprinter=fingerprinter_for(case, resume_action), remove_fingerprint_on_success=case['removeFingerprintOnSuccess'], upload_length_deferred=case['uploadLengthDeferred'], on_progress=record_progress(events), @@ -344,10 +402,17 @@ def storage_for(case): }) -def fingerprinter_for(case): - if case['storedUpload'] is None: +def fingerprinter_for(case, resume_action): + if resume_action is None: return None + if case['storedUpload'] is None: + raise AssertionError( + '{} cannot resume without a generated stored upload'.format( + case['scenarioId'] + ) + ) + return GeneratedTusFingerprinter(case['storedUpload']['fingerprint']) From b9c47cc34b3b87f5a289e29e89a6b0a0af3b44f7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:01:46 +0200 Subject: [PATCH 31/95] Expose TUS request-start cancellation hints --- tests/generated_protocol_contract.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 7b94e18..2b5e75c 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -3164,6 +3164,14 @@ 'completion': { 'kind': 'aborted', }, + 'execution': { + 'onRequestStart': [ + { + 'kind': 'cancel-upload', + 'requestIndex': 0, + }, + ], + }, 'featureId': 'abortUpload', 'input': { 'content': 'hello world', @@ -3204,6 +3212,14 @@ 'kind': 'aborted', 'uploadUrl': 'https://tus.io/uploads/abort-terminate-contract', }, + 'execution': { + 'onRequestStart': [ + { + 'kind': 'cancel-upload', + 'requestIndex': 1, + }, + ], + }, 'featureId': 'abortUpload', 'input': { 'content': 'hello world', From ea0e2fc3e922404ced6312104c60a0d1d359acf7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:19:12 +0200 Subject: [PATCH 32/95] Expose TUS parallel request gates --- tests/generated_protocol_contract.py | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 2b5e75c..8daafb9 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2707,6 +2707,23 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'execution': { + 'serverRequestGates': [ + { + 'gateId': 'parallel-patches', + 'heldRequestIndexes': [ + 2, + 3, + ], + 'kind': 'release-after-all-started', + 'releaseAfterRequestIndexes': [ + 2, + 3, + ], + 'timeoutMs': 2000, + }, + ], + }, 'featureId': 'parallelUploadConcat', 'input': { 'content': 'hello world', @@ -2845,6 +2862,23 @@ 'completion': { 'kind': 'aborted', }, + 'execution': { + 'serverRequestGates': [ + { + 'gateId': 'parallel-cleanup-patches', + 'heldRequestIndexes': [ + 2, + 3, + ], + 'kind': 'release-after-all-started', + 'releaseAfterRequestIndexes': [ + 2, + 3, + ], + 'timeoutMs': 2000, + }, + ], + }, 'featureId': 'parallelUploadConcat', 'input': { 'content': 'hello world', From 2a026b34f9afc868b0751454e33bb67f309a9656 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:37:34 +0200 Subject: [PATCH 33/95] Expose TUS managed upload contract --- tests/generated_protocol_contract.py | 246 +++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 8daafb9..2bd5c60 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1017,6 +1017,252 @@ }, ] +TUS_MANAGED_UPLOAD = { + 'capabilities': { + 'cleanup': { + 'policies': [ + 'remove-owned-source-after-success', + 'remove-owned-source-after-cancel', + 'retain-owned-source-after-permanent-failure', + 'retain-source-after-retryable-failure', + 'remove-managed-state-after-terminal-retention', + ], + }, + 'failureClassification': { + 'permanentFailures': [ + 'source-unavailable', + 'unretryable-protocol-error', + 'retry-policy-exhausted', + ], + 'retryableFailures': [ + 'retryable-protocol-error', + 'io-error', + 'network-unavailable', + ], + }, + 'networkConstraints': { + 'options': [ + 'any-network', + 'unmetered-network', + ], + }, + 'retryPolicy': { + 'controls': [ + 'max-attempts', + 'deadline', + 'progress-sensitive-budget', + 'unbounded-until-permanent-failure', + ], + 'permanentFailure': 'stop-without-retry', + 'progressReset': 'reset-budget-after-accepted-offset-advances', + }, + 'scheduling': { + 'strategies': [ + 'foreground-task', + 'process-lifetime-worker-pool', + 'durable-os-scheduler', + ], + }, + 'sourceDurability': { + 'ownedCopyCleanup': 'after-success-or-cancel', + 'strategies': [ + 'copy-to-owned-storage', + 'reference-original-source', + 'memory-only', + ], + }, + 'stateReporting': { + 'states': [ + 'pending', + 'running', + 'succeeded', + 'failed', + ], + 'terminalRetention': 'session-and-next-launch', + 'transientRetention': 'until-terminal', + }, + }, + 'conformance': { + 'scenarioIds': [ + 'managedUploadDurableRetry', + 'managedUploadPermanentFailure', + 'managedUploadNetworkConstraint', + ], + 'status': 'needs-generated-scenario', + }, + 'description': 'Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.', + 'featureId': 'managedUpload', + 'flow': [ + { + 'kind': 'managed-primitive', + 'primitive': 'accept-upload-submission', + 'summary': 'Accept source, metadata, headers, endpoint, and retry/scheduling policy.', + }, + { + 'kind': 'managed-primitive', + 'primitive': 'make-source-durable', + 'summary': 'Keep the source readable according to the selected runtime durability strategy.', + }, + { + 'kind': 'managed-primitive', + 'primitive': 'schedule-upload-work', + 'summary': 'Run upload work according to the runtime scheduler capability.', + }, + { + 'featureId': 'singleUploadLifecycle', + 'kind': 'protocol-feature', + 'summary': 'Use the raw protocol upload lifecycle for each execution attempt.', + }, + { + 'featureId': 'retryOffsetRecovery', + 'kind': 'protocol-feature', + 'summary': 'Use protocol retry and offset recovery before classifying terminal failure.', + }, + { + 'kind': 'managed-primitive', + 'primitive': 'publish-upload-state', + 'summary': 'Expose pending, running, succeeded, and failed state snapshots.', + }, + { + 'kind': 'managed-primitive', + 'primitive': 'cleanup-managed-upload', + 'summary': 'Remove owned sources and terminal state according to cleanup policy.', + }, + ], + 'layer': 'feature-over-protocol', + 'primitives': [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + 'protocolPrimitives': [ + 'store-resume-url', + 'resume-from-previous-upload', + 'recover-offset-after-error', + 'retry-with-backoff', + 'emit-progress', + 'emit-chunk-complete', + 'terminate-upload', + ], + 'runtimeProfiles': [ + { + 'networkConstraints': [ + 'any-network', + 'unmetered-network', + ], + 'runtime': 'android', + 'scheduler': 'durable-os-scheduler', + 'sourceDurability': [ + 'copy-to-owned-storage', + 'reference-original-source', + ], + 'stateBackend': 'platform-key-value-store', + }, + { + 'networkConstraints': [ + 'any-network', + 'unmetered-network', + ], + 'runtime': 'ios', + 'scheduler': 'durable-os-scheduler', + 'sourceDurability': [ + 'copy-to-owned-storage', + 'reference-original-source', + ], + 'stateBackend': 'platform-key-value-store', + }, + { + 'networkConstraints': [ + 'any-network', + ], + 'runtime': 'browser', + 'scheduler': 'foreground-task', + 'sourceDurability': [ + 'reference-original-source', + 'memory-only', + ], + 'stateBackend': 'web-storage', + }, + { + 'networkConstraints': [ + 'any-network', + ], + 'runtime': 'java', + 'scheduler': 'process-lifetime-worker-pool', + 'sourceDurability': [ + 'copy-to-owned-storage', + 'reference-original-source', + ], + 'stateBackend': 'filesystem', + }, + { + 'networkConstraints': [ + 'any-network', + ], + 'runtime': 'node', + 'scheduler': 'process-lifetime-worker-pool', + 'sourceDurability': [ + 'copy-to-owned-storage', + 'reference-original-source', + 'memory-only', + ], + 'stateBackend': 'filesystem', + }, + { + 'networkConstraints': [ + 'any-network', + ], + 'runtime': 'react-native', + 'scheduler': 'foreground-task', + 'sourceDurability': [ + 'reference-original-source', + 'memory-only', + ], + 'stateBackend': 'platform-key-value-store', + }, + ], + 'scenarios': [ + { + 'requiredPrimitives': [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + 'scenarioId': 'managedUploadDurableRetry', + 'summary': 'Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.', + }, + { + 'requiredPrimitives': [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'classify-failure', + 'publish-upload-state', + ], + 'scenarioId': 'managedUploadPermanentFailure', + 'summary': 'Classify missing sources and unretryable protocol failures as terminal without further retry.', + }, + { + 'requiredPrimitives': [ + 'accept-upload-submission', + 'schedule-upload-work', + 'publish-upload-state', + ], + 'scenarioId': 'managedUploadNetworkConstraint', + 'summary': 'Honor network constraints before starting or resuming upload work.', + }, + ], +} + TUS_CLIENT_CONFORMANCE_SCENARIOS = [ { 'behavior': 'single-upload-lifecycle', From 888ab9f35759d7df8a1bc135e897c59ec0991d2b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:52:40 +0200 Subject: [PATCH 34/95] Expose managed upload proof cases --- tests/generated_protocol_contract.py | 75 ++++++++++++++++++++++ tests/test_generated_conformance_events.py | 25 ++++++++ 2 files changed, 100 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 2bd5c60..0fa8c4b 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1263,6 +1263,81 @@ ], } +TUS_MANAGED_UPLOAD_PROOF_CASES = [ + { + 'featureId': 'managedUpload', + 'layer': 'feature-over-protocol', + 'protocolFeatureIds': [ + 'singleUploadLifecycle', + 'retryOffsetRecovery', + ], + 'requiredPrimitives': [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + 'runtimeProfiles': [ + 'android', + 'ios', + 'browser', + 'java', + 'node', + 'react-native', + ], + 'scenarioId': 'managedUploadDurableRetry', + }, + { + 'featureId': 'managedUpload', + 'layer': 'feature-over-protocol', + 'protocolFeatureIds': [ + 'singleUploadLifecycle', + 'retryOffsetRecovery', + ], + 'requiredPrimitives': [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'classify-failure', + 'publish-upload-state', + ], + 'runtimeProfiles': [ + 'android', + 'ios', + 'browser', + 'java', + 'node', + 'react-native', + ], + 'scenarioId': 'managedUploadPermanentFailure', + }, + { + 'featureId': 'managedUpload', + 'layer': 'feature-over-protocol', + 'protocolFeatureIds': [ + 'singleUploadLifecycle', + 'retryOffsetRecovery', + ], + 'requiredPrimitives': [ + 'accept-upload-submission', + 'schedule-upload-work', + 'publish-upload-state', + ], + 'runtimeProfiles': [ + 'android', + 'ios', + 'browser', + 'java', + 'node', + 'react-native', + ], + 'scenarioId': 'managedUploadNetworkConstraint', + }, +] + TUS_CLIENT_CONFORMANCE_SCENARIOS = [ { 'behavior': 'single-upload-lifecycle', diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index b1f88a1..af6974e 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -7,6 +7,8 @@ from tests.generated_protocol_contract import ( TUS_CLIENT_CONFORMANCE_SCENARIOS, TUS_CLIENT_FEATURES, + TUS_MANAGED_UPLOAD, + TUS_MANAGED_UPLOAD_PROOF_CASES, ) @@ -396,6 +398,13 @@ def client_scenario(scenario_id): raise AssertionError("Missing generated TUS client scenario: {}".format(scenario_id)) +def managed_upload_scenario(scenario_id): + for scenario in TUS_MANAGED_UPLOAD["scenarios"]: + if scenario["scenarioId"] == scenario_id: + return scenario + raise AssertionError("Missing generated TUS managed-upload scenario: {}".format(scenario_id)) + + class GeneratedTusConformanceEventsTest(unittest.TestCase): def test_generated_scenario_event_keys(self): for case in CASES: @@ -424,3 +433,19 @@ def test_generated_proof_profile_scenarios(self): self.assertIn(scenario["scenarioId"], feature["conformance"]["scenarioIds"]) self.assertEqual(scenario["operationIds"], case["operationIds"]) self.assertEqual(scenario["primitives"], case["primitives"]) + + def test_generated_managed_upload_proof_scenarios(self): + for case in TUS_MANAGED_UPLOAD_PROOF_CASES: + scenario = managed_upload_scenario(case["scenarioId"]) + + self.assertEqual(TUS_MANAGED_UPLOAD["featureId"], case["featureId"]) + self.assertEqual(TUS_MANAGED_UPLOAD["layer"], case["layer"]) + self.assertEqual(scenario["requiredPrimitives"], case["requiredPrimitives"]) + for primitive in case["requiredPrimitives"]: + self.assertIn(primitive, TUS_MANAGED_UPLOAD["primitives"]) + for feature_id in case["protocolFeatureIds"]: + client_feature(feature_id) + self.assertEqual( + [profile["runtime"] for profile in TUS_MANAGED_UPLOAD["runtimeProfiles"]], + case["runtimeProfiles"], + ) From 9dd5b882395b11a068dab93c291001449c847b08 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:17:07 +0200 Subject: [PATCH 35/95] Update managed upload proof fixture --- tests/generated_protocol_contract.py | 202 +++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 0fa8c4b..aa0c1de 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1228,6 +1228,107 @@ ], 'scenarios': [ { + 'proof': { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'afterAcceptedOffset': 7, + 'kind': 'io-error', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/managed-durable-retry', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 7, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '7', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'stateAfterAttempt': 'failed', + }, + { + 'attemptIndex': 1, + 'requests': [ + { + 'headers': {}, + 'operationId': 'getTusUploadOffset', + 'response': { + 'headers': { + 'Upload-Length': '14', + 'Upload-Offset': '7', + }, + 'statusCode': 200, + }, + 'url': 'upload', + }, + { + 'bodySize': 7, + 'headers': { + 'Upload-Offset': '7', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '14', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'stateAfterAttempt': 'succeeded', + }, + ], + 'cleanup': { + 'ownedSource': 'remove-owned-source-after-success', + 'resumeUrl': 'remove-after-success', + }, + 'input': { + 'chunkSize': 7, + 'content': 'hello managed!', + 'fingerprint': 'managed-durable-retry-fingerprint', + 'metadata': { + 'filename': 'managed.txt', + }, + 'uploadPath': 'managed-durable-retry', + }, + 'retryDelays': [ + 0, + ], + 'runtime': 'java', + 'scheduler': 'process-lifetime-worker-pool', + 'sourceDurability': 'copy-to-owned-storage', + 'stateBackend': 'filesystem', + 'states': [ + 'pending', + 'running', + 'failed', + 'running', + 'succeeded', + ], + }, 'requiredPrimitives': [ 'accept-upload-submission', 'make-source-durable', @@ -1267,6 +1368,107 @@ { 'featureId': 'managedUpload', 'layer': 'feature-over-protocol', + 'proof': { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'afterAcceptedOffset': 7, + 'kind': 'io-error', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/managed-durable-retry', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', + }, + { + 'bodySize': 7, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '7', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'stateAfterAttempt': 'failed', + }, + { + 'attemptIndex': 1, + 'requests': [ + { + 'headers': {}, + 'operationId': 'getTusUploadOffset', + 'response': { + 'headers': { + 'Upload-Length': '14', + 'Upload-Offset': '7', + }, + 'statusCode': 200, + }, + 'url': 'upload', + }, + { + 'bodySize': 7, + 'headers': { + 'Upload-Offset': '7', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '14', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'stateAfterAttempt': 'succeeded', + }, + ], + 'cleanup': { + 'ownedSource': 'remove-owned-source-after-success', + 'resumeUrl': 'remove-after-success', + }, + 'input': { + 'chunkSize': 7, + 'content': 'hello managed!', + 'fingerprint': 'managed-durable-retry-fingerprint', + 'metadata': { + 'filename': 'managed.txt', + }, + 'uploadPath': 'managed-durable-retry', + }, + 'retryDelays': [ + 0, + ], + 'runtime': 'java', + 'scheduler': 'process-lifetime-worker-pool', + 'sourceDurability': 'copy-to-owned-storage', + 'stateBackend': 'filesystem', + 'states': [ + 'pending', + 'running', + 'failed', + 'running', + 'succeeded', + ], + }, 'protocolFeatureIds': [ 'singleUploadLifecycle', 'retryOffsetRecovery', From fba9b5305aed4abb0652a8901f91c68b2e4a7dc0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:43:36 +0200 Subject: [PATCH 36/95] Update managed upload proof fixture --- tests/generated_protocol_contract.py | 370 ++++++++++++++------------- 1 file changed, 189 insertions(+), 181 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index aa0c1de..53471e1 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1228,107 +1228,210 @@ ], 'scenarios': [ { - 'proof': { - 'attempts': [ - { - 'attemptIndex': 0, - 'failure': { - 'afterAcceptedOffset': 7, - 'kind': 'io-error', - }, - 'requests': [ - { - 'bodySize': 0, - 'headers': { - 'Upload-Length': '14', + 'proofs': [ + { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'afterAcceptedOffset': 7, + 'kind': 'io-error', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/managed-durable-retry', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', }, - 'operationId': 'createTusUpload', - 'response': { + { + 'bodySize': 7, 'headers': { - 'Location': 'https://tus.io/uploads/managed-durable-retry', + 'Upload-Offset': '0', }, - 'statusCode': 201, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '7', + }, + 'statusCode': 204, + }, + 'url': 'upload', }, - 'url': 'endpoint', - }, - { - 'bodySize': 7, - 'headers': { - 'Upload-Offset': '0', + ], + 'stateAfterAttempt': 'failed', + }, + { + 'attemptIndex': 1, + 'requests': [ + { + 'headers': {}, + 'operationId': 'getTusUploadOffset', + 'response': { + 'headers': { + 'Upload-Length': '14', + 'Upload-Offset': '7', + }, + 'statusCode': 200, + }, + 'url': 'upload', }, - 'operationId': 'patchTusUpload', - 'response': { + { + 'bodySize': 7, 'headers': { 'Upload-Offset': '7', }, - 'statusCode': 204, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '14', + }, + 'statusCode': 204, + }, + 'url': 'upload', }, - 'url': 'upload', - }, - ], - 'stateAfterAttempt': 'failed', + ], + 'stateAfterAttempt': 'succeeded', + }, + ], + 'cleanup': { + 'ownedSource': 'remove-owned-source-after-success', + 'resumeUrl': 'remove-after-success', }, - { - 'attemptIndex': 1, - 'requests': [ - { - 'headers': {}, - 'operationId': 'getTusUploadOffset', - 'response': { + 'input': { + 'chunkSize': 7, + 'content': 'hello managed!', + 'fingerprint': 'managed-durable-retry-fingerprint', + 'metadata': { + 'filename': 'managed.txt', + }, + 'uploadPath': 'managed-durable-retry', + }, + 'retryDelays': [ + 0, + ], + 'sourceDurability': 'copy-to-owned-storage', + 'states': [ + 'pending', + 'running', + 'failed', + 'running', + 'succeeded', + ], + 'runtime': 'java', + 'scheduler': 'process-lifetime-worker-pool', + 'stateBackend': 'filesystem', + }, + { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'afterAcceptedOffset': 7, + 'kind': 'io-error', + }, + 'requests': [ + { + 'bodySize': 0, 'headers': { 'Upload-Length': '14', - 'Upload-Offset': '7', }, - 'statusCode': 200, + 'operationId': 'createTusUpload', + 'response': { + 'headers': { + 'Location': 'https://tus.io/uploads/managed-durable-retry', + }, + 'statusCode': 201, + }, + 'url': 'endpoint', }, - 'url': 'upload', - }, - { - 'bodySize': 7, - 'headers': { - 'Upload-Offset': '7', + { + 'bodySize': 7, + 'headers': { + 'Upload-Offset': '0', + }, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '7', + }, + 'statusCode': 204, + }, + 'url': 'upload', + }, + ], + 'stateAfterAttempt': 'failed', + }, + { + 'attemptIndex': 1, + 'requests': [ + { + 'headers': {}, + 'operationId': 'getTusUploadOffset', + 'response': { + 'headers': { + 'Upload-Length': '14', + 'Upload-Offset': '7', + }, + 'statusCode': 200, + }, + 'url': 'upload', }, - 'operationId': 'patchTusUpload', - 'response': { + { + 'bodySize': 7, 'headers': { - 'Upload-Offset': '14', + 'Upload-Offset': '7', }, - 'statusCode': 204, + 'operationId': 'patchTusUpload', + 'response': { + 'headers': { + 'Upload-Offset': '14', + }, + 'statusCode': 204, + }, + 'url': 'upload', }, - 'url': 'upload', - }, - ], - 'stateAfterAttempt': 'succeeded', + ], + 'stateAfterAttempt': 'succeeded', + }, + ], + 'cleanup': { + 'ownedSource': 'remove-owned-source-after-success', + 'resumeUrl': 'remove-after-success', }, - ], - 'cleanup': { - 'ownedSource': 'remove-owned-source-after-success', - 'resumeUrl': 'remove-after-success', - }, - 'input': { - 'chunkSize': 7, - 'content': 'hello managed!', - 'fingerprint': 'managed-durable-retry-fingerprint', - 'metadata': { - 'filename': 'managed.txt', + 'input': { + 'chunkSize': 7, + 'content': 'hello managed!', + 'fingerprint': 'managed-durable-retry-fingerprint', + 'metadata': { + 'filename': 'managed.txt', + }, + 'uploadPath': 'managed-durable-retry', }, - 'uploadPath': 'managed-durable-retry', + 'retryDelays': [ + 0, + ], + 'sourceDurability': 'copy-to-owned-storage', + 'states': [ + 'pending', + 'running', + 'failed', + 'running', + 'succeeded', + ], + 'runtime': 'android', + 'scheduler': 'durable-os-scheduler', + 'stateBackend': 'platform-key-value-store', }, - 'retryDelays': [ - 0, - ], - 'runtime': 'java', - 'scheduler': 'process-lifetime-worker-pool', - 'sourceDurability': 'copy-to-owned-storage', - 'stateBackend': 'filesystem', - 'states': [ - 'pending', - 'running', - 'failed', - 'running', - 'succeeded', - ], - }, + ], 'requiredPrimitives': [ 'accept-upload-submission', 'make-source-durable', @@ -1368,107 +1471,10 @@ { 'featureId': 'managedUpload', 'layer': 'feature-over-protocol', - 'proof': { - 'attempts': [ - { - 'attemptIndex': 0, - 'failure': { - 'afterAcceptedOffset': 7, - 'kind': 'io-error', - }, - 'requests': [ - { - 'bodySize': 0, - 'headers': { - 'Upload-Length': '14', - }, - 'operationId': 'createTusUpload', - 'response': { - 'headers': { - 'Location': 'https://tus.io/uploads/managed-durable-retry', - }, - 'statusCode': 201, - }, - 'url': 'endpoint', - }, - { - 'bodySize': 7, - 'headers': { - 'Upload-Offset': '0', - }, - 'operationId': 'patchTusUpload', - 'response': { - 'headers': { - 'Upload-Offset': '7', - }, - 'statusCode': 204, - }, - 'url': 'upload', - }, - ], - 'stateAfterAttempt': 'failed', - }, - { - 'attemptIndex': 1, - 'requests': [ - { - 'headers': {}, - 'operationId': 'getTusUploadOffset', - 'response': { - 'headers': { - 'Upload-Length': '14', - 'Upload-Offset': '7', - }, - 'statusCode': 200, - }, - 'url': 'upload', - }, - { - 'bodySize': 7, - 'headers': { - 'Upload-Offset': '7', - }, - 'operationId': 'patchTusUpload', - 'response': { - 'headers': { - 'Upload-Offset': '14', - }, - 'statusCode': 204, - }, - 'url': 'upload', - }, - ], - 'stateAfterAttempt': 'succeeded', - }, - ], - 'cleanup': { - 'ownedSource': 'remove-owned-source-after-success', - 'resumeUrl': 'remove-after-success', - }, - 'input': { - 'chunkSize': 7, - 'content': 'hello managed!', - 'fingerprint': 'managed-durable-retry-fingerprint', - 'metadata': { - 'filename': 'managed.txt', - }, - 'uploadPath': 'managed-durable-retry', - }, - 'retryDelays': [ - 0, - ], - 'runtime': 'java', - 'scheduler': 'process-lifetime-worker-pool', - 'sourceDurability': 'copy-to-owned-storage', - 'stateBackend': 'filesystem', - 'states': [ - 'pending', - 'running', - 'failed', - 'running', - 'succeeded', - ], - }, + 'proofRuntimes': [ + 'java', + 'android', + ], 'protocolFeatureIds': [ 'singleUploadLifecycle', 'retryOffsetRecovery', @@ -1495,6 +1501,7 @@ { 'featureId': 'managedUpload', 'layer': 'feature-over-protocol', + 'proofRuntimes': [], 'protocolFeatureIds': [ 'singleUploadLifecycle', 'retryOffsetRecovery', @@ -1519,6 +1526,7 @@ { 'featureId': 'managedUpload', 'layer': 'feature-over-protocol', + 'proofRuntimes': [], 'protocolFeatureIds': [ 'singleUploadLifecycle', 'retryOffsetRecovery', From bbb931b644db01361fe11a0f4c7ccdb976b6461e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:09:50 +0200 Subject: [PATCH 37/95] Update managed upload proof fixture --- tests/generated_protocol_contract.py | 125 ++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 53471e1..0966875 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1236,6 +1236,7 @@ 'failure': { 'afterAcceptedOffset': 7, 'kind': 'io-error', + 'phase': 'after-accepted-offset', }, 'requests': [ { @@ -1326,6 +1327,9 @@ 'running', 'succeeded', ], + 'terminal': { + 'state': 'succeeded', + }, 'runtime': 'java', 'scheduler': 'process-lifetime-worker-pool', 'stateBackend': 'filesystem', @@ -1337,6 +1341,7 @@ 'failure': { 'afterAcceptedOffset': 7, 'kind': 'io-error', + 'phase': 'after-accepted-offset', }, 'requests': [ { @@ -1427,6 +1432,9 @@ 'running', 'succeeded', ], + 'terminal': { + 'state': 'succeeded', + }, 'runtime': 'android', 'scheduler': 'durable-os-scheduler', 'stateBackend': 'platform-key-value-store', @@ -1445,12 +1453,122 @@ 'summary': 'Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.', }, { + 'proofs': [ + { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'kind': 'unretryable-protocol-error', + 'phase': 'during-protocol-request', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': {}, + 'statusCode': 400, + }, + 'url': 'endpoint', + }, + ], + 'stateAfterAttempt': 'failed', + }, + ], + 'cleanup': { + 'ownedSource': 'retain-owned-source-after-permanent-failure', + 'resumeUrl': 'absent-after-permanent-failure', + }, + 'input': { + 'chunkSize': 7, + 'content': 'hello failure!', + 'fingerprint': 'managed-permanent-failure-fingerprint', + 'metadata': { + 'filename': 'managed-permanent-failure.txt', + }, + 'uploadPath': 'managed-permanent-failure', + }, + 'retryDelays': [], + 'sourceDurability': 'copy-to-owned-storage', + 'states': [ + 'pending', + 'running', + 'failed', + ], + 'terminal': { + 'failure': 'unretryable-protocol-error', + 'state': 'failed', + }, + 'runtime': 'java', + 'scheduler': 'process-lifetime-worker-pool', + 'stateBackend': 'filesystem', + }, + { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'kind': 'unretryable-protocol-error', + 'phase': 'during-protocol-request', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': {}, + 'statusCode': 400, + }, + 'url': 'endpoint', + }, + ], + 'stateAfterAttempt': 'failed', + }, + ], + 'cleanup': { + 'ownedSource': 'retain-owned-source-after-permanent-failure', + 'resumeUrl': 'absent-after-permanent-failure', + }, + 'input': { + 'chunkSize': 7, + 'content': 'hello failure!', + 'fingerprint': 'managed-permanent-failure-fingerprint', + 'metadata': { + 'filename': 'managed-permanent-failure.txt', + }, + 'uploadPath': 'managed-permanent-failure', + }, + 'retryDelays': [], + 'sourceDurability': 'copy-to-owned-storage', + 'states': [ + 'pending', + 'running', + 'failed', + ], + 'terminal': { + 'failure': 'unretryable-protocol-error', + 'state': 'failed', + }, + 'runtime': 'android', + 'scheduler': 'durable-os-scheduler', + 'stateBackend': 'platform-key-value-store', + }, + ], 'requiredPrimitives': [ 'accept-upload-submission', 'make-source-durable', 'schedule-upload-work', + 'run-protocol-upload', 'classify-failure', 'publish-upload-state', + 'cleanup-managed-upload', ], 'scenarioId': 'managedUploadPermanentFailure', 'summary': 'Classify missing sources and unretryable protocol failures as terminal without further retry.', @@ -1501,7 +1619,10 @@ { 'featureId': 'managedUpload', 'layer': 'feature-over-protocol', - 'proofRuntimes': [], + 'proofRuntimes': [ + 'java', + 'android', + ], 'protocolFeatureIds': [ 'singleUploadLifecycle', 'retryOffsetRecovery', @@ -1510,8 +1631,10 @@ 'accept-upload-submission', 'make-source-durable', 'schedule-upload-work', + 'run-protocol-upload', 'classify-failure', 'publish-upload-state', + 'cleanup-managed-upload', ], 'runtimeProfiles': [ 'android', From 314b70cdc15963f4ebf0ef6676894210d1c21842 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:32:43 +0200 Subject: [PATCH 38/95] Update managed upload proof fixture --- tests/generated_protocol_contract.py | 256 +++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 0966875..f7f6cb1 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1086,6 +1086,7 @@ 'scenarioIds': [ 'managedUploadDurableRetry', 'managedUploadPermanentFailure', + 'managedUploadRetryPolicyExhausted', 'managedUploadNetworkConstraint', ], 'status': 'needs-generated-scenario', @@ -1573,6 +1574,230 @@ 'scenarioId': 'managedUploadPermanentFailure', 'summary': 'Classify missing sources and unretryable protocol failures as terminal without further retry.', }, + { + 'proofs': [ + { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'kind': 'retryable-protocol-error', + 'phase': 'during-protocol-request', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': {}, + 'statusCode': 500, + }, + 'url': 'endpoint', + }, + ], + 'stateAfterAttempt': 'failed', + }, + { + 'attemptIndex': 1, + 'failure': { + 'kind': 'retryable-protocol-error', + 'phase': 'during-protocol-request', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': {}, + 'statusCode': 500, + }, + 'url': 'endpoint', + }, + ], + 'stateAfterAttempt': 'failed', + }, + { + 'attemptIndex': 2, + 'failure': { + 'kind': 'retryable-protocol-error', + 'phase': 'during-protocol-request', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': {}, + 'statusCode': 500, + }, + 'url': 'endpoint', + }, + ], + 'stateAfterAttempt': 'failed', + }, + ], + 'cleanup': { + 'ownedSource': 'retain-owned-source-after-permanent-failure', + 'resumeUrl': 'absent-after-permanent-failure', + }, + 'input': { + 'chunkSize': 7, + 'content': 'hello retries!', + 'fingerprint': 'managed-retry-exhausted-fingerprint', + 'metadata': { + 'filename': 'managed-retry-exhausted.txt', + }, + 'uploadPath': 'managed-retry-exhausted', + }, + 'retryDelays': [ + 0, + 0, + ], + 'sourceDurability': 'copy-to-owned-storage', + 'states': [ + 'pending', + 'running', + 'failed', + 'running', + 'failed', + 'running', + 'failed', + ], + 'terminal': { + 'failure': 'retry-policy-exhausted', + 'state': 'failed', + }, + 'runtime': 'java', + 'scheduler': 'process-lifetime-worker-pool', + 'stateBackend': 'filesystem', + }, + { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'kind': 'retryable-protocol-error', + 'phase': 'during-protocol-request', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': {}, + 'statusCode': 500, + }, + 'url': 'endpoint', + }, + ], + 'stateAfterAttempt': 'failed', + }, + { + 'attemptIndex': 1, + 'failure': { + 'kind': 'retryable-protocol-error', + 'phase': 'during-protocol-request', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': {}, + 'statusCode': 500, + }, + 'url': 'endpoint', + }, + ], + 'stateAfterAttempt': 'failed', + }, + { + 'attemptIndex': 2, + 'failure': { + 'kind': 'retryable-protocol-error', + 'phase': 'during-protocol-request', + }, + 'requests': [ + { + 'bodySize': 0, + 'headers': { + 'Upload-Length': '14', + }, + 'operationId': 'createTusUpload', + 'response': { + 'headers': {}, + 'statusCode': 500, + }, + 'url': 'endpoint', + }, + ], + 'stateAfterAttempt': 'failed', + }, + ], + 'cleanup': { + 'ownedSource': 'retain-owned-source-after-permanent-failure', + 'resumeUrl': 'absent-after-permanent-failure', + }, + 'input': { + 'chunkSize': 7, + 'content': 'hello retries!', + 'fingerprint': 'managed-retry-exhausted-fingerprint', + 'metadata': { + 'filename': 'managed-retry-exhausted.txt', + }, + 'uploadPath': 'managed-retry-exhausted', + }, + 'retryDelays': [ + 0, + 0, + ], + 'sourceDurability': 'copy-to-owned-storage', + 'states': [ + 'pending', + 'running', + 'failed', + 'running', + 'failed', + 'running', + 'failed', + ], + 'terminal': { + 'failure': 'retry-policy-exhausted', + 'state': 'failed', + }, + 'runtime': 'android', + 'scheduler': 'durable-os-scheduler', + 'stateBackend': 'platform-key-value-store', + }, + ], + 'requiredPrimitives': [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + 'scenarioId': 'managedUploadRetryPolicyExhausted', + 'summary': 'Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.', + }, { 'requiredPrimitives': [ 'accept-upload-submission', @@ -1646,6 +1871,37 @@ ], 'scenarioId': 'managedUploadPermanentFailure', }, + { + 'featureId': 'managedUpload', + 'layer': 'feature-over-protocol', + 'proofRuntimes': [ + 'java', + 'android', + ], + 'protocolFeatureIds': [ + 'singleUploadLifecycle', + 'retryOffsetRecovery', + ], + 'requiredPrimitives': [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'run-protocol-upload', + 'apply-managed-retry-policy', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + 'runtimeProfiles': [ + 'android', + 'ios', + 'browser', + 'java', + 'node', + 'react-native', + ], + 'scenarioId': 'managedUploadRetryPolicyExhausted', + }, { 'featureId': 'managedUpload', 'layer': 'feature-over-protocol', From 4c9dff0b09e8c8c0afdb17097f046a2eb8445a1b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:53:29 +0200 Subject: [PATCH 39/95] Update generated protocol contract fixture --- tests/generated_protocol_contract.py | 135 ++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index f7f6cb1..5656a2e 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1021,6 +1021,7 @@ 'capabilities': { 'cleanup': { 'policies': [ + 'absent-after-source-unavailable', 'remove-owned-source-after-success', 'remove-owned-source-after-cancel', 'retain-owned-source-after-permanent-failure', @@ -1087,6 +1088,7 @@ 'managedUploadDurableRetry', 'managedUploadPermanentFailure', 'managedUploadRetryPolicyExhausted', + 'managedUploadSourceUnavailable', 'managedUploadNetworkConstraint', ], 'status': 'needs-generated-scenario', @@ -1320,6 +1322,7 @@ 'retryDelays': [ 0, ], + 'sourceAvailability': 'available', 'sourceDurability': 'copy-to-owned-storage', 'states': [ 'pending', @@ -1425,6 +1428,7 @@ 'retryDelays': [ 0, ], + 'sourceAvailability': 'available', 'sourceDurability': 'copy-to-owned-storage', 'states': [ 'pending', @@ -1494,6 +1498,7 @@ 'uploadPath': 'managed-permanent-failure', }, 'retryDelays': [], + 'sourceAvailability': 'available', 'sourceDurability': 'copy-to-owned-storage', 'states': [ 'pending', @@ -1547,6 +1552,7 @@ 'uploadPath': 'managed-permanent-failure', }, 'retryDelays': [], + 'sourceAvailability': 'available', 'sourceDurability': 'copy-to-owned-storage', 'states': [ 'pending', @@ -1572,7 +1578,7 @@ 'cleanup-managed-upload', ], 'scenarioId': 'managedUploadPermanentFailure', - 'summary': 'Classify missing sources and unretryable protocol failures as terminal without further retry.', + 'summary': 'Classify unretryable protocol failures as terminal without further retry.', }, { 'proofs': [ @@ -1662,6 +1668,7 @@ 0, 0, ], + 'sourceAvailability': 'available', 'sourceDurability': 'copy-to-owned-storage', 'states': [ 'pending', @@ -1766,6 +1773,7 @@ 0, 0, ], + 'sourceAvailability': 'available', 'sourceDurability': 'copy-to-owned-storage', 'states': [ 'pending', @@ -1798,6 +1806,102 @@ 'scenarioId': 'managedUploadRetryPolicyExhausted', 'summary': 'Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.', }, + { + 'proofs': [ + { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'kind': 'source-unavailable', + 'phase': 'before-protocol-request', + }, + 'requests': [], + 'stateAfterAttempt': 'failed', + }, + ], + 'cleanup': { + 'ownedSource': 'absent-after-source-unavailable', + 'resumeUrl': 'absent-after-permanent-failure', + }, + 'input': { + 'chunkSize': 7, + 'content': 'hello missing!', + 'fingerprint': 'managed-source-unavailable-fingerprint', + 'metadata': { + 'filename': 'managed-source-unavailable.txt', + }, + 'uploadPath': 'managed-source-unavailable', + }, + 'retryDelays': [], + 'sourceAvailability': 'missing-before-durable-copy', + 'sourceDurability': 'copy-to-owned-storage', + 'states': [ + 'pending', + 'running', + 'failed', + ], + 'terminal': { + 'failure': 'source-unavailable', + 'state': 'failed', + }, + 'runtime': 'java', + 'scheduler': 'process-lifetime-worker-pool', + 'stateBackend': 'filesystem', + }, + { + 'attempts': [ + { + 'attemptIndex': 0, + 'failure': { + 'kind': 'source-unavailable', + 'phase': 'before-protocol-request', + }, + 'requests': [], + 'stateAfterAttempt': 'failed', + }, + ], + 'cleanup': { + 'ownedSource': 'absent-after-source-unavailable', + 'resumeUrl': 'absent-after-permanent-failure', + }, + 'input': { + 'chunkSize': 7, + 'content': 'hello missing!', + 'fingerprint': 'managed-source-unavailable-fingerprint', + 'metadata': { + 'filename': 'managed-source-unavailable.txt', + }, + 'uploadPath': 'managed-source-unavailable', + }, + 'retryDelays': [], + 'sourceAvailability': 'missing-before-durable-copy', + 'sourceDurability': 'copy-to-owned-storage', + 'states': [ + 'pending', + 'running', + 'failed', + ], + 'terminal': { + 'failure': 'source-unavailable', + 'state': 'failed', + }, + 'runtime': 'android', + 'scheduler': 'durable-os-scheduler', + 'stateBackend': 'platform-key-value-store', + }, + ], + 'requiredPrimitives': [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + 'scenarioId': 'managedUploadSourceUnavailable', + 'summary': 'Classify source disappearance before protocol requests as terminal without issuing a TUS request.', + }, { 'requiredPrimitives': [ 'accept-upload-submission', @@ -1902,6 +2006,35 @@ ], 'scenarioId': 'managedUploadRetryPolicyExhausted', }, + { + 'featureId': 'managedUpload', + 'layer': 'feature-over-protocol', + 'proofRuntimes': [ + 'java', + 'android', + ], + 'protocolFeatureIds': [ + 'singleUploadLifecycle', + 'retryOffsetRecovery', + ], + 'requiredPrimitives': [ + 'accept-upload-submission', + 'make-source-durable', + 'schedule-upload-work', + 'classify-failure', + 'publish-upload-state', + 'cleanup-managed-upload', + ], + 'runtimeProfiles': [ + 'android', + 'ios', + 'browser', + 'java', + 'node', + 'react-native', + ], + 'scenarioId': 'managedUploadSourceUnavailable', + }, { 'featureId': 'managedUpload', 'layer': 'feature-over-protocol', From 35b69bb7b7e2fe8f13882ea51954b88425281fa6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 14:22:41 +0200 Subject: [PATCH 40/95] Update generated managed upload contract --- tests/generated_protocol_contract.py | 154 +++++++++++++++++++++------ 1 file changed, 122 insertions(+), 32 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 5656a2e..960cc57 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1024,6 +1024,7 @@ 'absent-after-source-unavailable', 'remove-owned-source-after-success', 'remove-owned-source-after-cancel', + 'retain-owned-source-while-deferred', 'retain-owned-source-after-permanent-failure', 'retain-source-after-retryable-failure', 'remove-managed-state-after-terminal-retention', @@ -1091,7 +1092,7 @@ 'managedUploadSourceUnavailable', 'managedUploadNetworkConstraint', ], - 'status': 'needs-generated-scenario', + 'status': 'covered-by-generated-scenario', }, 'description': 'Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.', 'featureId': 'managedUpload', @@ -1319,6 +1320,15 @@ }, 'uploadPath': 'managed-durable-retry', }, + 'network': { + 'current': 'unmetered-network', + 'decision': 'start-upload-work', + 'required': 'any-network', + }, + 'outcome': { + 'kind': 'terminal', + 'state': 'succeeded', + }, 'retryDelays': [ 0, ], @@ -1331,9 +1341,6 @@ 'running', 'succeeded', ], - 'terminal': { - 'state': 'succeeded', - }, 'runtime': 'java', 'scheduler': 'process-lifetime-worker-pool', 'stateBackend': 'filesystem', @@ -1425,6 +1432,15 @@ }, 'uploadPath': 'managed-durable-retry', }, + 'network': { + 'current': 'unmetered-network', + 'decision': 'start-upload-work', + 'required': 'any-network', + }, + 'outcome': { + 'kind': 'terminal', + 'state': 'succeeded', + }, 'retryDelays': [ 0, ], @@ -1437,9 +1453,6 @@ 'running', 'succeeded', ], - 'terminal': { - 'state': 'succeeded', - }, 'runtime': 'android', 'scheduler': 'durable-os-scheduler', 'stateBackend': 'platform-key-value-store', @@ -1497,6 +1510,16 @@ }, 'uploadPath': 'managed-permanent-failure', }, + 'network': { + 'current': 'unmetered-network', + 'decision': 'start-upload-work', + 'required': 'any-network', + }, + 'outcome': { + 'failure': 'unretryable-protocol-error', + 'kind': 'terminal', + 'state': 'failed', + }, 'retryDelays': [], 'sourceAvailability': 'available', 'sourceDurability': 'copy-to-owned-storage', @@ -1505,10 +1528,6 @@ 'running', 'failed', ], - 'terminal': { - 'failure': 'unretryable-protocol-error', - 'state': 'failed', - }, 'runtime': 'java', 'scheduler': 'process-lifetime-worker-pool', 'stateBackend': 'filesystem', @@ -1551,6 +1570,16 @@ }, 'uploadPath': 'managed-permanent-failure', }, + 'network': { + 'current': 'unmetered-network', + 'decision': 'start-upload-work', + 'required': 'any-network', + }, + 'outcome': { + 'failure': 'unretryable-protocol-error', + 'kind': 'terminal', + 'state': 'failed', + }, 'retryDelays': [], 'sourceAvailability': 'available', 'sourceDurability': 'copy-to-owned-storage', @@ -1559,10 +1588,6 @@ 'running', 'failed', ], - 'terminal': { - 'failure': 'unretryable-protocol-error', - 'state': 'failed', - }, 'runtime': 'android', 'scheduler': 'durable-os-scheduler', 'stateBackend': 'platform-key-value-store', @@ -1664,6 +1689,16 @@ }, 'uploadPath': 'managed-retry-exhausted', }, + 'network': { + 'current': 'unmetered-network', + 'decision': 'start-upload-work', + 'required': 'any-network', + }, + 'outcome': { + 'failure': 'retry-policy-exhausted', + 'kind': 'terminal', + 'state': 'failed', + }, 'retryDelays': [ 0, 0, @@ -1679,10 +1714,6 @@ 'running', 'failed', ], - 'terminal': { - 'failure': 'retry-policy-exhausted', - 'state': 'failed', - }, 'runtime': 'java', 'scheduler': 'process-lifetime-worker-pool', 'stateBackend': 'filesystem', @@ -1769,6 +1800,16 @@ }, 'uploadPath': 'managed-retry-exhausted', }, + 'network': { + 'current': 'unmetered-network', + 'decision': 'start-upload-work', + 'required': 'any-network', + }, + 'outcome': { + 'failure': 'retry-policy-exhausted', + 'kind': 'terminal', + 'state': 'failed', + }, 'retryDelays': [ 0, 0, @@ -1784,10 +1825,6 @@ 'running', 'failed', ], - 'terminal': { - 'failure': 'retry-policy-exhausted', - 'state': 'failed', - }, 'runtime': 'android', 'scheduler': 'durable-os-scheduler', 'stateBackend': 'platform-key-value-store', @@ -1833,6 +1870,16 @@ }, 'uploadPath': 'managed-source-unavailable', }, + 'network': { + 'current': 'unmetered-network', + 'decision': 'start-upload-work', + 'required': 'any-network', + }, + 'outcome': { + 'failure': 'source-unavailable', + 'kind': 'terminal', + 'state': 'failed', + }, 'retryDelays': [], 'sourceAvailability': 'missing-before-durable-copy', 'sourceDurability': 'copy-to-owned-storage', @@ -1841,10 +1888,6 @@ 'running', 'failed', ], - 'terminal': { - 'failure': 'source-unavailable', - 'state': 'failed', - }, 'runtime': 'java', 'scheduler': 'process-lifetime-worker-pool', 'stateBackend': 'filesystem', @@ -1874,6 +1917,16 @@ }, 'uploadPath': 'managed-source-unavailable', }, + 'network': { + 'current': 'unmetered-network', + 'decision': 'start-upload-work', + 'required': 'any-network', + }, + 'outcome': { + 'failure': 'source-unavailable', + 'kind': 'terminal', + 'state': 'failed', + }, 'retryDelays': [], 'sourceAvailability': 'missing-before-durable-copy', 'sourceDurability': 'copy-to-owned-storage', @@ -1882,10 +1935,6 @@ 'running', 'failed', ], - 'terminal': { - 'failure': 'source-unavailable', - 'state': 'failed', - }, 'runtime': 'android', 'scheduler': 'durable-os-scheduler', 'stateBackend': 'platform-key-value-store', @@ -1903,8 +1952,46 @@ 'summary': 'Classify source disappearance before protocol requests as terminal without issuing a TUS request.', }, { + 'proofs': [ + { + 'attempts': [], + 'cleanup': { + 'ownedSource': 'retain-owned-source-while-deferred', + 'resumeUrl': 'absent-while-deferred', + }, + 'input': { + 'chunkSize': 7, + 'content': 'hello later!', + 'fingerprint': 'managed-network-constraint-fingerprint', + 'metadata': { + 'filename': 'managed-network-constraint.txt', + }, + 'uploadPath': 'managed-network-constraint', + }, + 'network': { + 'current': 'metered-network', + 'decision': 'defer-until-network-constraint-satisfied', + 'required': 'unmetered-network', + }, + 'outcome': { + 'kind': 'deferred', + 'reason': 'network-constraint-unsatisfied', + 'state': 'pending', + }, + 'retryDelays': [], + 'sourceAvailability': 'available', + 'sourceDurability': 'copy-to-owned-storage', + 'states': [ + 'pending', + ], + 'runtime': 'android', + 'scheduler': 'durable-os-scheduler', + 'stateBackend': 'platform-key-value-store', + }, + ], 'requiredPrimitives': [ 'accept-upload-submission', + 'make-source-durable', 'schedule-upload-work', 'publish-upload-state', ], @@ -2038,13 +2125,16 @@ { 'featureId': 'managedUpload', 'layer': 'feature-over-protocol', - 'proofRuntimes': [], + 'proofRuntimes': [ + 'android', + ], 'protocolFeatureIds': [ 'singleUploadLifecycle', 'retryOffsetRecovery', ], 'requiredPrimitives': [ 'accept-upload-submission', + 'make-source-durable', 'schedule-upload-work', 'publish-upload-state', ], From fbcd3eee1405f4fdf1888f97ed94b0bcba1b25f8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 22:21:02 +0200 Subject: [PATCH 41/95] Add devdock TUS upload example --- .../main.py | 131 ++++++++++++++++++ tests/test_examples.py | 10 ++ 2 files changed, 141 insertions(+) create mode 100644 examples/api2-devdock-transloadit-assembly-upload/main.py create mode 100644 tests/test_examples.py diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.py b/examples/api2-devdock-transloadit-assembly-upload/main.py new file mode 100644 index 0000000..9bc5ab7 --- /dev/null +++ b/examples/api2-devdock-transloadit-assembly-upload/main.py @@ -0,0 +1,131 @@ +"""Upload to a Transloadit devdock Assembly using tus-py-client. + +This example is intentionally checked into the SDK repository. API2 owns the +scenario JSON and prepares the live Transloadit Assembly; this file only shows +ordinary tus-py-client usage against the injected TUS endpoint. +""" + +import json +import os +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus + + +def fail(message): + raise RuntimeError(message) + + +def load_scenario(): + configured_path = os.environ.get("API2_SDK_EXAMPLE_SCENARIO") + scenario_path = ( + Path(configured_path) if configured_path else Path(__file__).with_name("api2-scenario.json") + ) + with scenario_path.open(encoding="utf-8") as scenario_file: + return json.load(scenario_file) + + +def read_path(value, path_parts, label): + current = value + for part in path_parts: + if isinstance(current, list) and isinstance(part, int): + if part >= len(current): + fail("{} path {!r} index {} is out of range".format(label, path_parts, part)) + current = current[part] + continue + + if isinstance(current, dict) and isinstance(part, str): + if part not in current: + fail("{} path {!r} is missing key {!r}".format(label, path_parts, part)) + current = current[part] + continue + + fail("{} path {!r} cannot read {!r} from {!r}".format(label, path_parts, part, current)) + + return current + + +def resolve_value(value_spec, context, label): + if "value" in value_spec: + return value_spec["value"] + + source = value_spec.get("source") + if not isinstance(source, dict): + fail("{} value spec has no literal value or source".format(label)) + + root = source.get("root") + if root not in context: + fail("{} value source root {!r} is unavailable".format(label, root)) + + path_parts = source.get("path") or [] + if not isinstance(path_parts, list): + fail("{} value source path must be a list".format(label)) + + return read_path(context[root], path_parts, label) + + +def scenario_bytes(upload_config): + source = upload_config["source"] + if source["kind"] != "bytes": + fail("unsupported scenario source kind {!r}".format(source["kind"])) + if source["encoding"] != "utf8": + fail("unsupported scenario source encoding {!r}".format(source["encoding"])) + return source["value"].encode("utf-8") + + +def scalar_string(value): + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def upload_metadata(upload_config, scenario, create_response): + context = {"createResponse": create_response, "scenario": scenario} + metadata = {} + for field in upload_config["metadata"]: + metadata[field["name"]] = scalar_string( + resolve_value(field["value"], context, field["name"]) + ) + return metadata + + +def upload_with_tus(scenario, create_response): + upload_config = scenario["upload"] + context = {"createResponse": create_response, "scenario": scenario} + endpoint_url = scalar_string(resolve_value(upload_config["tusUrl"], context, "tusUrl")) + content = scenario_bytes(upload_config) + if upload_config["chunkSize"] != "full-file": + fail("unsupported chunk size policy {!r}".format(upload_config["chunkSize"])) + + uploader = tus.TusClient(endpoint_url).uploader( + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + ) + uploader.upload() + + if not uploader.url: + fail("TUS upload did not expose an upload URL") + if uploader.offset != len(content): + fail("TUS upload offset {}, expected {}".format(uploader.offset, len(content))) + + return uploader.url + + +def main(): + scenario = load_scenario() + create_response = scenario["prepared"]["createResponse"] + upload_url = upload_with_tus(scenario, create_response) + print( + "Python TUS SDK devdock scenario {} uploaded to {}".format( + scenario["scenarioId"], upload_url + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..e3eb7dc --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,10 @@ +"""Smoke checks for checked-in examples.""" + +import py_compile +from pathlib import Path + + +def test_api2_devdock_examples_compile(): + examples_root = Path(__file__).parent.parent / "examples" + for example in examples_root.glob("api2-devdock-*/main.py"): + py_compile.compile(str(example), doraise=True) From 2b9ee70e3ff683a63512665261cfd7241e98260d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 22:31:47 +0200 Subject: [PATCH 42/95] Cap aiohttp below 3.14 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a48a4e9..c868a76 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ install_requires=[ 'requests>=2.18.4', 'tinydb>=3.5.0', - 'aiohttp>=3.6.2' + 'aiohttp>=3.6.2,<3.14' ], extras_require={ 'test': [ From d767ccfdb741c558ec02a8c9e1a67ee301d8767f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 22:38:18 +0200 Subject: [PATCH 43/95] Emit devdock example result --- .../api2-devdock-transloadit-assembly-upload/main.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.py b/examples/api2-devdock-transloadit-assembly-upload/main.py index 9bc5ab7..137aa94 100644 --- a/examples/api2-devdock-transloadit-assembly-upload/main.py +++ b/examples/api2-devdock-transloadit-assembly-upload/main.py @@ -116,10 +116,21 @@ def upload_with_tus(scenario, create_response): return uploader.url +def write_result(upload_url): + result_path = os.environ.get("API2_SDK_EXAMPLE_RESULT") + if not result_path: + return + + with Path(result_path).open("w", encoding="utf-8") as result_file: + json.dump({"uploadUrl": upload_url}, result_file, indent=2) + result_file.write("\n") + + def main(): scenario = load_scenario() create_response = scenario["prepared"]["createResponse"] upload_url = upload_with_tus(scenario, create_response) + write_result(upload_url) print( "Python TUS SDK devdock scenario {} uploaded to {}".format( scenario["scenarioId"], upload_url From a43105d51b859bafa6beeb8bfce35230bd167e60 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 23:35:03 +0200 Subject: [PATCH 44/95] Normalize generated request facts --- tests/generated_protocol_contract.py | 747 ++++++++++++++++++++++++++- 1 file changed, 738 insertions(+), 9 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 960cc57..972ce45 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2186,31 +2186,56 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/generated-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'singleUploadLifecycle', @@ -2289,20 +2314,32 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/creation-with-upload-contract', 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, ], 'scenarioId': 'creationWithUpload', @@ -2365,52 +2402,86 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, 'bodySize': 5, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/creation-with-upload-partial-contract', 'Upload-Offset': '5', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 5, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '10', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-chunk', 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', 'url': 'upload', + 'requestIndex': 1, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 1, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '10', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-final-chunk', 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', 'url': 'upload', + 'requestIndex': 2, }, ], 'scenarioId': 'creationWithUploadPartialChunk', @@ -2519,7 +2590,9 @@ 'absentHeaders': [ 'Tus-Resumable', ], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, 'headerMode': 'exact', 'headers': { 'Content-Type': 'application/partial-upload', @@ -2527,16 +2600,23 @@ 'Upload-Draft-Interop-Version': '6', 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, 'headerMode': 'exact', 'headers': { 'Location': 'https://tus.io/uploads/ietf-draft-05-contract', 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': None, + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, ], 'scenarioId': 'ietfDraft05CreationWithUpload', @@ -2599,41 +2679,60 @@ 'absentHeaders': [ 'Tus-Resumable', ], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, 'headerMode': 'exact', 'headers': { 'Upload-Draft-Interop-Version': '5', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'getTusUploadOffset', 'response': { + 'body': None, 'headerMode': 'exact', 'headers': { 'Upload-Offset': '5', }, + 'headersSpecified': True, 'statusCode': 200, }, + 'role': None, + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 0, }, { 'absentHeaders': [ 'Content-Type', 'Tus-Resumable', ], + 'abort': False, 'bodySize': 6, + 'errorMessage': None, 'headerMode': 'exact', 'headers': { 'Upload-Complete': '?1', 'Upload-Draft-Interop-Version': '5', 'Upload-Offset': '5', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, 'headerMode': 'exact', 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': None, + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'ietfDraft03ResumeWithoutKnownLength', @@ -2690,6 +2789,7 @@ ], 'requests': [], 'scenarioId': 'startValidationMissingInput', + 'events': [], }, { 'behavior': 'start-option-validation', @@ -2709,6 +2809,7 @@ ], 'requests': [], 'scenarioId': 'startValidationMissingEndpointOrUploadUrl', + 'events': [], }, { 'behavior': 'start-option-validation', @@ -2730,6 +2831,7 @@ ], 'requests': [], 'scenarioId': 'startValidationUnsupportedProtocol', + 'events': [], }, { 'behavior': 'start-option-validation', @@ -2753,6 +2855,7 @@ ], 'requests': [], 'scenarioId': 'startValidationRetryDelaysNotArray', + 'events': [], }, { 'behavior': 'start-option-validation', @@ -2775,6 +2878,7 @@ ], 'requests': [], 'scenarioId': 'startValidationParallelUploadsWithUploadUrl', + 'events': [], }, { 'behavior': 'start-option-validation', @@ -2797,6 +2901,7 @@ ], 'requests': [], 'scenarioId': 'startValidationParallelUploadsWithUploadSize', + 'events': [], }, { 'behavior': 'start-option-validation', @@ -2819,6 +2924,7 @@ ], 'requests': [], 'scenarioId': 'startValidationParallelUploadsWithDeferredLength', + 'events': [], }, { 'behavior': 'start-option-validation', @@ -2841,6 +2947,7 @@ ], 'requests': [], 'scenarioId': 'startValidationParallelUploadsWithUploadDataDuringCreation', + 'events': [], }, { 'behavior': 'start-option-validation', @@ -2867,6 +2974,7 @@ ], 'requests': [], 'scenarioId': 'startValidationParallelBoundariesWithoutParallelUploads', + 'events': [], }, { 'behavior': 'start-option-validation', @@ -2894,6 +3002,7 @@ ], 'requests': [], 'scenarioId': 'startValidationParallelBoundariesLengthMismatch', + 'events': [], }, { 'behavior': 'detailed-error', @@ -2925,19 +3034,33 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'X-Request-ID': 'contract-request-id', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { 'body': 'server_error', + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, 'statusCode': 500, }, + 'role': None, + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, ], 'scenarioId': 'detailedCreateResponseError', + 'events': [], }, { 'behavior': 'detailed-error', @@ -2969,18 +3092,27 @@ ], 'requests': [ { - 'error': { - 'message': 'socket down', - }, + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': 'socket down', + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'X-Request-ID': 'contract-request-id', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', + 'response': None, + 'role': None, + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, ], 'scenarioId': 'detailedCreateRequestError', + 'events': [], }, { 'behavior': 'upload-body-headers', @@ -3006,35 +3138,61 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/upload-body-headers-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': None, + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': None, + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'uploadBodyHeaders', + 'events': [], }, { 'behavior': 'custom-request-headers', @@ -3064,39 +3222,65 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/custom-headers-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'customRequestHeaders', + 'events': [], }, { 'behavior': 'resume-from-previous-upload', @@ -3141,29 +3325,55 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, + 'method': None, 'operationId': 'getTusUploadOffset', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Upload-Offset': '5', }, + 'headersSpecified': True, 'statusCode': 200, }, + 'role': 'recover-upload-offset', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 6, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '5', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'resumeFromPreviousUpload', @@ -3251,31 +3461,56 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'relative-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': None, + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': None, + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'relativeLocationResolution', @@ -3337,31 +3572,56 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/array-buffer-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': None, + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': None, + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'arrayBufferInput', @@ -3406,31 +3666,56 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/array-buffer-view-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': None, + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': None, + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'arrayBufferViewInput', @@ -3480,32 +3765,56 @@ 'absentHeaders': [ 'Upload-Length', ], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Defer-Length': '1', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/web-stream-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': None, + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': None, + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'webReadableStreamInput', @@ -3555,37 +3864,58 @@ 'absentHeaders': [ 'Upload-Length', ], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Defer-Length': '1', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/node-stream-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': None, + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': None, + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], - 'runtimes': [ - 'node', - ], 'scenarioId': 'nodeReadableStreamInput', 'events': [ { @@ -3603,6 +3933,9 @@ 'key': 'source-close', }, ], + 'runtimes': [ + 'node', + ], }, { 'behavior': 'node-path-input', @@ -3628,36 +3961,58 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/node-path-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], - 'runtimes': [ - 'node', - ], 'scenarioId': 'nodePathInput', 'events': [ { @@ -3675,6 +4030,9 @@ 'key': 'source-close', }, ], + 'runtimes': [ + 'node', + ], }, { 'behavior': 'deferred-length-upload', @@ -3711,32 +4069,56 @@ 'absentHeaders': [ 'Upload-Length', ], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Defer-Length': '1', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/deferred-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'deferredLengthUpload', @@ -3797,37 +4179,61 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, + 'method': None, 'operationId': 'getTusUploadOffset', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Upload-Offset': '3', }, + 'headersSpecified': True, 'statusCode': 200, }, + 'role': 'recover-upload-offset', 'uploadUrl': 'https://tus.io/uploads/override-contract', 'url': 'upload', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 8, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '3', 'X-HTTP-Method-Override': 'PATCH', }, + 'headersSpecified': True, 'method': 'POST', 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-chunk', 'uploadUrl': 'https://tus.io/uploads/override-contract', 'url': 'upload', + 'requestIndex': 1, }, ], 'scenarioId': 'overridePatchMethod', + 'events': [], }, { 'behavior': 'parallel-upload-concat', @@ -3883,81 +4289,141 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Concat': 'partial', 'Upload-Length': '5', 'Upload-Metadata': 'test d29ybGQ=', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/parallel-part-1', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-partial-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Concat': 'partial', 'Upload-Length': '6', 'Upload-Metadata': 'test d29ybGQ=', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/parallel-part-2', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-partial-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 1, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 5, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '5', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-partial-chunk', 'uploadUrl': 'https://tus.io/uploads/parallel-part-1', 'url': 'upload', + 'requestIndex': 2, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 6, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '6', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-partial-chunk', 'uploadUrl': 'https://tus.io/uploads/parallel-part-2', 'url': 'upload', + 'requestIndex': 3, }, { 'absentHeaders': [ 'Upload-Length', ], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Concat': 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', 'Upload-Metadata': 'foo aGVsbG8=', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/parallel-final', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-final-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 4, }, ], 'scenarioId': 'parallelUploadConcat', @@ -4044,6 +4510,11 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Concat': 'partial', 'Upload-Length': '5', @@ -4051,16 +4522,29 @@ 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/parallel-cleanup-part-1', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-partial-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Concat': 'partial', 'Upload-Length': '6', @@ -4068,17 +4552,29 @@ 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/parallel-cleanup-part-2', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-partial-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 1, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 5, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', @@ -4086,17 +4582,27 @@ 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + 'headersSpecified': True, 'method': 'POST', 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, 'statusCode': 500, }, + 'role': 'upload-partial-chunk', 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-1', 'url': 'upload', + 'requestIndex': 2, }, { + 'absentHeaders': [], 'abort': True, 'bodySize': 6, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', @@ -4104,34 +4610,64 @@ 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + 'headersSpecified': True, 'method': 'POST', 'operationId': 'patchTusUpload', + 'response': None, + 'role': 'upload-partial-chunk', 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-2', 'url': 'upload', + 'requestIndex': 3, }, { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'terminateTusUpload', 'response': { + 'body': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, 'statusCode': 204, }, + 'role': 'terminate-upload', 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-1', 'url': 'upload', + 'requestIndex': 4, }, { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'terminateTusUpload', 'response': { + 'body': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, 'statusCode': 204, }, + 'role': 'terminate-upload', 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-2', 'url': 'upload', + 'requestIndex': 5, }, ], 'scenarioId': 'parallelUploadAbortCleanup', @@ -4175,75 +4711,154 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/retry-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, 'statusCode': 500, }, + 'role': 'upload-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, + 'method': None, 'operationId': 'getTusUploadOffset', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Upload-Offset': '5', }, + 'headersSpecified': True, 'statusCode': 200, }, + 'role': 'recover-upload-offset', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 2, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 6, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '5', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, 'statusCode': 500, }, + 'role': 'retry-upload-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 3, }, { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, + 'method': None, 'operationId': 'getTusUploadOffset', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Upload-Offset': '5', }, + 'headersSpecified': True, 'statusCode': 200, }, + 'role': 'recover-upload-offset', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 4, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 6, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '5', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-final-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 5, }, ], 'scenarioId': 'retryPatchAfterOffsetRecovery', @@ -4293,15 +4908,29 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, + 'method': None, 'operationId': 'getTusUploadOffset', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Upload-Offset': '11', }, + 'headersSpecified': True, 'statusCode': 200, }, + 'role': 'recover-upload-offset', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 0, }, ], 'scenarioId': 'requestLifecycleHooks', @@ -4356,12 +4985,22 @@ ], 'requests': [ { + 'absentHeaders': [], 'abort': True, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', + 'response': None, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, ], 'scenarioId': 'abortUpload', @@ -4414,21 +5053,37 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/abort-terminate-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], 'abort': True, 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', @@ -4436,20 +5091,39 @@ 'X-Tus-Contract': 'abort-policy', 'X-Tus-Trace': 'abort-trace-123', }, + 'headersSpecified': True, 'method': 'POST', 'operationId': 'patchTusUpload', + 'response': None, + 'role': 'abort-upload-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'X-Tus-Contract': 'abort-policy', 'X-Tus-Trace': 'abort-trace-123', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'terminateTusUpload', 'response': { + 'body': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, 'statusCode': 204, }, + 'role': 'terminate-upload', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 2, }, ], 'scenarioId': 'abortUploadAfterStoredUrl', @@ -4501,45 +5175,100 @@ ], 'requests': [ { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Length': '11', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'createTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Location': 'https://tus.io/uploads/terminate-contract', }, + 'headersSpecified': True, 'statusCode': 201, }, + 'role': 'create-upload', + 'uploadUrl': None, 'url': 'endpoint', + 'requestIndex': 0, }, { + 'absentHeaders': [], + 'abort': False, 'bodySize': 5, + 'errorMessage': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '0', }, + 'headersSpecified': True, + 'method': None, 'operationId': 'patchTusUpload', 'response': { + 'body': None, + 'headerMode': None, 'headers': { 'Upload-Offset': '5', }, + 'headersSpecified': True, 'statusCode': 204, }, + 'role': 'upload-chunk', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 1, }, { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, + 'method': None, 'operationId': 'terminateTusUpload', 'response': { + 'body': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, 'statusCode': 423, }, + 'role': 'terminate-upload', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 2, }, { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, + 'method': None, 'operationId': 'terminateTusUpload', 'response': { + 'body': None, + 'headerMode': None, + 'headers': {}, + 'headersSpecified': False, 'statusCode': 204, }, + 'role': 'retry-terminate-upload', + 'uploadUrl': None, 'url': 'upload', + 'requestIndex': 3, }, ], 'scenarioId': 'terminateWithRetry', From 59ca230122fdf5bc438dc4c7cbc8ecf629f0704e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 14:10:01 +0200 Subject: [PATCH 45/95] Regenerate TUS runtime event proofs --- tests/test_generated_runtime_events.py | 38 +++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 0df16f8..e4ac0fa 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -16,6 +16,7 @@ { 'chunkSize': 11, 'content': 'hello world', + 'endpointHasTrailingSlash': False, 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:0:11', @@ -28,6 +29,7 @@ 'transportProgress': 'may-emit-extra-samples', }, 'execution': None, + 'locationHeaderKind': 'absolute', 'metadata': { 'filename': 'hello.txt', }, @@ -36,10 +38,13 @@ { 'headers': { 'Upload-Length': '11', + 'Tus-Resumable': '1.0.0', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'method': 'POST', 'responseHeaders': { 'Location': 'https://tus.io/uploads/generated-contract', + 'Tus-Resumable': '1.0.0', }, 'statusCode': 201, 'url': 'endpoint', @@ -47,10 +52,13 @@ { 'headers': { 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + 'Tus-Resumable': '1.0.0', }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', }, 'statusCode': 204, 'url': 'upload', @@ -59,11 +67,13 @@ 'scenarioId': 'singleUploadLifecycle', 'storedUpload': None, 'uploadLengthDeferred': False, + 'uploadPath': 'generated-contract', 'uploadUrl': 'https://tus.io/uploads/generated-contract', }, { 'chunkSize': 6, 'content': 'hello world', + 'endpointHasTrailingSlash': False, 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:5:11', @@ -84,15 +94,19 @@ }, ], }, + 'locationHeaderKind': 'stored', 'metadata': {}, 'removeFingerprintOnSuccess': True, 'requests': [ { - 'headers': {}, + 'headers': { + 'Tus-Resumable': '1.0.0', + }, 'method': 'HEAD', 'responseHeaders': { 'Upload-Length': '11', 'Upload-Offset': '5', + 'Tus-Resumable': '1.0.0', }, 'statusCode': 200, 'url': 'upload', @@ -100,10 +114,13 @@ { 'headers': { 'Upload-Offset': '5', + 'Content-Type': 'application/offset+octet-stream', + 'Tus-Resumable': '1.0.0', }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', }, 'statusCode': 204, 'url': 'upload', @@ -116,11 +133,13 @@ 'urlStorageKey': 'tus::contract-resume-fingerprint::1337', }, 'uploadLengthDeferred': False, + 'uploadPath': 'resume-contract', 'uploadUrl': 'https://tus.io/uploads/resume-contract', }, { 'chunkSize': 11, 'content': 'hello world', + 'endpointHasTrailingSlash': True, 'endpointUrl': 'https://tus.io/files/', 'eventKeys': [ 'progress:0:11', @@ -133,6 +152,7 @@ 'transportProgress': 'may-emit-extra-samples', }, 'execution': None, + 'locationHeaderKind': 'relative', 'metadata': { 'filename': 'hello.txt', }, @@ -141,10 +161,13 @@ { 'headers': { 'Upload-Length': '11', + 'Tus-Resumable': '1.0.0', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'method': 'POST', 'responseHeaders': { 'Location': 'relative-contract', + 'Tus-Resumable': '1.0.0', }, 'statusCode': 201, 'url': 'endpoint', @@ -152,10 +175,13 @@ { 'headers': { 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + 'Tus-Resumable': '1.0.0', }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', }, 'statusCode': 204, 'url': 'upload', @@ -164,11 +190,13 @@ 'scenarioId': 'relativeLocationResolution', 'storedUpload': None, 'uploadLengthDeferred': False, + 'uploadPath': 'relative-contract', 'uploadUrl': 'https://tus.io/files/relative-contract', }, { 'chunkSize': 100, 'content': 'hello world', + 'endpointHasTrailingSlash': False, 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:0:11', @@ -181,6 +209,7 @@ 'transportProgress': 'may-emit-extra-samples', }, 'execution': None, + 'locationHeaderKind': 'absolute', 'metadata': { 'filename': 'hello.txt', }, @@ -189,10 +218,13 @@ { 'headers': { 'Upload-Defer-Length': '1', + 'Tus-Resumable': '1.0.0', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'method': 'POST', 'responseHeaders': { 'Location': 'https://tus.io/uploads/deferred-contract', + 'Tus-Resumable': '1.0.0', }, 'statusCode': 201, 'url': 'endpoint', @@ -201,10 +233,13 @@ 'headers': { 'Upload-Length': '11', 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + 'Tus-Resumable': '1.0.0', }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', + 'Tus-Resumable': '1.0.0', }, 'statusCode': 204, 'url': 'upload', @@ -213,6 +248,7 @@ 'scenarioId': 'deferredLengthUpload', 'storedUpload': None, 'uploadLengthDeferred': True, + 'uploadPath': 'deferred-contract', 'uploadUrl': 'https://tus.io/uploads/deferred-contract', }, ] From e4467d00d1d81c2a4ee7fc48480be0da776f6ed0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 14:30:58 +0200 Subject: [PATCH 46/95] Use generated TUS default headers --- tests/test_generated_protocol_contract.py | 18 +++++++++++++----- tests/test_uploader.py | 5 +++-- tusclient/protocol_generated.py | 6 ++++++ tusclient/uploader/baseuploader.py | 4 ++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/test_generated_protocol_contract.py b/tests/test_generated_protocol_contract.py index 1822a3f..a4e28d7 100644 --- a/tests/test_generated_protocol_contract.py +++ b/tests/test_generated_protocol_contract.py @@ -10,7 +10,12 @@ TUS_WIRE_VERSIONS, ) from tusclient.client import TusClient -from tusclient.protocol_generated import DEFAULT_PROTOCOL_VERSION +from tusclient.protocol_generated import ( + DEFAULT_PROTOCOL_VERSION, + DEFAULT_REQUEST_HEADERS, + DEFAULT_RESPONSE_HEADERS, +) +from tusclient.uploader.baseuploader import BaseUploader def default_wire_version(): @@ -52,10 +57,10 @@ def response_headers_for(response, overrides): for field in variant["fields"]: if not field["required"]: continue - headers[field["displayName"]] = overrides.get( - field["displayName"], - default_wire_version(), - ) + if field["displayName"] in overrides: + headers[field["displayName"]] = overrides[field["displayName"]] + continue + headers[field["displayName"]] = DEFAULT_RESPONSE_HEADERS[field["displayName"]] return headers @@ -64,6 +69,9 @@ def request_header(request, field): class GeneratedProtocolContractTest(unittest.TestCase): + def test_runtime_default_headers_are_generated_contract_headers(self): + self.assertEqual(BaseUploader.DEFAULT_HEADERS, DEFAULT_REQUEST_HEADERS) + @responses.activate def test_drives_create_and_patch_lifecycle_assertions_from_generated_contract(self): self.assertEqual(DEFAULT_PROTOCOL_VERSION, default_wire_version()) diff --git a/tests/test_uploader.py b/tests/test_uploader.py index aaec290..0c54226 100644 --- a/tests/test_uploader.py +++ b/tests/test_uploader.py @@ -11,6 +11,7 @@ from tusclient import exceptions from tusclient.fingerprint import fingerprint +from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS from tusclient.storage import filestorage from tests import mixin @@ -35,10 +36,10 @@ def test_instance_attributes(self): self.assertEqual(self.uploader.offset, 0) def test_headers(self): - self.assertEqual(self.uploader.get_headers(), {"Tus-Resumable": "1.0.0"}) + self.assertEqual(self.uploader.get_headers(), DEFAULT_REQUEST_HEADERS) self.client.set_headers({'foo': 'bar'}) - self.assertEqual(self.uploader.get_headers(), {"Tus-Resumable": "1.0.0", 'foo': 'bar'}) + self.assertEqual(self.uploader.get_headers(), dict(DEFAULT_REQUEST_HEADERS, foo='bar')) @responses.activate def test_get_offset(self): diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index 4be9352..4ef6b3d 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -3,3 +3,9 @@ # the source fix belongs in the protocol contract generator so all TUS clients stay in sync. DEFAULT_PROTOCOL_VERSION = '1.0.0' +DEFAULT_REQUEST_HEADERS = { + 'Tus-Resumable': '1.0.0', +} +DEFAULT_RESPONSE_HEADERS = { + 'Tus-Resumable': '1.0.0', +} diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index 405c77d..d805b69 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -10,7 +10,7 @@ from tusclient.exceptions import TusCommunicationError from tusclient.request import TusRequest, catch_requests_error from tusclient.fingerprint import fingerprint, interface -from tusclient.protocol_generated import DEFAULT_PROTOCOL_VERSION +from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS from tusclient.storage.interface import Storage if TYPE_CHECKING: @@ -104,7 +104,7 @@ class BaseUploader: - upload_length_deferred (Optional[bool]) """ - DEFAULT_HEADERS = {"Tus-Resumable": DEFAULT_PROTOCOL_VERSION} + DEFAULT_HEADERS = dict(DEFAULT_REQUEST_HEADERS) DEFAULT_CHUNK_SIZE = MAXSIZE CHECKSUM_ALGORITHM_PAIR = ( "sha1", From fa512b4deff2458a1d02af76659051353298430b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 15:39:34 +0200 Subject: [PATCH 47/95] Regenerate Python TUS default header fixtures --- tests/test_generated_runtime_events.py | 49 ++++++++++++++++---------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index e4ac0fa..340e435 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -9,6 +9,7 @@ from tusclient.client import TusClient from tusclient.fingerprint.interface import Fingerprint +from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS, DEFAULT_RESPONSE_HEADERS from tusclient.storage.interface import Storage @@ -38,30 +39,30 @@ { 'headers': { 'Upload-Length': '11', - 'Tus-Resumable': '1.0.0', 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'method': 'POST', 'responseHeaders': { 'Location': 'https://tus.io/uploads/generated-contract', - 'Tus-Resumable': '1.0.0', }, 'statusCode': 201, 'url': 'endpoint', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, }, { 'headers': { 'Upload-Offset': '0', 'Content-Type': 'application/offset+octet-stream', - 'Tus-Resumable': '1.0.0', }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', - 'Tus-Resumable': '1.0.0', }, 'statusCode': 204, 'url': 'upload', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, }, ], 'scenarioId': 'singleUploadLifecycle', @@ -99,31 +100,30 @@ 'removeFingerprintOnSuccess': True, 'requests': [ { - 'headers': { - 'Tus-Resumable': '1.0.0', - }, + 'headers': {}, 'method': 'HEAD', 'responseHeaders': { 'Upload-Length': '11', 'Upload-Offset': '5', - 'Tus-Resumable': '1.0.0', }, 'statusCode': 200, 'url': 'upload', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, }, { 'headers': { 'Upload-Offset': '5', 'Content-Type': 'application/offset+octet-stream', - 'Tus-Resumable': '1.0.0', }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', - 'Tus-Resumable': '1.0.0', }, 'statusCode': 204, 'url': 'upload', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, }, ], 'scenarioId': 'resumeFromPreviousUpload', @@ -161,30 +161,30 @@ { 'headers': { 'Upload-Length': '11', - 'Tus-Resumable': '1.0.0', 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'method': 'POST', 'responseHeaders': { 'Location': 'relative-contract', - 'Tus-Resumable': '1.0.0', }, 'statusCode': 201, 'url': 'endpoint', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, }, { 'headers': { 'Upload-Offset': '0', 'Content-Type': 'application/offset+octet-stream', - 'Tus-Resumable': '1.0.0', }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', - 'Tus-Resumable': '1.0.0', }, 'statusCode': 204, 'url': 'upload', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, }, ], 'scenarioId': 'relativeLocationResolution', @@ -218,31 +218,31 @@ { 'headers': { 'Upload-Defer-Length': '1', - 'Tus-Resumable': '1.0.0', 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'method': 'POST', 'responseHeaders': { 'Location': 'https://tus.io/uploads/deferred-contract', - 'Tus-Resumable': '1.0.0', }, 'statusCode': 201, 'url': 'endpoint', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, }, { 'headers': { 'Upload-Length': '11', 'Upload-Offset': '0', 'Content-Type': 'application/offset+octet-stream', - 'Tus-Resumable': '1.0.0', }, 'method': 'PATCH', 'responseHeaders': { 'Upload-Offset': '11', - 'Tus-Resumable': '1.0.0', }, 'statusCode': 204, 'url': 'upload', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, }, ], 'scenarioId': 'deferredLengthUpload', @@ -405,7 +405,7 @@ def test_sync_uploader_emits_generated_progress_and_chunk_events(self): responses.add( request['method'], url, - adding_headers=request['responseHeaders'], + adding_headers=response_headers_for(request), status=request['statusCode'], ) @@ -456,6 +456,14 @@ def request_header(request, name): return request.headers.get(name) or request.headers.get(name.lower()) +def response_headers_for(request): + headers = {} + if request['includesDefaultProtocolResponseHeaders']: + headers.update(DEFAULT_RESPONSE_HEADERS) + headers.update(request['responseHeaders']) + return headers + + def assert_request_sequence(test, case, calls): test.assertEqual(len(calls), len(case['requests']), case['scenarioId']) @@ -469,6 +477,9 @@ def assert_request_sequence(test, case, calls): test.assertEqual(actual_request.method, expected_request['method'], case['scenarioId']) test.assertEqual(actual_request.url, expected_url, case['scenarioId']) + if expected_request['includesDefaultProtocolRequestHeaders']: + for name, value in DEFAULT_REQUEST_HEADERS.items(): + test.assertEqual(request_header(actual_request, name), value, case['scenarioId']) for name, value in expected_request['headers'].items(): test.assertEqual(request_header(actual_request, name), value, case['scenarioId']) From dbaa0ee185de394fadd2eec6008ac1625da6d1da Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 02:09:40 +0200 Subject: [PATCH 48/95] Add generated TUS request ID proof --- tests/generated_protocol_contract.py | 158 ++++++++++++++++++++++----- 1 file changed, 133 insertions(+), 25 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 972ce45..2f2863d 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -552,6 +552,41 @@ 'apply-custom-request-headers', ], }, + { + 'conformance': { + 'scenarioIds': [ + 'requestIdHeaders', + ], + 'status': 'covered-by-generated-scenario', + }, + 'description': 'Add generated request IDs after protocol and custom request headers.', + 'featureId': 'requestIdHeaders', + 'flow': [ + { + 'kind': 'primitive', + 'primitive': 'add-request-id-header', + 'summary': 'Generate a request ID and apply it after custom request headers so it is authoritative.', + }, + { + 'kind': 'operation', + 'operationId': 'createTusUpload', + 'summary': 'Create uploads with a generated request ID.', + }, + { + 'kind': 'operation', + 'operationId': 'patchTusUpload', + 'summary': 'Upload bytes with a generated request ID.', + }, + ], + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'add-request-id-header', + 'apply-custom-request-headers', + ], + }, { 'conformance': { 'scenarioIds': [ @@ -2320,7 +2355,6 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Length': '11', }, 'headersSpecified': True, @@ -2408,7 +2442,6 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Length': '11', }, 'headersSpecified': True, @@ -2436,7 +2469,6 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, 'headersSpecified': True, @@ -2463,7 +2495,6 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '10', }, 'headersSpecified': True, @@ -2595,10 +2626,10 @@ 'errorMessage': None, 'headerMode': 'exact', 'headers': { - 'Content-Type': 'application/partial-upload', + 'Upload-Length': '11', 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', 'Upload-Draft-Interop-Version': '6', - 'Upload-Length': '11', }, 'headersSpecified': True, 'method': None, @@ -3170,7 +3201,6 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -3205,6 +3235,7 @@ 'content': 'hello world', 'endpointUrl': 'https://tus.io/uploads', 'headers': { + 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -3229,6 +3260,7 @@ 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -3256,8 +3288,8 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', + 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -3282,6 +3314,93 @@ 'scenarioId': 'customRequestHeaders', 'events': [], }, + { + 'behavior': 'request-id-headers', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/request-id-contract', + }, + 'featureId': 'requestIdHeaders', + 'input': { + 'addRequestId': True, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'generatedRequestId': '00000000-0000-4000-8000-000000000000', + 'headers': { + 'X-Request-ID': 'custom-request-id', + }, + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'add-request-id-header', + 'apply-custom-request-headers', + ], + 'requests': [ + { + 'absentHeaders': [], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, + 'headers': { + 'Upload-Length': '11', + 'X-Request-ID': '00000000-0000-4000-8000-000000000000', + }, + 'headersSpecified': True, + 'method': None, + 'operationId': 'createTusUpload', + 'response': { + 'body': None, + 'headerMode': None, + 'headers': { + 'Location': 'https://tus.io/uploads/request-id-contract', + }, + 'headersSpecified': True, + 'statusCode': 201, + }, + 'role': 'create-upload', + 'uploadUrl': None, + 'url': 'endpoint', + 'requestIndex': 0, + }, + { + 'absentHeaders': [], + 'abort': False, + 'bodySize': 11, + 'errorMessage': None, + 'headerMode': None, + 'headers': { + 'Upload-Offset': '0', + 'X-Request-ID': '00000000-0000-4000-8000-000000000000', + }, + 'headersSpecified': True, + 'method': None, + 'operationId': 'patchTusUpload', + 'response': { + 'body': None, + 'headerMode': None, + 'headers': { + 'Upload-Offset': '11', + }, + 'headersSpecified': True, + 'statusCode': 204, + }, + 'role': 'upload-chunk', + 'uploadUrl': None, + 'url': 'upload', + 'requestIndex': 1, + }, + ], + 'scenarioId': 'requestIdHeaders', + 'events': [], + }, { 'behavior': 'resume-from-previous-upload', 'completion': { @@ -4210,12 +4329,10 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '3', - 'X-HTTP-Method-Override': 'PATCH', }, 'headersSpecified': True, - 'method': 'POST', + 'method': None, 'operationId': 'patchTusUpload', 'response': { 'body': None, @@ -4297,7 +4414,6 @@ 'headers': { 'Upload-Concat': 'partial', 'Upload-Length': '5', - 'Upload-Metadata': 'test d29ybGQ=', }, 'headersSpecified': True, 'method': None, @@ -4325,7 +4441,6 @@ 'headers': { 'Upload-Concat': 'partial', 'Upload-Length': '6', - 'Upload-Metadata': 'test d29ybGQ=', }, 'headersSpecified': True, 'method': None, @@ -4406,7 +4521,6 @@ 'headerMode': None, 'headers': { 'Upload-Concat': 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', - 'Upload-Metadata': 'foo aGVsbG8=', }, 'headersSpecified': True, 'method': None, @@ -4518,7 +4632,6 @@ 'headers': { 'Upload-Concat': 'partial', 'Upload-Length': '5', - 'Upload-Metadata': 'test d29ybGQ=', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, @@ -4548,7 +4661,6 @@ 'headers': { 'Upload-Concat': 'partial', 'Upload-Length': '6', - 'Upload-Metadata': 'test d29ybGQ=', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, @@ -4576,14 +4688,12 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'X-HTTP-Method-Override': 'PATCH', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, 'headersSpecified': True, - 'method': 'POST', + 'method': None, 'operationId': 'patchTusUpload', 'response': { 'body': None, @@ -4604,14 +4714,12 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'X-HTTP-Method-Override': 'PATCH', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', }, 'headersSpecified': True, - 'method': 'POST', + 'method': None, 'operationId': 'patchTusUpload', 'response': None, 'role': 'upload-partial-chunk', @@ -5060,6 +5168,8 @@ 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', }, 'headersSpecified': True, 'method': None, @@ -5085,14 +5195,12 @@ 'errorMessage': None, 'headerMode': None, 'headers': { - 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'X-HTTP-Method-Override': 'PATCH', 'X-Tus-Contract': 'abort-policy', 'X-Tus-Trace': 'abort-trace-123', }, 'headersSpecified': True, - 'method': 'POST', + 'method': None, 'operationId': 'patchTusUpload', 'response': None, 'role': 'abort-upload-chunk', From d2bc87efe32fecc34cdd243f94b5dc44124e4c14 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:57:59 +0200 Subject: [PATCH 49/95] Regenerate TUS deferred length proofs --- tests/generated_protocol_contract.py | 483 ++++++++++++++++++++- tests/test_generated_conformance_events.py | 48 ++ tests/test_generated_runtime_events.py | 102 ++++- 3 files changed, 617 insertions(+), 16 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 2f2863d..18fe956 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -427,10 +427,11 @@ 'conformance': { 'scenarioIds': [ 'deferredLengthUpload', + 'deferredLengthChunkedUpload', ], 'status': 'covered-by-generated-scenario', }, - 'description': 'Create an upload without a known length and declare the length on final PATCH.', + 'description': 'Create an upload without a known length and declare the length on the final upload request.', 'featureId': 'deferredLengthUpload', 'flow': [ { @@ -441,12 +442,12 @@ { 'kind': 'primitive', 'primitive': 'defer-upload-length', - 'summary': 'Track the source until the final chunk reveals the total size.', + 'summary': 'Track the source until the final upload request reveals the total size.', }, { 'kind': 'operation', 'operationId': 'patchTusUpload', - 'summary': 'Declare Upload-Length on the final chunk request.', + 'summary': 'Declare Upload-Length on the final upload request.', }, ], 'operationIds': [ @@ -455,6 +456,7 @@ ], 'primitives': [ 'defer-upload-length', + 'emit-chunk-complete', 'emit-progress', ], }, @@ -953,6 +955,7 @@ 'conformance': { 'scenarioIds': [ 'ietfDraft05CreationWithUpload', + 'ietfDraft05ChunkedUploadComplete', 'ietfDraft03ResumeWithoutKnownLength', ], 'status': 'covered-by-generated-scenario', @@ -2253,6 +2256,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -2356,6 +2360,7 @@ 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', }, 'headersSpecified': True, 'method': None, @@ -2443,6 +2448,7 @@ 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', }, 'headersSpecified': True, 'method': None, @@ -2469,6 +2475,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, 'headersSpecified': True, @@ -2495,6 +2502,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '10', }, 'headersSpecified': True, @@ -2629,7 +2637,6 @@ 'Upload-Length': '11', 'Upload-Complete': '?1', 'Content-Type': 'application/partial-upload', - 'Upload-Draft-Interop-Version': '6', }, 'headersSpecified': True, 'method': None, @@ -2678,6 +2685,225 @@ }, ], }, + { + 'behavior': 'upload-body-headers', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', + }, + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, + 'featureId': 'protocolVersionSelection', + 'input': { + 'chunkSize': 5, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'protocol': 'ietf-draft-05', + 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', + }, + 'operationIds': [ + 'getTusUploadOffset', + 'patchTusUpload', + ], + 'primitives': [ + 'select-client-protocol', + ], + 'requests': [ + { + 'absentHeaders': [ + 'Tus-Resumable', + ], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': 'exact', + 'headers': {}, + 'headersSpecified': False, + 'method': None, + 'operationId': 'getTusUploadOffset', + 'response': { + 'body': None, + 'headerMode': 'exact', + 'headers': { + 'Upload-Length': '11', + 'Upload-Offset': '0', + }, + 'headersSpecified': True, + 'statusCode': 200, + }, + 'role': None, + 'uploadUrl': None, + 'url': 'upload', + 'requestIndex': 0, + }, + { + 'absentHeaders': [ + 'Tus-Resumable', + ], + 'abort': False, + 'bodySize': 5, + 'errorMessage': None, + 'headerMode': 'exact', + 'headers': { + 'Upload-Complete': '?0', + 'Content-Type': 'application/partial-upload', + 'Upload-Offset': '0', + }, + 'headersSpecified': True, + 'method': None, + 'operationId': 'patchTusUpload', + 'response': { + 'body': None, + 'headerMode': 'exact', + 'headers': { + 'Upload-Offset': '5', + }, + 'headersSpecified': True, + 'statusCode': 204, + }, + 'role': 'upload-chunk', + 'uploadUrl': None, + 'url': 'upload', + 'requestIndex': 1, + }, + { + 'absentHeaders': [ + 'Tus-Resumable', + ], + 'abort': False, + 'bodySize': 5, + 'errorMessage': None, + 'headerMode': 'exact', + 'headers': { + 'Upload-Complete': '?0', + 'Content-Type': 'application/partial-upload', + 'Upload-Offset': '5', + }, + 'headersSpecified': True, + 'method': None, + 'operationId': 'patchTusUpload', + 'response': { + 'body': None, + 'headerMode': 'exact', + 'headers': { + 'Upload-Offset': '10', + }, + 'headersSpecified': True, + 'statusCode': 204, + }, + 'role': 'upload-chunk', + 'uploadUrl': None, + 'url': 'upload', + 'requestIndex': 2, + }, + { + 'absentHeaders': [ + 'Tus-Resumable', + ], + 'abort': False, + 'bodySize': 1, + 'errorMessage': None, + 'headerMode': 'exact', + 'headers': { + 'Upload-Complete': '?1', + 'Content-Type': 'application/partial-upload', + 'Upload-Offset': '10', + }, + 'headersSpecified': True, + 'method': None, + 'operationId': 'patchTusUpload', + 'response': { + 'body': None, + 'headerMode': 'exact', + 'headers': { + 'Upload-Offset': '11', + }, + 'headersSpecified': True, + 'statusCode': 204, + }, + 'role': 'upload-final-chunk', + 'uploadUrl': None, + 'url': 'upload', + 'requestIndex': 3, + }, + ], + 'scenarioId': 'ietfDraft05ChunkedUploadComplete', + 'events': [ + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'bytesSent': 0, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:0:11', + }, + { + 'bytesSent': 5, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:5:11', + }, + { + 'bytesAccepted': 5, + 'bytesTotal': 11, + 'chunkSize': 5, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:5:5:11', + }, + { + 'bytesSent': 5, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:5:11', + }, + { + 'bytesSent': 10, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:10:11', + }, + { + 'bytesAccepted': 10, + 'bytesTotal': 11, + 'chunkSize': 5, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:5:10:11', + }, + { + 'bytesSent': 10, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:10:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'bytesAccepted': 11, + 'bytesTotal': 11, + 'chunkSize': 1, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:1:11:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, { 'behavior': 'upload-body-headers', 'completion': { @@ -2714,10 +2940,8 @@ 'bodySize': None, 'errorMessage': None, 'headerMode': 'exact', - 'headers': { - 'Upload-Draft-Interop-Version': '5', - }, - 'headersSpecified': True, + 'headers': {}, + 'headersSpecified': False, 'method': None, 'operationId': 'getTusUploadOffset', 'response': { @@ -2745,7 +2969,6 @@ 'headerMode': 'exact', 'headers': { 'Upload-Complete': '?1', - 'Upload-Draft-Interop-Version': '5', 'Upload-Offset': '5', }, 'headersSpecified': True, @@ -3201,6 +3424,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -3235,7 +3459,6 @@ 'content': 'hello world', 'endpointUrl': 'https://tus.io/uploads', 'headers': { - 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -3260,7 +3483,6 @@ 'headerMode': None, 'headers': { 'Upload-Length': '11', - 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -3288,8 +3510,8 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', - 'Content-Type': 'application/x-tus-custom-body', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -3377,6 +3599,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', 'X-Request-ID': '00000000-0000-4000-8000-000000000000', }, @@ -3475,6 +3698,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, 'headersSpecified': True, @@ -3612,6 +3836,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -3723,6 +3948,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -3817,6 +4043,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -3916,6 +4143,7 @@ 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -4015,6 +4243,7 @@ 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -4112,6 +4341,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -4160,6 +4390,7 @@ 'uploadUrl': 'https://tus.io/uploads/deferred-contract', }, 'eventPolicy': { + 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', @@ -4220,6 +4451,7 @@ 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -4275,6 +4507,223 @@ }, ], }, + { + 'behavior': 'deferred-length-upload', + 'completion': { + 'kind': 'success', + 'uploadUrl': 'https://tus.io/uploads/deferred-chunked-contract', + }, + 'eventPolicy': { + 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, + 'featureId': 'deferredLengthUpload', + 'input': { + 'chunkSize': 5, + 'content': 'hello world', + 'endpointUrl': 'https://tus.io/uploads', + 'kind': 'blob', + 'metadata': { + 'filename': 'hello.txt', + }, + 'uploadLengthDeferred': True, + }, + 'operationIds': [ + 'createTusUpload', + 'patchTusUpload', + ], + 'primitives': [ + 'defer-upload-length', + 'emit-chunk-complete', + 'emit-progress', + ], + 'requests': [ + { + 'absentHeaders': [ + 'Upload-Length', + ], + 'abort': False, + 'bodySize': None, + 'errorMessage': None, + 'headerMode': None, + 'headers': { + 'Upload-Defer-Length': '1', + }, + 'headersSpecified': True, + 'method': None, + 'operationId': 'createTusUpload', + 'response': { + 'body': None, + 'headerMode': None, + 'headers': { + 'Location': 'https://tus.io/uploads/deferred-chunked-contract', + }, + 'headersSpecified': True, + 'statusCode': 201, + }, + 'role': 'create-upload', + 'uploadUrl': None, + 'url': 'endpoint', + 'requestIndex': 0, + }, + { + 'absentHeaders': [], + 'abort': False, + 'bodySize': 5, + 'errorMessage': None, + 'headerMode': None, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + 'headersSpecified': True, + 'method': None, + 'operationId': 'patchTusUpload', + 'response': { + 'body': None, + 'headerMode': None, + 'headers': { + 'Upload-Offset': '5', + }, + 'headersSpecified': True, + 'statusCode': 204, + }, + 'role': 'upload-chunk', + 'uploadUrl': None, + 'url': 'upload', + 'requestIndex': 1, + }, + { + 'absentHeaders': [], + 'abort': False, + 'bodySize': 5, + 'errorMessage': None, + 'headerMode': None, + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + 'headersSpecified': True, + 'method': None, + 'operationId': 'patchTusUpload', + 'response': { + 'body': None, + 'headerMode': None, + 'headers': { + 'Upload-Offset': '10', + }, + 'headersSpecified': True, + 'statusCode': 204, + }, + 'role': 'upload-chunk', + 'uploadUrl': None, + 'url': 'upload', + 'requestIndex': 2, + }, + { + 'absentHeaders': [], + 'abort': False, + 'bodySize': 1, + 'errorMessage': None, + 'headerMode': None, + 'headers': { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '10', + }, + 'headersSpecified': True, + 'method': None, + 'operationId': 'patchTusUpload', + 'response': { + 'body': None, + 'headerMode': None, + 'headers': { + 'Upload-Offset': '11', + }, + 'headersSpecified': True, + 'statusCode': 204, + }, + 'role': 'upload-final-chunk', + 'uploadUrl': None, + 'url': 'upload', + 'requestIndex': 3, + }, + ], + 'scenarioId': 'deferredLengthChunkedUpload', + 'events': [ + { + 'kind': 'upload-url-available', + 'key': 'upload-url-available', + }, + { + 'bytesSent': 0, + 'bytesTotal': None, + 'kind': 'progress', + 'key': 'progress:0:null', + }, + { + 'bytesSent': 5, + 'bytesTotal': None, + 'kind': 'progress', + 'key': 'progress:5:null', + }, + { + 'bytesAccepted': 5, + 'bytesTotal': None, + 'chunkSize': 5, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:5:5:null', + }, + { + 'bytesSent': 5, + 'bytesTotal': None, + 'kind': 'progress', + 'key': 'progress:5:null', + }, + { + 'bytesSent': 10, + 'bytesTotal': None, + 'kind': 'progress', + 'key': 'progress:10:null', + }, + { + 'bytesAccepted': 10, + 'bytesTotal': None, + 'chunkSize': 5, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:5:10:null', + }, + { + 'bytesSent': 10, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:10:11', + }, + { + 'bytesSent': 11, + 'bytesTotal': 11, + 'kind': 'progress', + 'key': 'progress:11:11', + }, + { + 'bytesAccepted': 11, + 'bytesTotal': 11, + 'chunkSize': 1, + 'kind': 'chunk-complete', + 'key': 'chunk-complete:1:11:11', + }, + { + 'kind': 'success', + 'key': 'success', + }, + { + 'kind': 'source-close', + 'key': 'source-close', + }, + ], + }, { 'behavior': 'override-patch-method', 'completion': { @@ -4329,6 +4778,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '3', }, 'headersSpecified': True, @@ -4466,6 +4916,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -4492,6 +4943,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -4688,6 +5140,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', @@ -4714,6 +5167,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', 'X-Tus-Contract': 'parallel-cleanup-policy', 'X-Tus-Trace': 'parallel-cleanup-trace-123', @@ -4851,6 +5305,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, @@ -4900,6 +5355,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, 'headersSpecified': True, @@ -4949,6 +5405,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '5', }, 'headersSpecified': True, @@ -5195,6 +5652,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', 'X-Tus-Contract': 'abort-policy', 'X-Tus-Trace': 'abort-trace-123', @@ -5315,6 +5773,7 @@ 'errorMessage': None, 'headerMode': None, 'headers': { + 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': '0', }, 'headersSpecified': True, diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index af6974e..63a3ac6 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -87,6 +87,29 @@ 'featureId': 'protocolVersionSelection', 'scenarioId': 'ietfDraft05CreationWithUpload', }, + { + 'eventKeys': [ + 'upload-url-available', + 'progress:0:11', + 'progress:5:11', + 'chunk-complete:5:5:11', + 'progress:5:11', + 'progress:10:11', + 'chunk-complete:5:10:11', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + 'success', + 'source-close', + ], + 'eventPolicy': { + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, + 'featureId': 'protocolVersionSelection', + 'scenarioId': 'ietfDraft05ChunkedUploadComplete', + }, { 'eventKeys': [ 'upload-url-available', @@ -212,6 +235,7 @@ 'source-close', ], 'eventPolicy': { + 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', @@ -219,6 +243,30 @@ 'featureId': 'deferredLengthUpload', 'scenarioId': 'deferredLengthUpload', }, + { + 'eventKeys': [ + 'upload-url-available', + 'progress:0:null', + 'progress:5:null', + 'chunk-complete:5:5:null', + 'progress:5:null', + 'progress:10:null', + 'chunk-complete:5:10:null', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + 'success', + 'source-close', + ], + 'eventPolicy': { + 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, + 'featureId': 'deferredLengthUpload', + 'scenarioId': 'deferredLengthChunkedUpload', + }, { 'eventKeys': [ 'progress:5:11', diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 340e435..c94febc 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -52,8 +52,8 @@ }, { 'headers': { - 'Upload-Offset': '0', 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', }, 'method': 'PATCH', 'responseHeaders': { @@ -113,8 +113,8 @@ }, { 'headers': { - 'Upload-Offset': '5', 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', }, 'method': 'PATCH', 'responseHeaders': { @@ -174,8 +174,8 @@ }, { 'headers': { - 'Upload-Offset': '0', 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', }, 'method': 'PATCH', 'responseHeaders': { @@ -204,6 +204,7 @@ 'chunk-complete:11:11:11', ], 'eventPolicy': { + 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-extra-progress', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', @@ -232,8 +233,8 @@ { 'headers': { 'Upload-Length': '11', - 'Upload-Offset': '0', 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', }, 'method': 'PATCH', 'responseHeaders': { @@ -251,6 +252,99 @@ 'uploadPath': 'deferred-contract', 'uploadUrl': 'https://tus.io/uploads/deferred-contract', }, + { + 'chunkSize': 5, + 'content': 'hello world', + 'endpointHasTrailingSlash': False, + 'endpointUrl': 'https://tus.io/uploads', + 'eventKeys': [ + 'progress:0:null', + 'progress:5:null', + 'chunk-complete:5:5:null', + 'progress:5:null', + 'progress:10:null', + 'chunk-complete:5:10:null', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + ], + 'eventPolicy': { + 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', + 'matching': 'exact-except-extra-progress', + 'progress': 'milestone', + 'transportProgress': 'may-emit-extra-samples', + }, + 'execution': None, + 'locationHeaderKind': 'absolute', + 'metadata': { + 'filename': 'hello.txt', + }, + 'removeFingerprintOnSuccess': False, + 'requests': [ + { + 'headers': { + 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', + }, + 'method': 'POST', + 'responseHeaders': { + 'Location': 'https://tus.io/uploads/deferred-chunked-contract', + }, + 'statusCode': 201, + 'url': 'endpoint', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, + }, + { + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '0', + }, + 'method': 'PATCH', + 'responseHeaders': { + 'Upload-Offset': '5', + }, + 'statusCode': 204, + 'url': 'upload', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, + }, + { + 'headers': { + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '5', + }, + 'method': 'PATCH', + 'responseHeaders': { + 'Upload-Offset': '10', + }, + 'statusCode': 204, + 'url': 'upload', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, + }, + { + 'headers': { + 'Upload-Length': '11', + 'Content-Type': 'application/offset+octet-stream', + 'Upload-Offset': '10', + }, + 'method': 'PATCH', + 'responseHeaders': { + 'Upload-Offset': '11', + }, + 'statusCode': 204, + 'url': 'upload', + 'includesDefaultProtocolRequestHeaders': True, + 'includesDefaultProtocolResponseHeaders': True, + }, + ], + 'scenarioId': 'deferredLengthChunkedUpload', + 'storedUpload': None, + 'uploadLengthDeferred': True, + 'uploadPath': 'deferred-chunked-contract', + 'uploadUrl': 'https://tus.io/uploads/deferred-chunked-contract', + }, ] From 71d46b34569f6c5d4b5f02c85c40b375d312cf5a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:21:12 +0200 Subject: [PATCH 50/95] Regenerate TUS event alternatives --- tests/generated_protocol_contract.py | 186 +++++++++++++++++++++ tests/test_generated_conformance_events.py | 174 +++++++++++++++++++ tests/test_generated_runtime_events.py | 43 +++++ 3 files changed, 403 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 18fe956..615a38d 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2195,6 +2195,16 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/generated-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2329,6 +2339,13 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2415,6 +2432,20 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2602,6 +2633,13 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2691,6 +2729,20 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2910,6 +2962,14 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -3031,6 +3091,7 @@ 'message': 'tus: no file or stream to upload provided', 'reason': 'missingInput', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': '', @@ -3052,6 +3113,7 @@ 'message': 'tus: neither an endpoint or an upload URL is provided', 'reason': 'missingEndpointOrUploadUrl', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3072,6 +3134,7 @@ 'message': 'tus: unsupported protocol tus-v9', 'reason': 'unsupportedProtocol', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3094,6 +3157,7 @@ 'message': 'tus: the `retryDelays` option must either be an array or null', 'reason': 'retryDelaysNotArray', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3118,6 +3182,7 @@ 'message': 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', 'reason': 'parallelUploadsWithUploadUrl', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3141,6 +3206,7 @@ 'message': 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', 'reason': 'parallelUploadsWithUploadSize', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3164,6 +3230,7 @@ 'message': 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', 'reason': 'parallelUploadsWithDeferredLength', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3187,6 +3254,7 @@ 'message': 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', 'reason': 'parallelUploadsWithUploadDataDuringCreation', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3210,6 +3278,7 @@ 'message': 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', 'reason': 'parallelBoundariesWithoutParallelUploads', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3237,6 +3306,7 @@ 'message': 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', 'reason': 'parallelBoundariesLengthMismatch', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3265,6 +3335,7 @@ 'message': 'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)', 'reason': 'unexpectedCreateResponse', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'detailedErrors', 'input': { 'content': 'hello world', @@ -3323,6 +3394,7 @@ 'message': 'tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)', 'reason': 'createUploadRequestFailed', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'detailedErrors', 'input': { 'content': 'hello world', @@ -3374,6 +3446,7 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/upload-body-headers-contract', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'uploadBodyHeaders', 'input': { 'content': 'hello world', @@ -3454,6 +3527,7 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/custom-headers-contract', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'customRequestHeaders', 'input': { 'content': 'hello world', @@ -3542,6 +3616,7 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/request-id-contract', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'requestIdHeaders', 'input': { 'addRequestId': True, @@ -3630,6 +3705,18 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/resume-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -3781,6 +3868,14 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/files/relative-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -3898,6 +3993,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/array-buffer-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -3993,6 +4093,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/array-buffer-view-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4088,6 +4193,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/web-stream-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'featureId': 'inputSources', 'input': { 'chunkSize': 100, @@ -4188,6 +4298,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/node-stream-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'featureId': 'inputSources', 'input': { 'chunkSize': 100, @@ -4291,6 +4406,11 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/node-path-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4389,6 +4509,14 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/deferred-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-extra-progress', @@ -4513,6 +4641,32 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/deferred-chunked-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [ + 'progress:0:11', + ], + [ + 'progress:5:11', + ], + [ + 'chunk-complete:5:5:11', + ], + [ + 'progress:5:11', + ], + [ + 'progress:10:11', + ], + [ + 'chunk-complete:5:10:11', + ], + [], + [], + [], + [], + [], + ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-extra-progress', @@ -4730,6 +4884,7 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/override-contract', }, + 'eventKeyAlternativeGroups': [], 'featureId': 'overridePatchMethod', 'input': { 'content': 'hello world', @@ -4808,6 +4963,12 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/parallel-final', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -5027,6 +5188,9 @@ 'completion': { 'kind': 'aborted', }, + 'eventKeyAlternativeGroups': [ + [], + ], 'execution': { 'serverRequestGates': [ { @@ -5247,6 +5411,12 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/retry-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + ], 'featureId': 'retryOffsetRecovery', 'input': { 'content': 'hello world', @@ -5458,6 +5628,12 @@ 'kind': 'success', 'uploadUrl': 'https://tus.io/uploads/request-hooks-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + ], 'featureId': 'requestLifecycleHooks', 'input': { 'content': 'hello world', @@ -5525,6 +5701,9 @@ 'completion': { 'kind': 'aborted', }, + 'eventKeyAlternativeGroups': [ + [], + ], 'execution': { 'onRequestStart': [ { @@ -5583,6 +5762,9 @@ 'kind': 'aborted', 'uploadUrl': 'https://tus.io/uploads/abort-terminate-contract', }, + 'eventKeyAlternativeGroups': [ + [], + ], 'execution': { 'onRequestStart': [ { @@ -5707,6 +5889,10 @@ 'kind': 'terminated', 'uploadUrl': 'https://tus.io/uploads/terminate-contract', }, + 'eventKeyAlternativeGroups': [ + [], + [], + ], 'execution': { 'onChunkComplete': [ { diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index 63a3ac6..de2ec5c 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -14,6 +14,16 @@ CASES = [ { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'fingerprint:contract-single-fingerprint', 'upload-url-available', @@ -33,6 +43,13 @@ 'scenarioId': 'singleUploadLifecycle', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'progress:0:11', 'progress:11:11', @@ -49,6 +66,20 @@ 'scenarioId': 'creationWithUpload', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'progress:0:11', 'progress:5:11', @@ -72,6 +103,13 @@ 'scenarioId': 'creationWithUploadPartialChunk', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'progress:0:11', 'progress:11:11', @@ -88,6 +126,20 @@ 'scenarioId': 'ietfDraft05CreationWithUpload', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'upload-url-available', 'progress:0:11', @@ -111,6 +163,14 @@ 'scenarioId': 'ietfDraft05ChunkedUploadComplete', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'upload-url-available', 'progress:5:11', @@ -128,6 +188,18 @@ 'scenarioId': 'ietfDraft03ResumeWithoutKnownLength', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'fingerprint:contract-resume-fingerprint', 'url-storage-find:contract-resume-fingerprint:1', @@ -149,6 +221,14 @@ 'scenarioId': 'resumeFromPreviousUpload', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'upload-url-available', 'progress:0:11', @@ -166,6 +246,11 @@ 'scenarioId': 'relativeLocationResolution', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'eventKeys': [ 'source-open:array-buffer:11', 'success', @@ -178,6 +263,11 @@ 'scenarioId': 'arrayBufferInput', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'eventKeys': [ 'source-open:array-buffer-view:11', 'success', @@ -190,6 +280,11 @@ 'scenarioId': 'arrayBufferViewInput', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'eventKeys': [ 'source-open:web-readable-stream:null', 'success', @@ -202,6 +297,11 @@ 'scenarioId': 'webReadableStreamInput', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'eventKeys': [ 'source-open:node-readable-stream:null', 'success', @@ -214,6 +314,11 @@ 'scenarioId': 'nodeReadableStreamInput', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'eventKeys': [ 'source-open:node-path-reference:11', 'success', @@ -226,6 +331,14 @@ 'scenarioId': 'nodePathInput', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'upload-url-available', 'progress:0:11', @@ -244,6 +357,32 @@ 'scenarioId': 'deferredLengthUpload', }, { + 'eventKeyAlternativeGroups': [ + [], + [ + 'progress:0:11', + ], + [ + 'progress:5:11', + ], + [ + 'chunk-complete:5:5:11', + ], + [ + 'progress:5:11', + ], + [ + 'progress:10:11', + ], + [ + 'chunk-complete:5:10:11', + ], + [], + [], + [], + [], + [], + ], 'eventKeys': [ 'upload-url-available', 'progress:0:null', @@ -268,6 +407,12 @@ 'scenarioId': 'deferredLengthChunkedUpload', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + ], 'eventKeys': [ 'progress:5:11', 'chunk-complete:5:5:11', @@ -283,6 +428,9 @@ 'scenarioId': 'parallelUploadConcat', }, { + 'eventKeyAlternativeGroups': [ + [], + ], 'eventKeys': [ 'request-abort:3', ], @@ -293,6 +441,12 @@ 'scenarioId': 'parallelUploadAbortCleanup', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + ], 'eventKeys': [ 'should-retry:0:true', 'retry-schedule:0', @@ -306,6 +460,12 @@ 'scenarioId': 'retryPatchAfterOffsetRecovery', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + [], + [], + ], 'eventKeys': [ 'before-request:0', 'after-response:0', @@ -319,6 +479,9 @@ 'scenarioId': 'requestLifecycleHooks', }, { + 'eventKeyAlternativeGroups': [ + [], + ], 'eventKeys': [ 'request-abort:0', ], @@ -329,6 +492,9 @@ 'scenarioId': 'abortUpload', }, { + 'eventKeyAlternativeGroups': [ + [], + ], 'eventKeys': [ 'request-abort:1', ], @@ -339,6 +505,10 @@ 'scenarioId': 'abortUploadAfterStoredUrl', }, { + 'eventKeyAlternativeGroups': [ + [], + [], + ], 'eventKeys': [ 'should-retry:0:true', 'retry-schedule:0', @@ -465,6 +635,10 @@ def test_generated_scenario_event_keys(self): [event["key"] for event in scenario["events"]], case["eventKeys"], ) + self.assertEqual( + scenario["eventKeyAlternativeGroups"], + case["eventKeyAlternativeGroups"], + ) self.assertEqual( scenario.get("eventPolicy", {"matching": "exact"}), case["eventPolicy"], diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index c94febc..0da679a 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -18,6 +18,11 @@ 'chunkSize': 11, 'content': 'hello world', 'endpointHasTrailingSlash': False, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:0:11', @@ -75,6 +80,11 @@ 'chunkSize': 6, 'content': 'hello world', 'endpointHasTrailingSlash': False, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:5:11', @@ -140,6 +150,11 @@ 'chunkSize': 11, 'content': 'hello world', 'endpointHasTrailingSlash': True, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'endpointUrl': 'https://tus.io/files/', 'eventKeys': [ 'progress:0:11', @@ -197,6 +212,11 @@ 'chunkSize': 100, 'content': 'hello world', 'endpointHasTrailingSlash': False, + 'eventKeyAlternativeGroups': [ + [], + [], + [], + ], 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:0:11', @@ -256,6 +276,29 @@ 'chunkSize': 5, 'content': 'hello world', 'endpointHasTrailingSlash': False, + 'eventKeyAlternativeGroups': [ + [ + 'progress:0:11', + ], + [ + 'progress:5:11', + ], + [ + 'chunk-complete:5:5:11', + ], + [ + 'progress:5:11', + ], + [ + 'progress:10:11', + ], + [ + 'chunk-complete:5:10:11', + ], + [], + [], + [], + ], 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:0:null', From 3accbcc3ff1c47d4b42709c824dd5547611c4f02 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:37:17 +0200 Subject: [PATCH 51/95] Regenerate TUS extra event prefixes --- tests/generated_protocol_contract.py | 60 ++++++++++++++++++++++ tests/test_generated_conformance_events.py | 48 +++++++++++++++++ tests/test_generated_runtime_events.py | 15 ++++++ 3 files changed, 123 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 615a38d..de25a7d 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2205,6 +2205,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2346,6 +2349,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2446,6 +2452,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2640,6 +2649,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2743,6 +2755,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -2970,6 +2985,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -3092,6 +3110,7 @@ 'reason': 'missingInput', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': '', @@ -3114,6 +3133,7 @@ 'reason': 'missingEndpointOrUploadUrl', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3135,6 +3155,7 @@ 'reason': 'unsupportedProtocol', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3158,6 +3179,7 @@ 'reason': 'retryDelaysNotArray', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3183,6 +3205,7 @@ 'reason': 'parallelUploadsWithUploadUrl', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3207,6 +3230,7 @@ 'reason': 'parallelUploadsWithUploadSize', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3231,6 +3255,7 @@ 'reason': 'parallelUploadsWithDeferredLength', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3255,6 +3280,7 @@ 'reason': 'parallelUploadsWithUploadDataDuringCreation', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3279,6 +3305,7 @@ 'reason': 'parallelBoundariesWithoutParallelUploads', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3307,6 +3334,7 @@ 'reason': 'parallelBoundariesLengthMismatch', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3336,6 +3364,7 @@ 'reason': 'unexpectedCreateResponse', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'detailedErrors', 'input': { 'content': 'hello world', @@ -3395,6 +3424,7 @@ 'reason': 'createUploadRequestFailed', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'detailedErrors', 'input': { 'content': 'hello world', @@ -3447,6 +3477,7 @@ 'uploadUrl': 'https://tus.io/uploads/upload-body-headers-contract', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'uploadBodyHeaders', 'input': { 'content': 'hello world', @@ -3528,6 +3559,7 @@ 'uploadUrl': 'https://tus.io/uploads/custom-headers-contract', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'customRequestHeaders', 'input': { 'content': 'hello world', @@ -3617,6 +3649,7 @@ 'uploadUrl': 'https://tus.io/uploads/request-id-contract', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'requestIdHeaders', 'input': { 'addRequestId': True, @@ -3717,6 +3750,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -3876,6 +3912,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -3998,6 +4037,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4098,6 +4138,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4198,6 +4239,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'featureId': 'inputSources', 'input': { 'chunkSize': 100, @@ -4303,6 +4345,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'featureId': 'inputSources', 'input': { 'chunkSize': 100, @@ -4411,6 +4454,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4517,6 +4561,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-extra-progress', @@ -4667,6 +4714,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-extra-progress', @@ -4885,6 +4935,7 @@ 'uploadUrl': 'https://tus.io/uploads/override-contract', }, 'eventKeyAlternativeGroups': [], + 'eventKeyExtraPrefixes': [], 'featureId': 'overridePatchMethod', 'input': { 'content': 'hello world', @@ -4969,6 +5020,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventPolicy': { 'matching': 'exact-except-extra-progress', 'progress': 'milestone', @@ -5191,6 +5245,7 @@ 'eventKeyAlternativeGroups': [ [], ], + 'eventKeyExtraPrefixes': [], 'execution': { 'serverRequestGates': [ { @@ -5417,6 +5472,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'featureId': 'retryOffsetRecovery', 'input': { 'content': 'hello world', @@ -5634,6 +5690,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'featureId': 'requestLifecycleHooks', 'input': { 'content': 'hello world', @@ -5704,6 +5761,7 @@ 'eventKeyAlternativeGroups': [ [], ], + 'eventKeyExtraPrefixes': [], 'execution': { 'onRequestStart': [ { @@ -5765,6 +5823,7 @@ 'eventKeyAlternativeGroups': [ [], ], + 'eventKeyExtraPrefixes': [], 'execution': { 'onRequestStart': [ { @@ -5893,6 +5952,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'execution': { 'onChunkComplete': [ { diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index de2ec5c..cee3e56 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -24,6 +24,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'fingerprint:contract-single-fingerprint', 'upload-url-available', @@ -50,6 +53,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'progress:0:11', 'progress:11:11', @@ -80,6 +86,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'progress:0:11', 'progress:5:11', @@ -110,6 +119,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'progress:0:11', 'progress:11:11', @@ -140,6 +152,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'upload-url-available', 'progress:0:11', @@ -171,6 +186,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'upload-url-available', 'progress:5:11', @@ -200,6 +218,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'fingerprint:contract-resume-fingerprint', 'url-storage-find:contract-resume-fingerprint:1', @@ -229,6 +250,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'upload-url-available', 'progress:0:11', @@ -251,6 +275,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'source-open:array-buffer:11', 'success', @@ -268,6 +293,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'source-open:array-buffer-view:11', 'success', @@ -285,6 +311,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'source-open:web-readable-stream:null', 'success', @@ -302,6 +329,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'source-open:node-readable-stream:null', 'success', @@ -319,6 +347,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'source-open:node-path-reference:11', 'success', @@ -339,6 +368,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'upload-url-available', 'progress:0:11', @@ -383,6 +415,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'upload-url-available', 'progress:0:null', @@ -413,6 +448,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'eventKeys': [ 'progress:5:11', 'chunk-complete:5:5:11', @@ -431,6 +469,7 @@ 'eventKeyAlternativeGroups': [ [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'request-abort:3', ], @@ -447,6 +486,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'should-retry:0:true', 'retry-schedule:0', @@ -466,6 +506,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'before-request:0', 'after-response:0', @@ -482,6 +523,7 @@ 'eventKeyAlternativeGroups': [ [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'request-abort:0', ], @@ -495,6 +537,7 @@ 'eventKeyAlternativeGroups': [ [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'request-abort:1', ], @@ -509,6 +552,7 @@ [], [], ], + 'eventKeyExtraPrefixes': [], 'eventKeys': [ 'should-retry:0:true', 'retry-schedule:0', @@ -639,6 +683,10 @@ def test_generated_scenario_event_keys(self): scenario["eventKeyAlternativeGroups"], case["eventKeyAlternativeGroups"], ) + self.assertEqual( + scenario["eventKeyExtraPrefixes"], + case["eventKeyExtraPrefixes"], + ) self.assertEqual( scenario.get("eventPolicy", {"matching": "exact"}), case["eventPolicy"], diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 0da679a..132c1b6 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -23,6 +23,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:0:11', @@ -85,6 +88,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:5:11', @@ -155,6 +161,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'endpointUrl': 'https://tus.io/files/', 'eventKeys': [ 'progress:0:11', @@ -217,6 +226,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:0:11', @@ -299,6 +311,9 @@ [], [], ], + 'eventKeyExtraPrefixes': [ + 'progress:', + ], 'endpointUrl': 'https://tus.io/uploads', 'eventKeys': [ 'progress:0:null', From 70b8a0320fadf6f786d5cc0c0f03e75ac2cad7f0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:41:15 +0200 Subject: [PATCH 52/95] Regenerate Python TUS event prefix policy --- tests/test_generated_runtime_events.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 132c1b6..4204dc8 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -452,8 +452,12 @@ def on_chunk_complete(chunk_size, bytes_accepted, bytes_total): return on_chunk_complete -def is_progress_event_key(event_key): - return event_key.startswith('progress:') +def has_allowed_extra_event_prefix(event_key, prefixes): + for prefix in prefixes: + if event_key.startswith(prefix): + return True + + return False def execution_actions(case, phase): @@ -502,6 +506,7 @@ def assert_before_start_actions(test, case, storage): def assert_events(test, case, events): expected_events = case['eventKeys'] + extra_prefixes = case['eventKeyExtraPrefixes'] event_policy = case.get('eventPolicy', {'matching': 'exact'}) matching = event_policy['matching'] @@ -520,9 +525,9 @@ def assert_events(test, case, events): continue test.assertTrue( - is_progress_event_key(event), - '{} emitted an unexpected non-progress event {}; expected {}'.format( - case['scenarioId'], event, expected_events + has_allowed_extra_event_prefix(event, extra_prefixes), + '{} emitted an unexpected extra event {}; allowed prefixes {}; expected {}'.format( + case['scenarioId'], event, extra_prefixes, expected_events ), ) From 082b81110f8417dcc12f7fcd72ceffd20044f8b2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:16:58 +0200 Subject: [PATCH 53/95] Regenerate Python TUS event key helpers --- tests/test_generated_runtime_events.py | 61 ++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 4204dc8..b3ff9a7 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -432,10 +432,63 @@ def format_event_value(value): return 'null' if value is None else str(value) +def generated_tus_event_key(kind, *parts): + return ':'.join((kind,) + parts) + + +def generated_tus_event_key_after_response(request_index): + return generated_tus_event_key('after-response', request_index) + +def generated_tus_event_key_before_request(request_index): + return generated_tus_event_key('before-request', request_index) + +def generated_tus_event_key_chunk_complete(chunk_size, bytes_accepted, bytes_total): + return generated_tus_event_key('chunk-complete', chunk_size, bytes_accepted, bytes_total) + +def generated_tus_event_key_fingerprint(fingerprint): + return generated_tus_event_key('fingerprint', fingerprint) + +def generated_tus_event_key_progress(bytes_sent, bytes_total): + return generated_tus_event_key('progress', bytes_sent, bytes_total) + +def generated_tus_event_key_request_abort(request_index): + return generated_tus_event_key('request-abort', request_index) + +def generated_tus_event_key_retry_schedule(delay): + return generated_tus_event_key('retry-schedule', delay) + +def generated_tus_event_key_should_retry(retry_attempt, decision): + return generated_tus_event_key('should-retry', retry_attempt, decision) + +def generated_tus_event_key_source_close(): + return generated_tus_event_key('source-close') + +def generated_tus_event_key_source_open(input_kind, size): + return generated_tus_event_key('source-open', input_kind, size) + +def generated_tus_event_key_success(): + return generated_tus_event_key('success') + +def generated_tus_event_key_upload_url_available(): + return generated_tus_event_key('upload-url-available') + +def generated_tus_event_key_url_storage_add(fingerprint, upload_url): + return generated_tus_event_key('url-storage-add', fingerprint, upload_url) + +def generated_tus_event_key_url_storage_find(fingerprint, count): + return generated_tus_event_key('url-storage-find', fingerprint, count) + +def generated_tus_event_key_url_storage_remove(url_storage_key): + return generated_tus_event_key('url-storage-remove', url_storage_key) + + def record_progress(events): def on_progress(bytes_sent, bytes_total): events.append( - 'progress:{}:{}'.format(bytes_sent, format_event_value(bytes_total)) + generated_tus_event_key_progress( + format_event_value(bytes_sent), + format_event_value(bytes_total), + ) ) return on_progress @@ -443,9 +496,9 @@ def on_progress(bytes_sent, bytes_total): def record_chunk_complete(events): def on_chunk_complete(chunk_size, bytes_accepted, bytes_total): events.append( - 'chunk-complete:{}:{}:{}'.format( - chunk_size, - bytes_accepted, + generated_tus_event_key_chunk_complete( + format_event_value(chunk_size), + format_event_value(bytes_accepted), format_event_value(bytes_total), ) ) From a8566701ba98a2719b53e13834e616a8eb06e52c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:30:04 +0200 Subject: [PATCH 54/95] Use generic TUS extra event matching policy --- tests/generated_protocol_contract.py | 22 +++++++++++----------- tests/test_generated_conformance_events.py | 22 +++++++++++----------- tests/test_generated_runtime_events.py | 12 ++++++------ 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index de25a7d..03e9f01 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2209,7 +2209,7 @@ 'progress:', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -2353,7 +2353,7 @@ 'progress:', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -2456,7 +2456,7 @@ 'progress:', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -2653,7 +2653,7 @@ 'progress:', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -2759,7 +2759,7 @@ 'progress:', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -2989,7 +2989,7 @@ 'progress:', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -3754,7 +3754,7 @@ 'progress:', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -3916,7 +3916,7 @@ 'progress:', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -4566,7 +4566,7 @@ ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -4719,7 +4719,7 @@ ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -5024,7 +5024,7 @@ 'progress:', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index cee3e56..0eb4649 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -38,7 +38,7 @@ 'source-close', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -64,7 +64,7 @@ 'source-close', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -104,7 +104,7 @@ 'source-close', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -130,7 +130,7 @@ 'source-close', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -170,7 +170,7 @@ 'source-close', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -198,7 +198,7 @@ 'source-close', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -234,7 +234,7 @@ 'source-close', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -262,7 +262,7 @@ 'source-close', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -381,7 +381,7 @@ ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -434,7 +434,7 @@ ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -458,7 +458,7 @@ 'chunk-complete:6:11:11', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index b3ff9a7..e1dba1d 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -33,7 +33,7 @@ 'chunk-complete:11:11:11', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -98,7 +98,7 @@ 'chunk-complete:6:11:11', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -171,7 +171,7 @@ 'chunk-complete:11:11:11', ], 'eventPolicy': { - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -237,7 +237,7 @@ ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -328,7 +328,7 @@ ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', - 'matching': 'exact-except-extra-progress', + 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, @@ -567,7 +567,7 @@ def assert_events(test, case, events): test.assertEqual(events, expected_events, case['scenarioId']) return - if matching == 'exact-except-extra-progress': + if matching == 'exact-except-allowed-extra-events': expected_index = 0 for event in events: if ( From 05f777f6db259f61c216abcb2e266c713126fc9e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 07:04:44 +0200 Subject: [PATCH 55/95] Regenerate Python TUS event key helpers --- tests/test_generated_runtime_events.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index e1dba1d..0a0920f 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -432,8 +432,11 @@ def format_event_value(value): return 'null' if value is None else str(value) -def generated_tus_event_key(kind, *parts): - return ':'.join((kind,) + parts) +GENERATED_TUS_EVENT_KEY_PART_SEPARATOR = ':' + + +def generated_tus_event_key(*parts): + return GENERATED_TUS_EVENT_KEY_PART_SEPARATOR.join(parts) def generated_tus_event_key_after_response(request_index): From 0cbe27e5bca598de5c0468ea644527327b09b508 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 15:33:22 +0200 Subject: [PATCH 56/95] Use generated TUS fixture event keys --- tests/generated_protocol_contract.py | 255 +++++++++++++++++++++ tests/test_generated_conformance_events.py | 4 +- 2 files changed, 257 insertions(+), 2 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 03e9f01..cca901f 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2208,6 +2208,16 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'fingerprint:contract-single-fingerprint', + 'upload-url-available', + 'url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2352,6 +2362,13 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'progress:0:11', + 'progress:11:11', + 'upload-url-available', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2455,6 +2472,20 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'progress:0:11', + 'progress:5:11', + 'upload-url-available', + 'chunk-complete:5:5:11', + 'progress:5:11', + 'progress:10:11', + 'chunk-complete:5:10:11', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2652,6 +2683,13 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'progress:0:11', + 'progress:11:11', + 'upload-url-available', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2758,6 +2796,20 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'upload-url-available', + 'progress:0:11', + 'progress:5:11', + 'chunk-complete:5:5:11', + 'progress:5:11', + 'progress:10:11', + 'chunk-complete:5:10:11', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2988,6 +3040,14 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'upload-url-available', + 'progress:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -3111,6 +3171,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': '', @@ -3134,6 +3198,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3156,6 +3224,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3180,6 +3252,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3206,6 +3282,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3231,6 +3311,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3256,6 +3340,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3281,6 +3369,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3306,6 +3398,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3335,6 +3431,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3365,6 +3465,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'detailedErrors', 'input': { 'content': 'hello world', @@ -3425,6 +3529,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'detailedErrors', 'input': { 'content': 'hello world', @@ -3478,6 +3586,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'uploadBodyHeaders', 'input': { 'content': 'hello world', @@ -3560,6 +3672,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'customRequestHeaders', 'input': { 'content': 'hello world', @@ -3650,6 +3766,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'requestIdHeaders', 'input': { 'addRequestId': True, @@ -3753,6 +3873,18 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'fingerprint:contract-resume-fingerprint', + 'url-storage-find:contract-resume-fingerprint:1', + 'fingerprint:contract-resume-fingerprint', + 'upload-url-available', + 'progress:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + 'url-storage-remove:tus::contract-resume-fingerprint::1337', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -3915,6 +4047,14 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'upload-url-available', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -4038,6 +4178,14 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'source-open:array-buffer:11', + 'success', + 'source-close', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4139,6 +4287,14 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'source-open:array-buffer-view:11', + 'success', + 'source-close', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4240,6 +4396,14 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'source-open:web-readable-stream:null', + 'success', + 'source-close', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'input': { 'chunkSize': 100, @@ -4346,6 +4510,14 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'source-open:node-readable-stream:null', + 'success', + 'source-close', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'input': { 'chunkSize': 100, @@ -4455,6 +4627,14 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'source-open:node-path-reference:11', + 'success', + 'source-close', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4564,6 +4744,14 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'upload-url-available', + 'progress:0:11', + 'progress:11:11', + 'chunk-complete:11:11:11', + 'success', + 'source-close', + ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-allowed-extra-events', @@ -4717,6 +4905,20 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'upload-url-available', + 'progress:0:null', + 'progress:5:null', + 'chunk-complete:5:5:null', + 'progress:5:null', + 'progress:10:null', + 'chunk-complete:5:10:null', + 'progress:10:11', + 'progress:11:11', + 'chunk-complete:1:11:11', + 'success', + 'source-close', + ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-allowed-extra-events', @@ -4936,6 +5138,10 @@ }, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], + 'eventKeys': [], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'overridePatchMethod', 'input': { 'content': 'hello world', @@ -5023,6 +5229,12 @@ 'eventKeyExtraPrefixes': [ 'progress:', ], + 'eventKeys': [ + 'progress:5:11', + 'chunk-complete:5:5:11', + 'progress:11:11', + 'chunk-complete:6:11:11', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -5246,6 +5458,12 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'request-abort:3', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'execution': { 'serverRequestGates': [ { @@ -5473,6 +5691,15 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'should-retry:0:true', + 'retry-schedule:0', + 'should-retry:0:true', + 'retry-schedule:0', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'retryOffsetRecovery', 'input': { 'content': 'hello world', @@ -5691,6 +5918,15 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'before-request:0', + 'after-response:0', + 'success', + 'source-close', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'featureId': 'requestLifecycleHooks', 'input': { 'content': 'hello world', @@ -5762,6 +5998,12 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'request-abort:0', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'execution': { 'onRequestStart': [ { @@ -5824,6 +6066,12 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'request-abort:1', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'execution': { 'onRequestStart': [ { @@ -5953,6 +6201,13 @@ [], ], 'eventKeyExtraPrefixes': [], + 'eventKeys': [ + 'should-retry:0:true', + 'retry-schedule:0', + ], + 'eventPolicy': { + 'matching': 'exact', + }, 'execution': { 'onChunkComplete': [ { diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index 0eb4649..367f666 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -676,7 +676,7 @@ def test_generated_scenario_event_keys(self): self.assertEqual(scenario["featureId"], case["featureId"]) self.assertIn(scenario["scenarioId"], feature["conformance"]["scenarioIds"]) self.assertEqual( - [event["key"] for event in scenario["events"]], + scenario["eventKeys"], case["eventKeys"], ) self.assertEqual( @@ -688,7 +688,7 @@ def test_generated_scenario_event_keys(self): case["eventKeyExtraPrefixes"], ) self.assertEqual( - scenario.get("eventPolicy", {"matching": "exact"}), + scenario["eventPolicy"], case["eventPolicy"], ) From bd69af70b4c6f71ce20c2d14fa0ca883957aabb3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 15:37:07 +0200 Subject: [PATCH 57/95] Require generated TUS runtime event policy --- tests/test_generated_runtime_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 0a0920f..2aa3a56 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -563,7 +563,7 @@ def assert_before_start_actions(test, case, storage): def assert_events(test, case, events): expected_events = case['eventKeys'] extra_prefixes = case['eventKeyExtraPrefixes'] - event_policy = case.get('eventPolicy', {'matching': 'exact'}) + event_policy = case['eventPolicy'] matching = event_policy['matching'] if matching == 'exact': From dc7dfe9cb636d780e2aa5f3a6bd90ac28c26a486 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 15:41:16 +0200 Subject: [PATCH 58/95] Require generated TUS runtime execution keys --- tests/test_generated_runtime_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 2aa3a56..cfea24e 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -517,7 +517,7 @@ def has_allowed_extra_event_prefix(event_key, prefixes): def execution_actions(case, phase): - execution = case.get('execution') or {} + execution = case['execution'] or {} return execution.get(phase, []) From d8565dd45746864f62cb400dacb32bb5e20c3e6d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 15:49:07 +0200 Subject: [PATCH 59/95] Regenerate TUS retry decision fixtures --- tests/generated_protocol_contract.py | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index cca901f..647ac2d 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2300,6 +2300,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'singleUploadLifecycle', 'events': [ { @@ -2421,6 +2422,7 @@ 'requestIndex': 0, }, ], + 'retryDecisions': [], 'scenarioId': 'creationWithUpload', 'events': [ { @@ -2594,6 +2596,7 @@ 'requestIndex': 2, }, ], + 'retryDecisions': [], 'scenarioId': 'creationWithUploadPartialChunk', 'events': [ { @@ -2745,6 +2748,7 @@ 'requestIndex': 0, }, ], + 'retryDecisions': [], 'scenarioId': 'ietfDraft05CreationWithUpload', 'events': [ { @@ -2950,6 +2954,7 @@ 'requestIndex': 3, }, ], + 'retryDecisions': [], 'scenarioId': 'ietfDraft05ChunkedUploadComplete', 'events': [ { @@ -3127,6 +3132,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'ietfDraft03ResumeWithoutKnownLength', 'events': [ { @@ -3186,6 +3192,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationMissingInput', 'events': [], }, @@ -3212,6 +3219,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationMissingEndpointOrUploadUrl', 'events': [], }, @@ -3240,6 +3248,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationUnsupportedProtocol', 'events': [], }, @@ -3270,6 +3279,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationRetryDelaysNotArray', 'events': [], }, @@ -3299,6 +3309,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationParallelUploadsWithUploadUrl', 'events': [], }, @@ -3328,6 +3339,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationParallelUploadsWithUploadSize', 'events': [], }, @@ -3357,6 +3369,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationParallelUploadsWithDeferredLength', 'events': [], }, @@ -3386,6 +3399,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationParallelUploadsWithUploadDataDuringCreation', 'events': [], }, @@ -3419,6 +3433,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationParallelBoundariesWithoutParallelUploads', 'events': [], }, @@ -3453,6 +3468,7 @@ 'validate-start-options', ], 'requests': [], + 'retryDecisions': [], 'scenarioId': 'startValidationParallelBoundariesLengthMismatch', 'events': [], }, @@ -3517,6 +3533,7 @@ 'requestIndex': 0, }, ], + 'retryDecisions': [], 'scenarioId': 'detailedCreateResponseError', 'events': [], }, @@ -3575,6 +3592,7 @@ 'requestIndex': 0, }, ], + 'retryDecisions': [], 'scenarioId': 'detailedCreateRequestError', 'events': [], }, @@ -3661,6 +3679,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'uploadBodyHeaders', 'events': [], }, @@ -3755,6 +3774,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'customRequestHeaders', 'events': [], }, @@ -3849,6 +3869,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'requestIdHeaders', 'events': [], }, @@ -3974,6 +3995,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'resumeFromPreviousUpload', 'events': [ { @@ -4131,6 +4153,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'relativeLocationResolution', 'events': [ { @@ -4257,6 +4280,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'arrayBufferInput', 'events': [ { @@ -4366,6 +4390,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'arrayBufferViewInput', 'events': [ { @@ -4480,6 +4505,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'webReadableStreamInput', 'events': [ { @@ -4594,6 +4620,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'nodeReadableStreamInput', 'events': [ { @@ -4706,6 +4733,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'nodePathInput', 'events': [ { @@ -4835,6 +4863,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'deferredLengthUpload', 'events': [ { @@ -5057,6 +5086,7 @@ 'requestIndex': 3, }, ], + 'retryDecisions': [], 'scenarioId': 'deferredLengthChunkedUpload', 'events': [ { @@ -5211,6 +5241,7 @@ 'requestIndex': 1, }, ], + 'retryDecisions': [], 'scenarioId': 'overridePatchMethod', 'events': [], }, @@ -5419,6 +5450,7 @@ 'requestIndex': 4, }, ], + 'retryDecisions': [], 'scenarioId': 'parallelUploadConcat', 'events': [ { @@ -5669,6 +5701,7 @@ 'requestIndex': 5, }, ], + 'retryDecisions': [], 'scenarioId': 'parallelUploadAbortCleanup', 'events': [ { @@ -5879,6 +5912,16 @@ 'requestIndex': 5, }, ], + 'retryDecisions': [ + { + 'decision': True, + 'retryAttempt': 0, + }, + { + 'decision': True, + 'retryAttempt': 0, + }, + ], 'scenarioId': 'retryPatchAfterOffsetRecovery', 'events': [ { @@ -5967,6 +6010,7 @@ 'requestIndex': 0, }, ], + 'retryDecisions': [], 'scenarioId': 'requestLifecycleHooks', 'events': [ { @@ -6047,6 +6091,7 @@ 'requestIndex': 0, }, ], + 'retryDecisions': [], 'scenarioId': 'abortUpload', 'events': [ { @@ -6181,6 +6226,7 @@ 'requestIndex': 2, }, ], + 'retryDecisions': [], 'scenarioId': 'abortUploadAfterStoredUrl', 'events': [ { @@ -6339,6 +6385,12 @@ 'requestIndex': 3, }, ], + 'retryDecisions': [ + { + 'decision': True, + 'retryAttempt': 0, + }, + ], 'scenarioId': 'terminateWithRetry', 'events': [ { From 25a0f732dfb18cf7d829bed5ef6737d1d0b31800 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 15:59:40 +0200 Subject: [PATCH 60/95] Regenerate TUS event kind fixtures --- tests/generated_protocol_contract.py | 805 +++++---------------------- 1 file changed, 141 insertions(+), 664 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 647ac2d..e9a1ab9 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2218,6 +2218,15 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'fingerprint', + 'upload-url-available', + 'url-storage-add', + 'progress', + 'chunk-complete', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2302,50 +2311,6 @@ ], 'retryDecisions': [], 'scenarioId': 'singleUploadLifecycle', - 'events': [ - { - 'fingerprint': 'contract-single-fingerprint', - 'kind': 'fingerprint', - 'key': 'fingerprint:contract-single-fingerprint', - }, - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'fingerprint': 'contract-single-fingerprint', - 'kind': 'url-storage-add', - 'uploadUrl': 'https://tus.io/uploads/generated-contract', - 'key': 'url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract', - }, - { - 'bytesSent': 0, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:0:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'bytesAccepted': 11, - 'bytesTotal': 11, - 'chunkSize': 11, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:11:11:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'creation-with-upload', @@ -2370,6 +2335,12 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'progress', + 'upload-url-available', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2424,32 +2395,6 @@ ], 'retryDecisions': [], 'scenarioId': 'creationWithUpload', - 'events': [ - { - 'bytesSent': 0, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:0:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'creation-with-upload-partial-chunk', @@ -2488,6 +2433,13 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'progress', + 'upload-url-available', + 'chunk-complete', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2598,77 +2550,6 @@ ], 'retryDecisions': [], 'scenarioId': 'creationWithUploadPartialChunk', - 'events': [ - { - 'bytesSent': 0, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:0:11', - }, - { - 'bytesSent': 5, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:5:11', - }, - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'bytesAccepted': 5, - 'bytesTotal': 11, - 'chunkSize': 5, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:5:5:11', - }, - { - 'bytesSent': 5, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:5:11', - }, - { - 'bytesSent': 10, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:10:11', - }, - { - 'bytesAccepted': 10, - 'bytesTotal': 11, - 'chunkSize': 5, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:5:10:11', - }, - { - 'bytesSent': 10, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:10:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'bytesAccepted': 11, - 'bytesTotal': 11, - 'chunkSize': 1, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:1:11:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'creation-with-upload', @@ -2693,6 +2574,12 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'progress', + 'upload-url-available', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2750,32 +2637,6 @@ ], 'retryDecisions': [], 'scenarioId': 'ietfDraft05CreationWithUpload', - 'events': [ - { - 'bytesSent': 0, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:0:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'upload-body-headers', @@ -2814,6 +2675,13 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'upload-url-available', + 'progress', + 'chunk-complete', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -2956,77 +2824,6 @@ ], 'retryDecisions': [], 'scenarioId': 'ietfDraft05ChunkedUploadComplete', - 'events': [ - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'bytesSent': 0, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:0:11', - }, - { - 'bytesSent': 5, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:5:11', - }, - { - 'bytesAccepted': 5, - 'bytesTotal': 11, - 'chunkSize': 5, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:5:5:11', - }, - { - 'bytesSent': 5, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:5:11', - }, - { - 'bytesSent': 10, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:10:11', - }, - { - 'bytesAccepted': 10, - 'bytesTotal': 11, - 'chunkSize': 5, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:5:10:11', - }, - { - 'bytesSent': 10, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:10:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'bytesAccepted': 11, - 'bytesTotal': 11, - 'chunkSize': 1, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:1:11:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'upload-body-headers', @@ -3053,6 +2850,13 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'upload-url-available', + 'progress', + 'chunk-complete', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -3134,39 +2938,6 @@ ], 'retryDecisions': [], 'scenarioId': 'ietfDraft03ResumeWithoutKnownLength', - 'events': [ - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'bytesSent': 5, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:5:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'bytesAccepted': 11, - 'bytesTotal': 11, - 'chunkSize': 6, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:6:11:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'start-option-validation', @@ -3178,6 +2949,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3194,7 +2966,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationMissingInput', - 'events': [], }, { 'behavior': 'start-option-validation', @@ -3206,6 +2977,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3221,7 +2993,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationMissingEndpointOrUploadUrl', - 'events': [], }, { 'behavior': 'start-option-validation', @@ -3233,6 +3004,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3250,7 +3022,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationUnsupportedProtocol', - 'events': [], }, { 'behavior': 'start-option-validation', @@ -3262,6 +3033,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3281,7 +3053,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationRetryDelaysNotArray', - 'events': [], }, { 'behavior': 'start-option-validation', @@ -3293,6 +3064,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3311,7 +3083,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationParallelUploadsWithUploadUrl', - 'events': [], }, { 'behavior': 'start-option-validation', @@ -3323,6 +3094,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3341,7 +3113,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationParallelUploadsWithUploadSize', - 'events': [], }, { 'behavior': 'start-option-validation', @@ -3353,6 +3124,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3371,7 +3143,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationParallelUploadsWithDeferredLength', - 'events': [], }, { 'behavior': 'start-option-validation', @@ -3383,6 +3154,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3401,7 +3173,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationParallelUploadsWithUploadDataDuringCreation', - 'events': [], }, { 'behavior': 'start-option-validation', @@ -3413,6 +3184,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3435,7 +3207,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationParallelBoundariesWithoutParallelUploads', - 'events': [], }, { 'behavior': 'start-option-validation', @@ -3447,6 +3218,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3470,7 +3242,6 @@ 'requests': [], 'retryDecisions': [], 'scenarioId': 'startValidationParallelBoundariesLengthMismatch', - 'events': [], }, { 'behavior': 'detailed-error', @@ -3482,6 +3253,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3535,7 +3307,6 @@ ], 'retryDecisions': [], 'scenarioId': 'detailedCreateResponseError', - 'events': [], }, { 'behavior': 'detailed-error', @@ -3547,6 +3318,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3594,7 +3366,6 @@ ], 'retryDecisions': [], 'scenarioId': 'detailedCreateRequestError', - 'events': [], }, { 'behavior': 'upload-body-headers', @@ -3605,6 +3376,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3681,7 +3453,6 @@ ], 'retryDecisions': [], 'scenarioId': 'uploadBodyHeaders', - 'events': [], }, { 'behavior': 'custom-request-headers', @@ -3692,6 +3463,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3776,7 +3548,6 @@ ], 'retryDecisions': [], 'scenarioId': 'customRequestHeaders', - 'events': [], }, { 'behavior': 'request-id-headers', @@ -3787,6 +3558,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -3871,7 +3643,6 @@ ], 'retryDecisions': [], 'scenarioId': 'requestIdHeaders', - 'events': [], }, { 'behavior': 'resume-from-previous-upload', @@ -3906,6 +3677,16 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'fingerprint', + 'url-storage-find', + 'upload-url-available', + 'progress', + 'chunk-complete', + 'url-storage-remove', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -3997,60 +3778,6 @@ ], 'retryDecisions': [], 'scenarioId': 'resumeFromPreviousUpload', - 'events': [ - { - 'fingerprint': 'contract-resume-fingerprint', - 'kind': 'fingerprint', - 'key': 'fingerprint:contract-resume-fingerprint', - }, - { - 'count': 1, - 'fingerprint': 'contract-resume-fingerprint', - 'kind': 'url-storage-find', - 'key': 'url-storage-find:contract-resume-fingerprint:1', - }, - { - 'fingerprint': 'contract-resume-fingerprint', - 'kind': 'fingerprint', - 'key': 'fingerprint:contract-resume-fingerprint', - }, - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'bytesSent': 5, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:5:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'bytesAccepted': 11, - 'bytesTotal': 11, - 'chunkSize': 6, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:6:11:11', - }, - { - 'kind': 'url-storage-remove', - 'urlStorageKey': 'tus::contract-resume-fingerprint::1337', - 'key': 'url-storage-remove:tus::contract-resume-fingerprint::1337', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'relative-location-resolution', @@ -4077,6 +3804,13 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'upload-url-available', + 'progress', + 'chunk-complete', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -4155,39 +3889,6 @@ ], 'retryDecisions': [], 'scenarioId': 'relativeLocationResolution', - 'events': [ - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'bytesSent': 0, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:0:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'bytesAccepted': 11, - 'bytesTotal': 11, - 'chunkSize': 11, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:11:11:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'array-buffer-input', @@ -4206,6 +3907,11 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'source-open', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -4282,22 +3988,6 @@ ], 'retryDecisions': [], 'scenarioId': 'arrayBufferInput', - 'events': [ - { - 'inputKind': 'array-buffer', - 'kind': 'source-open', - 'size': 11, - 'key': 'source-open:array-buffer:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'array-buffer-view-input', @@ -4316,6 +4006,11 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'source-open', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -4392,22 +4087,6 @@ ], 'retryDecisions': [], 'scenarioId': 'arrayBufferViewInput', - 'events': [ - { - 'inputKind': 'array-buffer-view', - 'kind': 'source-open', - 'size': 11, - 'key': 'source-open:array-buffer-view:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'web-readable-stream-input', @@ -4426,6 +4105,11 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'source-open', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -4507,22 +4191,6 @@ ], 'retryDecisions': [], 'scenarioId': 'webReadableStreamInput', - 'events': [ - { - 'inputKind': 'web-readable-stream', - 'kind': 'source-open', - 'size': None, - 'key': 'source-open:web-readable-stream:null', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'node-readable-stream-input', @@ -4541,6 +4209,11 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'source-open', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -4622,22 +4295,6 @@ ], 'retryDecisions': [], 'scenarioId': 'nodeReadableStreamInput', - 'events': [ - { - 'inputKind': 'node-readable-stream', - 'kind': 'source-open', - 'size': None, - 'key': 'source-open:node-readable-stream:null', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], 'runtimes': [ 'node', ], @@ -4659,6 +4316,11 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'source-open', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -4735,22 +4397,6 @@ ], 'retryDecisions': [], 'scenarioId': 'nodePathInput', - 'events': [ - { - 'inputKind': 'node-path-reference', - 'kind': 'source-open', - 'size': 11, - 'key': 'source-open:node-path-reference:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], 'runtimes': [ 'node', ], @@ -4780,6 +4426,13 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'upload-url-available', + 'progress', + 'chunk-complete', + 'success', + 'source-close', + ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-allowed-extra-events', @@ -4865,39 +4518,6 @@ ], 'retryDecisions': [], 'scenarioId': 'deferredLengthUpload', - 'events': [ - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'bytesSent': 0, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:0:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'bytesAccepted': 11, - 'bytesTotal': 11, - 'chunkSize': 11, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:11:11:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'deferred-length-upload', @@ -4948,6 +4568,13 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'upload-url-available', + 'progress', + 'chunk-complete', + 'success', + 'source-close', + ], 'eventPolicy': { 'deferredLengthBytesTotal': 'allow-known-total-before-declaration', 'matching': 'exact-except-allowed-extra-events', @@ -5088,77 +4715,6 @@ ], 'retryDecisions': [], 'scenarioId': 'deferredLengthChunkedUpload', - 'events': [ - { - 'kind': 'upload-url-available', - 'key': 'upload-url-available', - }, - { - 'bytesSent': 0, - 'bytesTotal': None, - 'kind': 'progress', - 'key': 'progress:0:null', - }, - { - 'bytesSent': 5, - 'bytesTotal': None, - 'kind': 'progress', - 'key': 'progress:5:null', - }, - { - 'bytesAccepted': 5, - 'bytesTotal': None, - 'chunkSize': 5, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:5:5:null', - }, - { - 'bytesSent': 5, - 'bytesTotal': None, - 'kind': 'progress', - 'key': 'progress:5:null', - }, - { - 'bytesSent': 10, - 'bytesTotal': None, - 'kind': 'progress', - 'key': 'progress:10:null', - }, - { - 'bytesAccepted': 10, - 'bytesTotal': None, - 'chunkSize': 5, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:5:10:null', - }, - { - 'bytesSent': 10, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:10:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'bytesAccepted': 11, - 'bytesTotal': 11, - 'chunkSize': 1, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:1:11:11', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'override-patch-method', @@ -5169,6 +4725,7 @@ 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], + 'eventKinds': [], 'eventPolicy': { 'matching': 'exact', }, @@ -5243,7 +4800,6 @@ ], 'retryDecisions': [], 'scenarioId': 'overridePatchMethod', - 'events': [], }, { 'behavior': 'parallel-upload-concat', @@ -5266,6 +4822,10 @@ 'progress:11:11', 'chunk-complete:6:11:11', ], + 'eventKinds': [ + 'progress', + 'chunk-complete', + ], 'eventPolicy': { 'matching': 'exact-except-allowed-extra-events', 'progress': 'milestone', @@ -5452,34 +5012,6 @@ ], 'retryDecisions': [], 'scenarioId': 'parallelUploadConcat', - 'events': [ - { - 'bytesSent': 5, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:5:11', - }, - { - 'bytesAccepted': 5, - 'bytesTotal': 11, - 'chunkSize': 5, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:5:5:11', - }, - { - 'bytesSent': 11, - 'bytesTotal': 11, - 'kind': 'progress', - 'key': 'progress:11:11', - }, - { - 'bytesAccepted': 11, - 'bytesTotal': 11, - 'chunkSize': 6, - 'kind': 'chunk-complete', - 'key': 'chunk-complete:6:11:11', - }, - ], }, { 'behavior': 'parallel-upload-abort-cleanup', @@ -5493,6 +5025,9 @@ 'eventKeys': [ 'request-abort:3', ], + 'eventKinds': [ + 'request-abort', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -5703,13 +5238,6 @@ ], 'retryDecisions': [], 'scenarioId': 'parallelUploadAbortCleanup', - 'events': [ - { - 'kind': 'request-abort', - 'requestIndex': 3, - 'key': 'request-abort:3', - }, - ], }, { 'behavior': 'retry-patch-after-offset-recovery', @@ -5730,6 +5258,10 @@ 'should-retry:0:true', 'retry-schedule:0', ], + 'eventKinds': [ + 'should-retry', + 'retry-schedule', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -5923,30 +5455,6 @@ }, ], 'scenarioId': 'retryPatchAfterOffsetRecovery', - 'events': [ - { - 'decision': True, - 'kind': 'should-retry', - 'retryAttempt': 0, - 'key': 'should-retry:0:true', - }, - { - 'delay': 0, - 'kind': 'retry-schedule', - 'key': 'retry-schedule:0', - }, - { - 'decision': True, - 'kind': 'should-retry', - 'retryAttempt': 0, - 'key': 'should-retry:0:true', - }, - { - 'delay': 0, - 'kind': 'retry-schedule', - 'key': 'retry-schedule:0', - }, - ], }, { 'behavior': 'request-lifecycle-hooks', @@ -5967,6 +5475,12 @@ 'success', 'source-close', ], + 'eventKinds': [ + 'before-request', + 'after-response', + 'success', + 'source-close', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -6012,26 +5526,6 @@ ], 'retryDecisions': [], 'scenarioId': 'requestLifecycleHooks', - 'events': [ - { - 'kind': 'before-request', - 'requestIndex': 0, - 'key': 'before-request:0', - }, - { - 'kind': 'after-response', - 'requestIndex': 0, - 'key': 'after-response:0', - }, - { - 'kind': 'success', - 'key': 'success', - }, - { - 'kind': 'source-close', - 'key': 'source-close', - }, - ], }, { 'behavior': 'abort-upload', @@ -6045,6 +5539,9 @@ 'eventKeys': [ 'request-abort:0', ], + 'eventKinds': [ + 'request-abort', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -6093,13 +5590,6 @@ ], 'retryDecisions': [], 'scenarioId': 'abortUpload', - 'events': [ - { - 'kind': 'request-abort', - 'requestIndex': 0, - 'key': 'request-abort:0', - }, - ], }, { 'behavior': 'abort-upload-after-stored-url', @@ -6114,6 +5604,9 @@ 'eventKeys': [ 'request-abort:1', ], + 'eventKinds': [ + 'request-abort', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -6228,13 +5721,6 @@ ], 'retryDecisions': [], 'scenarioId': 'abortUploadAfterStoredUrl', - 'events': [ - { - 'kind': 'request-abort', - 'requestIndex': 1, - 'key': 'request-abort:1', - }, - ], }, { 'behavior': 'terminate-with-retry', @@ -6251,6 +5737,10 @@ 'should-retry:0:true', 'retry-schedule:0', ], + 'eventKinds': [ + 'should-retry', + 'retry-schedule', + ], 'eventPolicy': { 'matching': 'exact', }, @@ -6392,18 +5882,5 @@ }, ], 'scenarioId': 'terminateWithRetry', - 'events': [ - { - 'decision': True, - 'kind': 'should-retry', - 'retryAttempt': 0, - 'key': 'should-retry:0:true', - }, - { - 'delay': 0, - 'kind': 'retry-schedule', - 'key': 'retry-schedule:0', - }, - ], }, ] From 2e0bd486c70650988959be546e8412c227627942 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:10:45 +0200 Subject: [PATCH 61/95] Regenerate TUS completion fact fixtures --- tests/generated_protocol_contract.py | 314 ++++++++++----------- tests/test_generated_conformance_events.py | 2 +- 2 files changed, 153 insertions(+), 163 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index e9a1ab9..48ef378 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2191,10 +2191,10 @@ TUS_CLIENT_CONFORMANCE_SCENARIOS = [ { 'behavior': 'single-upload-lifecycle', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/generated-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/generated-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -2314,10 +2314,10 @@ }, { 'behavior': 'creation-with-upload', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/creation-with-upload-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -2398,10 +2398,10 @@ }, { 'behavior': 'creation-with-upload-partial-chunk', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -2553,10 +2553,10 @@ }, { 'behavior': 'creation-with-upload', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/ietf-draft-05-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -2640,10 +2640,10 @@ }, { 'behavior': 'upload-body-headers', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -2827,10 +2827,10 @@ }, { 'behavior': 'upload-body-headers', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -2941,11 +2941,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: no file or stream to upload provided', - 'reason': 'missingInput', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: no file or stream to upload provided', + 'completionReason': 'missingInput', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -2969,11 +2968,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: neither an endpoint or an upload URL is provided', - 'reason': 'missingEndpointOrUploadUrl', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: neither an endpoint or an upload URL is provided', + 'completionReason': 'missingEndpointOrUploadUrl', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -2996,11 +2994,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: unsupported protocol tus-v9', - 'reason': 'unsupportedProtocol', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: unsupported protocol tus-v9', + 'completionReason': 'unsupportedProtocol', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3025,11 +3022,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: the `retryDelays` option must either be an array or null', - 'reason': 'retryDelaysNotArray', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: the `retryDelays` option must either be an array or null', + 'completionReason': 'retryDelaysNotArray', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3056,11 +3052,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', - 'reason': 'parallelUploadsWithUploadUrl', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + 'completionReason': 'parallelUploadsWithUploadUrl', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3086,11 +3081,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', - 'reason': 'parallelUploadsWithUploadSize', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', + 'completionReason': 'parallelUploadsWithUploadSize', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3116,11 +3110,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', - 'reason': 'parallelUploadsWithDeferredLength', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', + 'completionReason': 'parallelUploadsWithDeferredLength', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3146,11 +3139,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', - 'reason': 'parallelUploadsWithUploadDataDuringCreation', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', + 'completionReason': 'parallelUploadsWithUploadDataDuringCreation', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3176,11 +3168,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', - 'reason': 'parallelBoundariesWithoutParallelUploads', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', + 'completionReason': 'parallelBoundariesWithoutParallelUploads', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3210,11 +3201,10 @@ }, { 'behavior': 'start-option-validation', - 'completion': { - 'kind': 'error', - 'message': 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', - 'reason': 'parallelBoundariesLengthMismatch', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', + 'completionReason': 'parallelBoundariesLengthMismatch', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3245,11 +3235,10 @@ }, { 'behavior': 'detailed-error', - 'completion': { - 'kind': 'error', - 'message': 'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)', - 'reason': 'unexpectedCreateResponse', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)', + 'completionReason': 'unexpectedCreateResponse', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3310,11 +3299,10 @@ }, { 'behavior': 'detailed-error', - 'completion': { - 'kind': 'error', - 'message': 'tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)', - 'reason': 'createUploadRequestFailed', - }, + 'completionKind': 'error', + 'completionMessage': 'tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)', + 'completionReason': 'createUploadRequestFailed', + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3369,10 +3357,10 @@ }, { 'behavior': 'upload-body-headers', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/upload-body-headers-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/upload-body-headers-contract', 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3456,10 +3444,10 @@ }, { 'behavior': 'custom-request-headers', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/custom-headers-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/custom-headers-contract', 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3551,10 +3539,10 @@ }, { 'behavior': 'request-id-headers', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/request-id-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/request-id-contract', 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -3646,10 +3634,10 @@ }, { 'behavior': 'resume-from-previous-upload', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/resume-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/resume-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -3781,10 +3769,10 @@ }, { 'behavior': 'relative-location-resolution', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/files/relative-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/files/relative-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -3892,10 +3880,10 @@ }, { 'behavior': 'array-buffer-input', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/array-buffer-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/array-buffer-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -3991,10 +3979,10 @@ }, { 'behavior': 'array-buffer-view-input', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/array-buffer-view-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/array-buffer-view-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -4090,10 +4078,10 @@ }, { 'behavior': 'web-readable-stream-input', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/web-stream-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/web-stream-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -4194,10 +4182,10 @@ }, { 'behavior': 'node-readable-stream-input', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/node-stream-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/node-stream-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -4301,10 +4289,10 @@ }, { 'behavior': 'node-path-input', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/node-path-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/node-path-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -4403,10 +4391,10 @@ }, { 'behavior': 'deferred-length-upload', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/deferred-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/deferred-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -4521,10 +4509,10 @@ }, { 'behavior': 'deferred-length-upload', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/deferred-chunked-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/deferred-chunked-contract', 'eventKeyAlternativeGroups': [ [], [ @@ -4718,10 +4706,10 @@ }, { 'behavior': 'override-patch-method', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/override-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/override-contract', 'eventKeyAlternativeGroups': [], 'eventKeyExtraPrefixes': [], 'eventKeys': [], @@ -4803,10 +4791,10 @@ }, { 'behavior': 'parallel-upload-concat', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/parallel-final', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/parallel-final', 'eventKeyAlternativeGroups': [ [], [], @@ -5015,9 +5003,10 @@ }, { 'behavior': 'parallel-upload-abort-cleanup', - 'completion': { - 'kind': 'aborted', - }, + 'completionKind': 'aborted', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [ [], ], @@ -5241,10 +5230,10 @@ }, { 'behavior': 'retry-patch-after-offset-recovery', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/retry-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/retry-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -5458,10 +5447,10 @@ }, { 'behavior': 'request-lifecycle-hooks', - 'completion': { - 'kind': 'success', - 'uploadUrl': 'https://tus.io/uploads/request-hooks-contract', - }, + 'completionKind': 'success', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/request-hooks-contract', 'eventKeyAlternativeGroups': [ [], [], @@ -5529,9 +5518,10 @@ }, { 'behavior': 'abort-upload', - 'completion': { - 'kind': 'aborted', - }, + 'completionKind': 'aborted', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': None, 'eventKeyAlternativeGroups': [ [], ], @@ -5593,10 +5583,10 @@ }, { 'behavior': 'abort-upload-after-stored-url', - 'completion': { - 'kind': 'aborted', - 'uploadUrl': 'https://tus.io/uploads/abort-terminate-contract', - }, + 'completionKind': 'aborted', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/abort-terminate-contract', 'eventKeyAlternativeGroups': [ [], ], @@ -5724,10 +5714,10 @@ }, { 'behavior': 'terminate-with-retry', - 'completion': { - 'kind': 'terminated', - 'uploadUrl': 'https://tus.io/uploads/terminate-contract', - }, + 'completionKind': 'terminated', + 'completionMessage': None, + 'completionReason': None, + 'completionUploadUrl': 'https://tus.io/uploads/terminate-contract', 'eventKeyAlternativeGroups': [ [], [], diff --git a/tests/test_generated_conformance_events.py b/tests/test_generated_conformance_events.py index 367f666..55def33 100644 --- a/tests/test_generated_conformance_events.py +++ b/tests/test_generated_conformance_events.py @@ -698,7 +698,7 @@ def test_generated_proof_profile_scenarios(self): feature = client_feature(case["featureId"]) self.assertEqual(scenario["behavior"], case["behavior"]) - self.assertEqual(scenario["completion"]["kind"], case["completionKind"]) + self.assertEqual(scenario["completionKind"], case["completionKind"]) self.assertEqual(scenario["featureId"], case["featureId"]) self.assertIn(scenario["scenarioId"], feature["conformance"]["scenarioIds"]) self.assertEqual(scenario["operationIds"], case["operationIds"]) From 24571e9b26f3312b287f5ae74cab532565a7350f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:21:43 +0200 Subject: [PATCH 62/95] Regenerate TUS execution phase fixtures --- tests/generated_protocol_contract.py | 184 +++++++++++++++++---------- 1 file changed, 117 insertions(+), 67 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 48ef378..2d0908b 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2232,6 +2232,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'executionActionPhases': [], 'featureId': 'singleUploadLifecycle', 'input': { 'content': 'hello world', @@ -2346,6 +2347,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'executionActionPhases': [], 'featureId': 'creationWithUpload', 'input': { 'content': 'hello world', @@ -2445,6 +2447,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'executionActionPhases': [], 'featureId': 'creationWithUpload', 'input': { 'chunkSize': 5, @@ -2585,6 +2588,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'executionActionPhases': [], 'featureId': 'protocolVersionSelection', 'input': { 'content': 'hello world', @@ -2687,6 +2691,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'executionActionPhases': [], 'featureId': 'protocolVersionSelection', 'input': { 'chunkSize': 5, @@ -2862,6 +2867,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'executionActionPhases': [], 'featureId': 'protocolVersionSelection', 'input': { 'chunkSize': 6, @@ -2952,6 +2958,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': '', @@ -2979,6 +2986,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3005,6 +3013,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3033,6 +3042,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3063,6 +3073,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3092,6 +3103,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3121,6 +3133,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3150,6 +3163,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3179,6 +3193,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3212,6 +3227,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'startOptionValidation', 'input': { 'content': 'hello world', @@ -3246,6 +3262,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'detailedErrors', 'input': { 'content': 'hello world', @@ -3310,6 +3327,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'detailedErrors', 'input': { 'content': 'hello world', @@ -3368,6 +3386,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'uploadBodyHeaders', 'input': { 'content': 'hello world', @@ -3455,6 +3474,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'customRequestHeaders', 'input': { 'content': 'hello world', @@ -3550,6 +3570,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'requestIdHeaders', 'input': { 'addRequestId': True, @@ -3680,15 +3701,18 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, - 'execution': { - 'beforeStart': [ - { - 'expectedPreviousUploadCount': 1, - 'kind': 'resume-from-previous-upload', - 'selectedPreviousUploadIndex': 0, - }, - ], - }, + 'executionActionPhases': [ + { + 'actions': [ + { + 'expectedPreviousUploadCount': 1, + 'kind': 'resume-from-previous-upload', + 'selectedPreviousUploadIndex': 0, + }, + ], + 'phase': 'beforeStart', + }, + ], 'featureId': 'resumeUpload', 'input': { 'content': 'hello world', @@ -3804,6 +3828,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'executionActionPhases': [], 'featureId': 'relativeLocationResolution', 'input': { 'content': 'hello world', @@ -3903,6 +3928,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4002,6 +4028,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4101,6 +4128,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'inputSources', 'input': { 'chunkSize': 100, @@ -4205,6 +4233,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'inputSources', 'input': { 'chunkSize': 100, @@ -4312,6 +4341,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'inputSources', 'input': { 'content': 'hello world', @@ -4427,6 +4457,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'executionActionPhases': [], 'featureId': 'deferredLengthUpload', 'input': { 'chunkSize': 100, @@ -4569,6 +4600,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'executionActionPhases': [], 'featureId': 'deferredLengthUpload', 'input': { 'chunkSize': 5, @@ -4717,6 +4749,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'overridePatchMethod', 'input': { 'content': 'hello world', @@ -4819,23 +4852,26 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, - 'execution': { - 'serverRequestGates': [ - { - 'gateId': 'parallel-patches', - 'heldRequestIndexes': [ - 2, - 3, - ], - 'kind': 'release-after-all-started', - 'releaseAfterRequestIndexes': [ - 2, - 3, - ], - 'timeoutMs': 2000, - }, - ], - }, + 'executionActionPhases': [ + { + 'actions': [ + { + 'gateId': 'parallel-patches', + 'heldRequestIndexes': [ + 2, + 3, + ], + 'kind': 'release-after-all-started', + 'releaseAfterRequestIndexes': [ + 2, + 3, + ], + 'timeoutMs': 2000, + }, + ], + 'phase': 'serverRequestGates', + }, + ], 'featureId': 'parallelUploadConcat', 'input': { 'content': 'hello world', @@ -5020,23 +5056,26 @@ 'eventPolicy': { 'matching': 'exact', }, - 'execution': { - 'serverRequestGates': [ - { - 'gateId': 'parallel-cleanup-patches', - 'heldRequestIndexes': [ - 2, - 3, - ], - 'kind': 'release-after-all-started', - 'releaseAfterRequestIndexes': [ - 2, - 3, - ], - 'timeoutMs': 2000, - }, - ], - }, + 'executionActionPhases': [ + { + 'actions': [ + { + 'gateId': 'parallel-cleanup-patches', + 'heldRequestIndexes': [ + 2, + 3, + ], + 'kind': 'release-after-all-started', + 'releaseAfterRequestIndexes': [ + 2, + 3, + ], + 'timeoutMs': 2000, + }, + ], + 'phase': 'serverRequestGates', + }, + ], 'featureId': 'parallelUploadConcat', 'input': { 'content': 'hello world', @@ -5254,6 +5293,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'retryOffsetRecovery', 'input': { 'content': 'hello world', @@ -5473,6 +5513,7 @@ 'eventPolicy': { 'matching': 'exact', }, + 'executionActionPhases': [], 'featureId': 'requestLifecycleHooks', 'input': { 'content': 'hello world', @@ -5535,14 +5576,17 @@ 'eventPolicy': { 'matching': 'exact', }, - 'execution': { - 'onRequestStart': [ - { - 'kind': 'cancel-upload', - 'requestIndex': 0, - }, - ], - }, + 'executionActionPhases': [ + { + 'actions': [ + { + 'kind': 'cancel-upload', + 'requestIndex': 0, + }, + ], + 'phase': 'onRequestStart', + }, + ], 'featureId': 'abortUpload', 'input': { 'content': 'hello world', @@ -5600,14 +5644,17 @@ 'eventPolicy': { 'matching': 'exact', }, - 'execution': { - 'onRequestStart': [ - { - 'kind': 'cancel-upload', - 'requestIndex': 1, - }, - ], - }, + 'executionActionPhases': [ + { + 'actions': [ + { + 'kind': 'cancel-upload', + 'requestIndex': 1, + }, + ], + 'phase': 'onRequestStart', + }, + ], 'featureId': 'abortUpload', 'input': { 'content': 'hello world', @@ -5734,14 +5781,17 @@ 'eventPolicy': { 'matching': 'exact', }, - 'execution': { - 'onChunkComplete': [ - { - 'kind': 'abort-upload', - 'terminateUpload': True, - }, - ], - }, + 'executionActionPhases': [ + { + 'actions': [ + { + 'kind': 'abort-upload', + 'terminateUpload': True, + }, + ], + 'phase': 'onChunkComplete', + }, + ], 'featureId': 'terminateUpload', 'input': { 'chunkSize': 5, From 0b4f669e34f47e659651bff0db9d41e9e9b24664 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:32:47 +0200 Subject: [PATCH 63/95] Regenerate TUS source and URL fixtures --- tests/generated_protocol_contract.py | 221 +++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 2d0908b..fd14d18 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2243,6 +2243,10 @@ 'filename': 'hello.txt', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -2281,6 +2285,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -2308,6 +2313,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/generated-contract', }, ], 'retryDecisions': [], @@ -2358,6 +2364,10 @@ }, 'uploadDataDuringCreation': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', ], @@ -2393,6 +2403,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, ], 'retryDecisions': [], @@ -2459,6 +2470,10 @@ }, 'uploadDataDuringCreation': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -2495,6 +2510,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -2522,6 +2538,7 @@ 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', }, { 'absentHeaders': [], @@ -2549,6 +2566,7 @@ 'uploadUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', 'url': 'upload', 'requestIndex': 2, + 'expectedUrl': 'https://tus.io/uploads/creation-with-upload-partial-contract', }, ], 'retryDecisions': [], @@ -2600,6 +2618,10 @@ 'protocol': 'ietf-draft-05', 'uploadDataDuringCreation': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', ], @@ -2637,6 +2659,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, ], 'retryDecisions': [], @@ -2701,6 +2724,10 @@ 'protocol': 'ietf-draft-05', 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'getTusUploadOffset', 'patchTusUpload', @@ -2735,6 +2762,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, { 'absentHeaders': [ @@ -2765,6 +2793,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, { 'absentHeaders': [ @@ -2795,6 +2824,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 2, + 'expectedUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, { 'absentHeaders': [ @@ -2825,6 +2855,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 3, + 'expectedUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, ], 'retryDecisions': [], @@ -2877,6 +2908,10 @@ 'protocol': 'ietf-draft-03', 'uploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'getTusUploadOffset', 'patchTusUpload', @@ -2910,6 +2945,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, { 'absentHeaders': [ @@ -2940,6 +2976,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, ], 'retryDecisions': [], @@ -2965,6 +3002,10 @@ 'endpointUrl': 'https://tus.io/uploads', 'kind': 'none', }, + 'inputSource': { + 'content': '', + 'kind': 'none', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -2992,6 +3033,10 @@ 'content': 'hello world', 'kind': 'blob', }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -3021,6 +3066,10 @@ 'kind': 'blob', 'protocol': 'tus-v9', }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -3052,6 +3101,10 @@ 'retryDelays': 44, }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -3082,6 +3135,10 @@ 'parallelUploads': 2, 'uploadUrl': 'https://tus.io/uploads/start-validation-upload-url', }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -3112,6 +3169,10 @@ 'parallelUploads': 2, 'uploadSize': 11, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -3142,6 +3203,10 @@ 'parallelUploads': 2, 'uploadLengthDeferred': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -3172,6 +3237,10 @@ 'parallelUploads': 2, 'uploadDataDuringCreation': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -3206,6 +3275,10 @@ }, ], }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -3241,6 +3314,10 @@ ], 'parallelUploads': 2, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [], 'primitives': [ 'validate-start-options', @@ -3278,6 +3355,10 @@ 'retryDelays': None, }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', ], @@ -3309,6 +3390,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, ], 'retryDecisions': [], @@ -3343,6 +3425,10 @@ 'retryDelays': None, }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', ], @@ -3368,6 +3454,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, ], 'retryDecisions': [], @@ -3396,6 +3483,10 @@ 'filename': 'hello.txt', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -3429,6 +3520,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -3456,6 +3548,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/upload-body-headers-contract', }, ], 'retryDecisions': [], @@ -3488,6 +3581,10 @@ 'filename': 'hello.txt', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -3523,6 +3620,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -3552,6 +3650,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/custom-headers-contract', }, ], 'retryDecisions': [], @@ -3585,6 +3684,10 @@ 'filename': 'hello.txt', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -3620,6 +3723,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -3648,6 +3752,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/request-id-contract', }, ], 'retryDecisions': [], @@ -3725,6 +3830,10 @@ 'urlStorageKey': 'tus::contract-resume-fingerprint::1337', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'getTusUploadOffset', 'patchTusUpload', @@ -3759,6 +3868,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads/resume-contract', }, { 'absentHeaders': [], @@ -3786,6 +3896,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/resume-contract', }, ], 'retryDecisions': [], @@ -3838,6 +3949,10 @@ 'filename': 'hello.txt', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -3871,6 +3986,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/files/', }, { 'absentHeaders': [], @@ -3898,6 +4014,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/files/relative-contract', }, ], 'retryDecisions': [], @@ -3938,6 +4055,10 @@ 'filename': 'hello.txt', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'array-buffer', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -3971,6 +4092,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -3998,6 +4120,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/array-buffer-contract', }, ], 'retryDecisions': [], @@ -4038,6 +4161,10 @@ 'filename': 'hello.txt', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'array-buffer-view', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -4071,6 +4198,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -4098,6 +4226,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/array-buffer-view-contract', }, ], 'retryDecisions': [], @@ -4140,6 +4269,10 @@ }, 'uploadLengthDeferred': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'web-readable-stream', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -4175,6 +4308,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -4203,6 +4337,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/web-stream-contract', }, ], 'retryDecisions': [], @@ -4245,6 +4380,10 @@ }, 'uploadLengthDeferred': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'node-readable-stream', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -4280,6 +4419,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -4308,6 +4448,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/node-stream-contract', }, ], 'retryDecisions': [], @@ -4351,6 +4492,10 @@ 'filename': 'hello.txt', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'node-path-reference', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -4384,6 +4529,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -4411,6 +4557,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/node-path-contract', }, ], 'retryDecisions': [], @@ -4469,6 +4616,10 @@ }, 'uploadLengthDeferred': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'web-readable-stream', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -4505,6 +4656,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -4533,6 +4685,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/deferred-contract', }, ], 'retryDecisions': [], @@ -4612,6 +4765,10 @@ }, 'uploadLengthDeferred': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -4649,6 +4806,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -4676,6 +4834,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/deferred-chunked-contract', }, { 'absentHeaders': [], @@ -4703,6 +4862,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 2, + 'expectedUrl': 'https://tus.io/uploads/deferred-chunked-contract', }, { 'absentHeaders': [], @@ -4731,6 +4891,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 3, + 'expectedUrl': 'https://tus.io/uploads/deferred-chunked-contract', }, ], 'retryDecisions': [], @@ -4758,6 +4919,10 @@ 'overridePatchMethod': True, 'uploadUrl': 'https://tus.io/uploads/override-contract', }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'getTusUploadOffset', 'patchTusUpload', @@ -4790,6 +4955,7 @@ 'uploadUrl': 'https://tus.io/uploads/override-contract', 'url': 'upload', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads/override-contract', }, { 'absentHeaders': [], @@ -4817,6 +4983,7 @@ 'uploadUrl': 'https://tus.io/uploads/override-contract', 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/override-contract', }, ], 'retryDecisions': [], @@ -4885,6 +5052,10 @@ }, 'parallelUploads': 2, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'createTusUpload', @@ -4923,6 +5094,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -4950,6 +5122,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -4977,6 +5150,7 @@ 'uploadUrl': 'https://tus.io/uploads/parallel-part-1', 'url': 'upload', 'requestIndex': 2, + 'expectedUrl': 'https://tus.io/uploads/parallel-part-1', }, { 'absentHeaders': [], @@ -5004,6 +5178,7 @@ 'uploadUrl': 'https://tus.io/uploads/parallel-part-2', 'url': 'upload', 'requestIndex': 3, + 'expectedUrl': 'https://tus.io/uploads/parallel-part-2', }, { 'absentHeaders': [ @@ -5032,6 +5207,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 4, + 'expectedUrl': 'https://tus.io/uploads', }, ], 'retryDecisions': [], @@ -5093,6 +5269,10 @@ 'parallelUploads': 2, 'terminateUploadOnAbort': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'createTusUpload', @@ -5135,6 +5315,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -5164,6 +5345,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -5191,6 +5373,7 @@ 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-1', 'url': 'upload', 'requestIndex': 2, + 'expectedUrl': 'https://tus.io/uploads/parallel-cleanup-part-1', }, { 'absentHeaders': [], @@ -5212,6 +5395,7 @@ 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-2', 'url': 'upload', 'requestIndex': 3, + 'expectedUrl': 'https://tus.io/uploads/parallel-cleanup-part-2', }, { 'absentHeaders': [], @@ -5237,6 +5421,7 @@ 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-1', 'url': 'upload', 'requestIndex': 4, + 'expectedUrl': 'https://tus.io/uploads/parallel-cleanup-part-1', }, { 'absentHeaders': [], @@ -5262,6 +5447,7 @@ 'uploadUrl': 'https://tus.io/uploads/parallel-cleanup-part-2', 'url': 'upload', 'requestIndex': 5, + 'expectedUrl': 'https://tus.io/uploads/parallel-cleanup-part-2', }, ], 'retryDecisions': [], @@ -5306,6 +5492,10 @@ 0, ], }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -5344,6 +5534,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -5369,6 +5560,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/retry-contract', }, { 'absentHeaders': [], @@ -5394,6 +5586,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 2, + 'expectedUrl': 'https://tus.io/uploads/retry-contract', }, { 'absentHeaders': [], @@ -5419,6 +5612,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 3, + 'expectedUrl': 'https://tus.io/uploads/retry-contract', }, { 'absentHeaders': [], @@ -5444,6 +5638,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 4, + 'expectedUrl': 'https://tus.io/uploads/retry-contract', }, { 'absentHeaders': [], @@ -5471,6 +5666,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 5, + 'expectedUrl': 'https://tus.io/uploads/retry-contract', }, ], 'retryDecisions': [ @@ -5521,6 +5717,10 @@ 'kind': 'blob', 'uploadUrl': 'https://tus.io/uploads/request-hooks-contract', }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'getTusUploadOffset', ], @@ -5552,6 +5752,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads/request-hooks-contract', }, ], 'retryDecisions': [], @@ -5596,6 +5797,10 @@ 'filename': 'hello.txt', }, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', ], @@ -5620,6 +5825,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, ], 'retryDecisions': [], @@ -5671,6 +5877,10 @@ 'overridePatchMethod': True, 'terminateUploadOnAbort': True, }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -5708,6 +5918,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -5729,6 +5940,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/abort-terminate-contract', }, { 'absentHeaders': [], @@ -5754,6 +5966,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 2, + 'expectedUrl': 'https://tus.io/uploads/abort-terminate-contract', }, ], 'retryDecisions': [], @@ -5806,6 +6019,10 @@ 0, ], }, + 'inputSource': { + 'content': 'hello world', + 'kind': 'blob', + }, 'operationIds': [ 'createTusUpload', 'patchTusUpload', @@ -5842,6 +6059,7 @@ 'uploadUrl': None, 'url': 'endpoint', 'requestIndex': 0, + 'expectedUrl': 'https://tus.io/uploads', }, { 'absentHeaders': [], @@ -5869,6 +6087,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 1, + 'expectedUrl': 'https://tus.io/uploads/terminate-contract', }, { 'absentHeaders': [], @@ -5891,6 +6110,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 2, + 'expectedUrl': 'https://tus.io/uploads/terminate-contract', }, { 'absentHeaders': [], @@ -5913,6 +6133,7 @@ 'uploadUrl': None, 'url': 'upload', 'requestIndex': 3, + 'expectedUrl': 'https://tus.io/uploads/terminate-contract', }, ], 'retryDecisions': [ From 1015ee17d436ab3fb052ef174ac814b1f0c991c5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:45:09 +0200 Subject: [PATCH 64/95] Regenerate TUS input option fixtures --- tests/generated_protocol_contract.py | 611 +++++++++++++++++++++++++++ 1 file changed, 611 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index fd14d18..1e24e45 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2243,6 +2243,18 @@ 'filename': 'hello.txt', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -2364,6 +2376,22 @@ }, 'uploadDataDuringCreation': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'uploadDataDuringCreation', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -2470,6 +2498,26 @@ }, 'uploadDataDuringCreation': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'chunkSize', + 'value': 5, + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'uploadDataDuringCreation', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -2618,6 +2666,26 @@ 'protocol': 'ietf-draft-05', 'uploadDataDuringCreation': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'protocol', + 'value': 'ietf-draft-05', + }, + { + 'key': 'uploadDataDuringCreation', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -2724,6 +2792,24 @@ 'protocol': 'ietf-draft-05', 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'chunkSize', + 'value': 5, + }, + { + 'key': 'protocol', + 'value': 'ietf-draft-05', + }, + { + 'key': 'uploadUrl', + 'value': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -2908,6 +2994,24 @@ 'protocol': 'ietf-draft-03', 'uploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'chunkSize', + 'value': 6, + }, + { + 'key': 'protocol', + 'value': 'ietf-draft-03', + }, + { + 'key': 'uploadUrl', + 'value': 'https://tus.io/uploads/ietf-draft-03-resume-contract', + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3002,6 +3106,12 @@ 'endpointUrl': 'https://tus.io/uploads', 'kind': 'none', }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + ], 'inputSource': { 'content': '', 'kind': 'none', @@ -3033,6 +3143,7 @@ 'content': 'hello world', 'kind': 'blob', }, + 'inputOptionEntries': [], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3066,6 +3177,16 @@ 'kind': 'blob', 'protocol': 'tus-v9', }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'protocol', + 'value': 'tus-v9', + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3101,6 +3222,18 @@ 'retryDelays': 44, }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'rawOptions', + 'value': { + 'retryDelays': 44, + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3135,6 +3268,20 @@ 'parallelUploads': 2, 'uploadUrl': 'https://tus.io/uploads/start-validation-upload-url', }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'parallelUploads', + 'value': 2, + }, + { + 'key': 'uploadUrl', + 'value': 'https://tus.io/uploads/start-validation-upload-url', + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3169,6 +3316,20 @@ 'parallelUploads': 2, 'uploadSize': 11, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'parallelUploads', + 'value': 2, + }, + { + 'key': 'uploadSize', + 'value': 11, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3203,6 +3364,20 @@ 'parallelUploads': 2, 'uploadLengthDeferred': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'parallelUploads', + 'value': 2, + }, + { + 'key': 'uploadLengthDeferred', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3237,6 +3412,20 @@ 'parallelUploads': 2, 'uploadDataDuringCreation': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'parallelUploads', + 'value': 2, + }, + { + 'key': 'uploadDataDuringCreation', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3275,6 +3464,21 @@ }, ], }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'parallelUploadBoundaries', + 'value': [ + { + 'end': 5, + 'start': 0, + }, + ], + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3314,6 +3518,25 @@ ], 'parallelUploads': 2, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'parallelUploads', + 'value': 2, + }, + { + 'key': 'parallelUploadBoundaries', + 'value': [ + { + 'end': 5, + 'start': 0, + }, + ], + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3355,6 +3578,30 @@ 'retryDelays': None, }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'headers', + 'value': { + 'X-Request-ID': 'contract-request-id', + }, + }, + { + 'key': 'rawOptions', + 'value': { + 'retryDelays': None, + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3425,6 +3672,30 @@ 'retryDelays': None, }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'headers', + 'value': { + 'X-Request-ID': 'contract-request-id', + }, + }, + { + 'key': 'rawOptions', + 'value': { + 'retryDelays': None, + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3483,6 +3754,18 @@ 'filename': 'hello.txt', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3581,6 +3864,25 @@ 'filename': 'hello.txt', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'headers', + 'value': { + 'X-Tus-Contract': 'custom-header', + 'X-Tus-Trace': 'trace-123', + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3684,6 +3986,28 @@ 'filename': 'hello.txt', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'headers', + 'value': { + 'X-Request-ID': 'custom-request-id', + }, + }, + { + 'key': 'addRequestId', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3830,6 +4154,16 @@ 'urlStorageKey': 'tus::contract-resume-fingerprint::1337', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'removeFingerprintOnSuccess', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -3949,6 +4283,18 @@ 'filename': 'hello.txt', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/files/', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -4055,6 +4401,18 @@ 'filename': 'hello.txt', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'array-buffer', @@ -4161,6 +4519,18 @@ 'filename': 'hello.txt', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'array-buffer-view', @@ -4269,6 +4639,26 @@ }, 'uploadLengthDeferred': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'chunkSize', + 'value': 100, + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'uploadLengthDeferred', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'web-readable-stream', @@ -4380,6 +4770,26 @@ }, 'uploadLengthDeferred': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'chunkSize', + 'value': 100, + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'uploadLengthDeferred', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'node-readable-stream', @@ -4492,6 +4902,18 @@ 'filename': 'hello.txt', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'node-path-reference', @@ -4616,6 +5038,26 @@ }, 'uploadLengthDeferred': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'chunkSize', + 'value': 100, + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'uploadLengthDeferred', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'web-readable-stream', @@ -4765,6 +5207,26 @@ }, 'uploadLengthDeferred': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'chunkSize', + 'value': 5, + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'uploadLengthDeferred', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -4919,6 +5381,20 @@ 'overridePatchMethod': True, 'uploadUrl': 'https://tus.io/uploads/override-contract', }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'overridePatchMethod', + 'value': True, + }, + { + 'key': 'uploadUrl', + 'value': 'https://tus.io/uploads/override-contract', + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -5052,6 +5528,28 @@ }, 'parallelUploads': 2, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'foo': 'hello', + }, + }, + { + 'key': 'metadataForPartialUploads', + 'value': { + 'test': 'world', + }, + }, + { + 'key': 'parallelUploads', + 'value': 2, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -5269,6 +5767,33 @@ 'parallelUploads': 2, 'terminateUploadOnAbort': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadataForPartialUploads', + 'value': { + 'test': 'world', + }, + }, + { + 'key': 'headers', + 'value': { + 'X-Tus-Contract': 'parallel-cleanup-policy', + 'X-Tus-Trace': 'parallel-cleanup-trace-123', + }, + }, + { + 'key': 'overridePatchMethod', + 'value': True, + }, + { + 'key': 'parallelUploads', + 'value': 2, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -5492,6 +6017,24 @@ 0, ], }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'retryDelays', + 'value': [ + 0, + ], + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -5717,6 +6260,16 @@ 'kind': 'blob', 'uploadUrl': 'https://tus.io/uploads/request-hooks-contract', }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'uploadUrl', + 'value': 'https://tus.io/uploads/request-hooks-contract', + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -5797,6 +6350,18 @@ 'filename': 'hello.txt', }, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -5877,6 +6442,29 @@ 'overridePatchMethod': True, 'terminateUploadOnAbort': True, }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'headers', + 'value': { + 'X-Tus-Contract': 'abort-policy', + 'X-Tus-Trace': 'abort-trace-123', + }, + }, + { + 'key': 'overridePatchMethod', + 'value': True, + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', @@ -6019,6 +6607,29 @@ 0, ], }, + 'inputOptionEntries': [ + { + 'key': 'endpointUrl', + 'value': 'https://tus.io/uploads', + }, + { + 'key': 'chunkSize', + 'value': 5, + }, + { + 'key': 'metadata', + 'value': { + 'filename': 'hello.txt', + }, + }, + { + 'key': 'retryDelays', + 'value': [ + 0, + 0, + ], + }, + ], 'inputSource': { 'content': 'hello world', 'kind': 'blob', From be9c4d28aefeafc4a88cf7fc0f5cae067839e506 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 16:57:49 +0200 Subject: [PATCH 65/95] Regenerate TUS runtime setup fixtures --- tests/generated_protocol_contract.py | 650 +++++++++++++++++++++++++++ 1 file changed, 650 insertions(+) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index 1e24e45..da7bacd 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2329,6 +2329,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': True, + 'value': 'contract-single-fingerprint', + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': True, + 'storedUpload': None, + }, + }, 'scenarioId': 'singleUploadLifecycle', }, { @@ -2435,6 +2452,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'creationWithUpload', }, { @@ -2618,6 +2652,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'creationWithUploadPartialChunk', }, { @@ -2731,6 +2782,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'ietfDraft05CreationWithUpload', }, { @@ -2945,6 +3013,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'ietfDraft05ChunkedUploadComplete', }, { @@ -3084,6 +3169,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'ietfDraft03ResumeWithoutKnownLength', }, { @@ -3122,6 +3224,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationMissingInput', }, { @@ -3154,6 +3273,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationMissingEndpointOrUploadUrl', }, { @@ -3197,6 +3333,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationUnsupportedProtocol', }, { @@ -3244,6 +3397,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationRetryDelaysNotArray', }, { @@ -3292,6 +3462,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationParallelUploadsWithUploadUrl', }, { @@ -3340,6 +3527,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationParallelUploadsWithUploadSize', }, { @@ -3388,6 +3592,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationParallelUploadsWithDeferredLength', }, { @@ -3436,6 +3657,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationParallelUploadsWithUploadDataDuringCreation', }, { @@ -3489,6 +3727,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationParallelBoundariesWithoutParallelUploads', }, { @@ -3547,6 +3802,23 @@ ], 'requests': [], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'startValidationParallelBoundariesLengthMismatch', }, { @@ -3641,6 +3913,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'detailedCreateResponseError', }, { @@ -3729,6 +4018,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'detailedCreateRequestError', }, { @@ -3835,6 +4141,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'uploadBodyHeaders', }, { @@ -3956,6 +4279,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'customRequestHeaders', }, { @@ -4080,6 +4420,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': True, + 'generatedRequestId': '00000000-0000-4000-8000-000000000000', + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'requestIdHeaders', }, { @@ -4234,6 +4591,27 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': True, + 'value': 'contract-resume-fingerprint', + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': True, + 'storedUpload': { + 'fingerprint': 'contract-resume-fingerprint', + 'uploadUrl': 'https://tus.io/uploads/resume-contract', + 'urlStorageKey': 'tus::contract-resume-fingerprint::1337', + }, + }, + }, 'scenarioId': 'resumeFromPreviousUpload', }, { @@ -4364,6 +4742,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'relativeLocationResolution', }, { @@ -4482,6 +4877,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'arrayBufferInput', }, { @@ -4600,6 +5012,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'arrayBufferViewInput', }, { @@ -4731,6 +5160,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': True, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'webReadableStreamInput', }, { @@ -4862,6 +5308,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': True, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'nodeReadableStreamInput', 'runtimes': [ 'node', @@ -4983,6 +5446,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'nodePathInput', 'runtimes': [ 'node', @@ -5131,6 +5611,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': True, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'deferredLengthUpload', }, { @@ -5357,6 +5854,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'deferredLengthChunkedUpload', }, { @@ -5463,6 +5977,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'overridePatchMethod', }, { @@ -5709,6 +6240,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'parallelUploadConcat', }, { @@ -5976,6 +6524,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': True, + }, + 'fingerprint': { + 'install': True, + 'value': 'contract-parallel-cleanup-fingerprint', + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'parallelUploadAbortCleanup', }, { @@ -6222,6 +6787,23 @@ 'retryAttempt': 0, }, ], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'retryPatchAfterOffsetRecovery', }, { @@ -6309,6 +6891,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'requestLifecycleHooks', }, { @@ -6394,6 +6993,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'abortUpload', }, { @@ -6558,6 +7174,23 @@ }, ], 'retryDecisions': [], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': True, + }, + 'fingerprint': { + 'install': True, + 'value': 'contract-abort-terminate-fingerprint', + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'abortUploadAfterStoredUrl', }, { @@ -6753,6 +7386,23 @@ 'retryAttempt': 0, }, ], + 'runtimeSetup': { + 'abort': { + 'terminateUpload': False, + }, + 'fingerprint': { + 'install': False, + 'value': None, + }, + 'requestId': { + 'enabled': False, + 'generatedRequestId': None, + }, + 'urlStorage': { + 'install': False, + 'storedUpload': None, + }, + }, 'scenarioId': 'terminateWithRetry', }, ] From 17d94b5a55c844a4edcff8062e527b0c3df82b87 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 17:01:50 +0200 Subject: [PATCH 66/95] Drop raw input from TUS generated fixtures --- tests/generated_protocol_contract.py | 360 --------------------------- 1 file changed, 360 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index da7bacd..e4b7ca9 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -2234,15 +2234,6 @@ }, 'executionActionPhases': [], 'featureId': 'singleUploadLifecycle', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'fingerprint': 'contract-single-fingerprint', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -2384,15 +2375,6 @@ }, 'executionActionPhases': [], 'featureId': 'creationWithUpload', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - 'uploadDataDuringCreation': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -2522,16 +2504,6 @@ }, 'executionActionPhases': [], 'featureId': 'creationWithUpload', - 'input': { - 'chunkSize': 5, - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - 'uploadDataDuringCreation': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -2707,16 +2679,6 @@ }, 'executionActionPhases': [], 'featureId': 'protocolVersionSelection', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - 'protocol': 'ietf-draft-05', - 'uploadDataDuringCreation': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -2852,14 +2814,6 @@ }, 'executionActionPhases': [], 'featureId': 'protocolVersionSelection', - 'input': { - 'chunkSize': 5, - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'protocol': 'ietf-draft-05', - 'uploadUrl': 'https://tus.io/uploads/ietf-draft-05-chunked-contract', - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3071,14 +3025,6 @@ }, 'executionActionPhases': [], 'featureId': 'protocolVersionSelection', - 'input': { - 'chunkSize': 6, - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'protocol': 'ietf-draft-03', - 'uploadUrl': 'https://tus.io/uploads/ietf-draft-03-resume-contract', - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3203,11 +3149,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': '', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'none', - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3258,10 +3199,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': 'hello world', - 'kind': 'blob', - }, 'inputOptionEntries': [], 'inputSource': { 'content': 'hello world', @@ -3307,12 +3244,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'protocol': 'tus-v9', - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3367,14 +3298,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'rawOptions': { - 'retryDelays': 44, - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3431,13 +3354,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'parallelUploads': 2, - 'uploadUrl': 'https://tus.io/uploads/start-validation-upload-url', - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3496,13 +3412,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'parallelUploads': 2, - 'uploadSize': 11, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3561,13 +3470,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'parallelUploads': 2, - 'uploadLengthDeferred': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3626,13 +3528,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'parallelUploads': 2, - 'uploadDataDuringCreation': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3691,17 +3586,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'parallelUploadBoundaries': [ - { - 'end': 5, - 'start': 0, - }, - ], - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3761,18 +3645,6 @@ }, 'executionActionPhases': [], 'featureId': 'startOptionValidation', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'parallelUploadBoundaries': [ - { - 'end': 5, - 'start': 0, - }, - ], - 'parallelUploads': 2, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3836,20 +3708,6 @@ }, 'executionActionPhases': [], 'featureId': 'detailedErrors', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'headers': { - 'X-Request-ID': 'contract-request-id', - }, - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - 'rawOptions': { - 'retryDelays': None, - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -3947,20 +3805,6 @@ }, 'executionActionPhases': [], 'featureId': 'detailedErrors', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'headers': { - 'X-Request-ID': 'contract-request-id', - }, - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - 'rawOptions': { - 'retryDelays': None, - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -4052,14 +3896,6 @@ }, 'executionActionPhases': [], 'featureId': 'uploadBodyHeaders', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -4175,18 +4011,6 @@ }, 'executionActionPhases': [], 'featureId': 'customRequestHeaders', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'headers': { - 'X-Tus-Contract': 'custom-header', - 'X-Tus-Trace': 'trace-123', - }, - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -4313,19 +4137,6 @@ }, 'executionActionPhases': [], 'featureId': 'requestIdHeaders', - 'input': { - 'addRequestId': True, - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'generatedRequestId': '00000000-0000-4000-8000-000000000000', - 'headers': { - 'X-Request-ID': 'custom-request-id', - }, - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -4500,17 +4311,6 @@ }, ], 'featureId': 'resumeUpload', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'removeFingerprintOnSuccess': True, - 'storedUpload': { - 'fingerprint': 'contract-resume-fingerprint', - 'uploadUrl': 'https://tus.io/uploads/resume-contract', - 'urlStorageKey': 'tus::contract-resume-fingerprint::1337', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -4653,14 +4453,6 @@ }, 'executionActionPhases': [], 'featureId': 'relativeLocationResolution', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/files/', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -4788,14 +4580,6 @@ }, 'executionActionPhases': [], 'featureId': 'inputSources', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'array-buffer', - 'metadata': { - 'filename': 'hello.txt', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -4923,14 +4707,6 @@ }, 'executionActionPhases': [], 'featureId': 'inputSources', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'array-buffer-view', - 'metadata': { - 'filename': 'hello.txt', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -5058,16 +4834,6 @@ }, 'executionActionPhases': [], 'featureId': 'inputSources', - 'input': { - 'chunkSize': 100, - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'web-readable-stream', - 'metadata': { - 'filename': 'hello.txt', - }, - 'uploadLengthDeferred': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -5206,16 +4972,6 @@ }, 'executionActionPhases': [], 'featureId': 'inputSources', - 'input': { - 'chunkSize': 100, - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'node-readable-stream', - 'metadata': { - 'filename': 'hello.txt', - }, - 'uploadLengthDeferred': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -5357,14 +5113,6 @@ }, 'executionActionPhases': [], 'featureId': 'inputSources', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'node-path-reference', - 'metadata': { - 'filename': 'hello.txt', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -5508,16 +5256,6 @@ }, 'executionActionPhases': [], 'featureId': 'deferredLengthUpload', - 'input': { - 'chunkSize': 100, - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'web-readable-stream', - 'metadata': { - 'filename': 'hello.txt', - }, - 'uploadLengthDeferred': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -5694,16 +5432,6 @@ }, 'executionActionPhases': [], 'featureId': 'deferredLengthUpload', - 'input': { - 'chunkSize': 5, - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - 'uploadLengthDeferred': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -5888,13 +5616,6 @@ }, 'executionActionPhases': [], 'featureId': 'overridePatchMethod', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'overridePatchMethod': True, - 'uploadUrl': 'https://tus.io/uploads/override-contract', - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -6047,18 +5768,6 @@ }, ], 'featureId': 'parallelUploadConcat', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'metadata': { - 'foo': 'hello', - }, - 'metadataForPartialUploads': { - 'test': 'world', - }, - 'parallelUploads': 2, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -6299,22 +6008,6 @@ }, ], 'featureId': 'parallelUploadConcat', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'fingerprint': 'contract-parallel-cleanup-fingerprint', - 'headers': { - 'X-Tus-Contract': 'parallel-cleanup-policy', - 'X-Tus-Trace': 'parallel-cleanup-trace-123', - }, - 'kind': 'blob', - 'metadataForPartialUploads': { - 'test': 'world', - }, - 'overridePatchMethod': True, - 'parallelUploads': 2, - 'terminateUploadOnAbort': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -6571,17 +6264,6 @@ }, 'executionActionPhases': [], 'featureId': 'retryOffsetRecovery', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - 'retryDelays': [ - 0, - ], - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -6836,12 +6518,6 @@ }, 'executionActionPhases': [], 'featureId': 'requestLifecycleHooks', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'uploadUrl': 'https://tus.io/uploads/request-hooks-contract', - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -6941,14 +6617,6 @@ }, ], 'featureId': 'abortUpload', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -7043,21 +6711,6 @@ }, ], 'featureId': 'abortUpload', - 'input': { - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'fingerprint': 'contract-abort-terminate-fingerprint', - 'headers': { - 'X-Tus-Contract': 'abort-policy', - 'X-Tus-Trace': 'abort-trace-123', - }, - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - 'overridePatchMethod': True, - 'terminateUploadOnAbort': True, - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', @@ -7227,19 +6880,6 @@ }, ], 'featureId': 'terminateUpload', - 'input': { - 'chunkSize': 5, - 'content': 'hello world', - 'endpointUrl': 'https://tus.io/uploads', - 'kind': 'blob', - 'metadata': { - 'filename': 'hello.txt', - }, - 'retryDelays': [ - 0, - 0, - ], - }, 'inputOptionEntries': [ { 'key': 'endpointUrl', From c4d9d21438cf1bc7da6a6a9575f1ae2af514f239 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 17:14:16 +0200 Subject: [PATCH 67/95] Use generated before-start runtime facts --- tests/test_generated_runtime_events.py | 27 ++++++++------------------ 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index cfea24e..4e11c30 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -37,7 +37,6 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, - 'execution': None, 'locationHeaderKind': 'absolute', 'metadata': { 'filename': 'hello.txt', @@ -80,6 +79,13 @@ 'uploadUrl': 'https://tus.io/uploads/generated-contract', }, { + 'beforeStartActions': [ + { + 'expectedPreviousUploadCount': 1, + 'kind': 'resume-from-previous-upload', + 'selectedPreviousUploadIndex': 0, + }, + ], 'chunkSize': 6, 'content': 'hello world', 'endpointHasTrailingSlash': False, @@ -102,15 +108,6 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, - 'execution': { - 'beforeStart': [ - { - 'expectedPreviousUploadCount': 1, - 'kind': 'resume-from-previous-upload', - 'selectedPreviousUploadIndex': 0, - }, - ], - }, 'locationHeaderKind': 'stored', 'metadata': {}, 'removeFingerprintOnSuccess': True, @@ -175,7 +172,6 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, - 'execution': None, 'locationHeaderKind': 'relative', 'metadata': { 'filename': 'hello.txt', @@ -241,7 +237,6 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, - 'execution': None, 'locationHeaderKind': 'absolute', 'metadata': { 'filename': 'hello.txt', @@ -332,7 +327,6 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, - 'execution': None, 'locationHeaderKind': 'absolute', 'metadata': { 'filename': 'hello.txt', @@ -516,14 +510,9 @@ def has_allowed_extra_event_prefix(event_key, prefixes): return False -def execution_actions(case, phase): - execution = case['execution'] or {} - return execution.get(phase, []) - - def resume_before_start_action(case): action = None - for candidate in execution_actions(case, 'beforeStart'): + for candidate in case.get('beforeStartActions', []): if candidate['kind'] != 'resume-from-previous-upload': raise AssertionError( '{} uses unsupported generated beforeStart action {}'.format( From 60ea90b5c605c77bd9481a6522be8787f8d8e5c1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 11:13:21 +0200 Subject: [PATCH 68/95] Regenerate TUS protocol fixtures --- tests/generated_protocol_contract.py | 10 ++++++---- tests/test_generated_runtime_events.py | 5 +++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index e4b7ca9..ec6de7c 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -1204,6 +1204,7 @@ 'reference-original-source', ], 'stateBackend': 'platform-key-value-store', + 'transportProfileId': 'java-http-url-connection', }, { 'networkConstraints': [ @@ -1241,6 +1242,7 @@ 'reference-original-source', ], 'stateBackend': 'filesystem', + 'transportProfileId': 'java-http-url-connection', }, { 'networkConstraints': [ @@ -4108,8 +4110,8 @@ 'terminateUpload': False, }, 'fingerprint': { - 'install': False, - 'value': None, + 'install': True, + 'value': 'contract-custom-headers-fingerprint', }, 'requestId': { 'enabled': False, @@ -5703,8 +5705,8 @@ 'terminateUpload': False, }, 'fingerprint': { - 'install': False, - 'value': None, + 'install': True, + 'value': 'contract-override-fingerprint', }, 'requestId': { 'enabled': False, diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index 4e11c30..a27e71c 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -37,6 +37,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'locationHeaderName': 'Location', 'locationHeaderKind': 'absolute', 'metadata': { 'filename': 'hello.txt', @@ -108,6 +109,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'locationHeaderName': 'Location', 'locationHeaderKind': 'stored', 'metadata': {}, 'removeFingerprintOnSuccess': True, @@ -172,6 +174,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'locationHeaderName': 'Location', 'locationHeaderKind': 'relative', 'metadata': { 'filename': 'hello.txt', @@ -237,6 +240,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'locationHeaderName': 'Location', 'locationHeaderKind': 'absolute', 'metadata': { 'filename': 'hello.txt', @@ -327,6 +331,7 @@ 'progress': 'milestone', 'transportProgress': 'may-emit-extra-samples', }, + 'locationHeaderName': 'Location', 'locationHeaderKind': 'absolute', 'metadata': { 'filename': 'hello.txt', From 42b1cd3b53bfd862662f9a81e91b8c840838bc07 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 00:39:01 +0200 Subject: [PATCH 69/95] Regenerate TUS protocol response fixtures --- tests/generated_protocol_contract.py | 140 +++++++++++++++++++++++++ tests/test_generated_runtime_events.py | 20 +++- 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/tests/generated_protocol_contract.py b/tests/generated_protocol_contract.py index ec6de7c..fd513f7 100644 --- a/tests/generated_protocol_contract.py +++ b/tests/generated_protocol_contract.py @@ -166,6 +166,21 @@ }, ], }, + { + 'statusCode': 500, + 'bodyKind': 'empty', + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + ], + }, + ], + }, ], }, { @@ -286,6 +301,21 @@ }, ], }, + { + 'statusCode': 500, + 'bodyKind': 'empty', + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + ], + }, + ], + }, ], }, { @@ -324,6 +354,21 @@ }, ], }, + { + 'statusCode': 423, + 'bodyKind': 'empty', + 'headerVariants': [ + { + 'fields': [ + { + 'displayName': 'Tus-Resumable', + 'name': 'tus-resumable', + 'required': True, + }, + ], + }, + ], + }, ], }, { @@ -2269,10 +2314,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -2296,6 +2343,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -2409,11 +2457,13 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Content-Type': 'application/offset+octet-stream', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -2543,11 +2593,13 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 5, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', 'Content-Type': 'application/offset+octet-stream', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -2572,6 +2624,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 5, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -2600,6 +2653,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 1, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -2718,12 +2772,14 @@ ], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': 'exact', 'headers': { 'Upload-Length': '11', 'Upload-Complete': '?1', 'Content-Type': 'application/partial-upload', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -2852,6 +2908,7 @@ ], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': 'exact', 'headers': {}, @@ -2880,6 +2937,7 @@ ], 'abort': False, 'bodySize': 5, + 'bodyStart': None, 'errorMessage': None, 'headerMode': 'exact', 'headers': { @@ -2911,6 +2969,7 @@ ], 'abort': False, 'bodySize': 5, + 'bodyStart': None, 'errorMessage': None, 'headerMode': 'exact', 'headers': { @@ -2942,6 +3001,7 @@ ], 'abort': False, 'bodySize': 1, + 'bodyStart': None, 'errorMessage': None, 'headerMode': 'exact', 'headers': { @@ -3063,6 +3123,7 @@ ], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': 'exact', 'headers': {}, @@ -3091,6 +3152,7 @@ ], 'abort': False, 'bodySize': 6, + 'bodyStart': None, 'errorMessage': None, 'headerMode': 'exact', 'headers': { @@ -3749,10 +3811,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', 'X-Request-ID': 'contract-request-id', }, 'headersSpecified': True, @@ -3846,10 +3910,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': 'socket down', 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', 'X-Request-ID': 'contract-request-id', }, 'headersSpecified': True, @@ -3926,10 +3992,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -3953,6 +4021,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -4048,10 +4117,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', 'X-Tus-Contract': 'custom-header', 'X-Tus-Trace': 'trace-123', }, @@ -4077,6 +4148,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -4178,10 +4250,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', 'X-Request-ID': '00000000-0000-4000-8000-000000000000', }, 'headersSpecified': True, @@ -4206,6 +4280,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -4341,6 +4416,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': {}, @@ -4367,6 +4443,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 6, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -4483,10 +4560,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -4510,6 +4589,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -4610,10 +4690,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -4637,6 +4719,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -4737,10 +4820,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -4764,6 +4849,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -4874,10 +4960,12 @@ ], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -4901,6 +4989,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5012,10 +5101,12 @@ ], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -5039,6 +5130,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5143,10 +5235,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -5170,6 +5264,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5297,10 +5392,12 @@ ], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -5324,6 +5421,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5474,10 +5572,12 @@ ], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Defer-Length': '1', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -5501,6 +5601,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 5, + 'bodyStart': 0, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5529,6 +5630,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 5, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5557,6 +5659,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 1, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5648,6 +5751,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': {}, @@ -5674,6 +5778,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 8, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5812,9 +5917,11 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '5', }, @@ -5840,9 +5947,11 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '6', }, @@ -5868,6 +5977,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 5, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5896,6 +6006,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 6, + 'bodyStart': 5, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -5926,9 +6037,11 @@ ], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { + 'Upload-Metadata': 'foo aGVsbG8=', 'Upload-Concat': 'final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2', }, 'headersSpecified': True, @@ -6059,9 +6172,11 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '5', 'X-Tus-Contract': 'parallel-cleanup-policy', @@ -6089,9 +6204,11 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { + 'Upload-Metadata': 'test d29ybGQ=', 'Upload-Concat': 'partial', 'Upload-Length': '6', 'X-Tus-Contract': 'parallel-cleanup-policy', @@ -6119,6 +6236,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 5, + 'bodyStart': 0, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6147,6 +6265,7 @@ 'absentHeaders': [], 'abort': True, 'bodySize': 6, + 'bodyStart': 5, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6169,6 +6288,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6195,6 +6315,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6305,10 +6426,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -6332,6 +6455,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6358,6 +6482,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': {}, @@ -6384,6 +6509,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 6, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6410,6 +6536,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': {}, @@ -6436,6 +6563,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 6, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6545,6 +6673,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': {}, @@ -6646,10 +6775,12 @@ 'absentHeaders': [], 'abort': True, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -6754,10 +6885,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', 'X-Tus-Contract': 'abort-policy', 'X-Tus-Trace': 'abort-trace-123', }, @@ -6783,6 +6916,7 @@ 'absentHeaders': [], 'abort': True, 'bodySize': 11, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6805,6 +6939,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6924,10 +7059,12 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { 'Upload-Length': '11', + 'Upload-Metadata': 'filename aGVsbG8udHh0', }, 'headersSpecified': True, 'method': None, @@ -6951,6 +7088,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': 5, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': { @@ -6979,6 +7117,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': {}, @@ -7002,6 +7141,7 @@ 'absentHeaders': [], 'abort': False, 'bodySize': None, + 'bodyStart': None, 'errorMessage': None, 'headerMode': None, 'headers': {}, diff --git a/tests/test_generated_runtime_events.py b/tests/test_generated_runtime_events.py index a27e71c..a1a4f05 100644 --- a/tests/test_generated_runtime_events.py +++ b/tests/test_generated_runtime_events.py @@ -432,6 +432,10 @@ def format_event_value(value): GENERATED_TUS_EVENT_KEY_PART_SEPARATOR = ':' +GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_BEFORE_HOOK = True +GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_REQUIRES_OPTION = True +GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_ENABLED = True +GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_REQUIRES_OPTION = True def generated_tus_event_key(*parts): @@ -696,7 +700,7 @@ def assert_stored_upload_state(test, case, storage): return fingerprint = case['storedUpload']['fingerprint'] - if case['removeFingerprintOnSuccess']: + if should_remove_stored_upload_on_success(case): test.assertIsNone(storage.get_item(fingerprint), case['scenarioId']) else: test.assertEqual( @@ -704,3 +708,17 @@ def assert_stored_upload_state(test, case, storage): case['storedUpload']['uploadUrl'], case['scenarioId'], ) + + +def should_remove_stored_upload_on_success(case): + if not GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_BEFORE_HOOK: + return False + if not GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_ENABLED: + return False + if ( + GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_REQUIRES_OPTION + or GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_REQUIRES_OPTION + ): + return case['removeFingerprintOnSuccess'] + + return True From 07ff964a623fdbc048d794a675a3002a9c7bcf57 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 05:15:22 +0200 Subject: [PATCH 70/95] Add Python TUS devdock resume coverage --- .../main.py | 107 ++----------- .../api2-devdock-tus-resume-upload/main.py | 144 ++++++++++++++++++ examples/api2devdock.py | 97 ++++++++++++ 3 files changed, 254 insertions(+), 94 deletions(-) create mode 100644 examples/api2-devdock-tus-resume-upload/main.py create mode 100644 examples/api2devdock.py diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.py b/examples/api2-devdock-transloadit-assembly-upload/main.py index 137aa94..6f94c75 100644 --- a/examples/api2-devdock-transloadit-assembly-upload/main.py +++ b/examples/api2-devdock-transloadit-assembly-upload/main.py @@ -5,97 +5,26 @@ ordinary tus-py-client usage against the injected TUS endpoint. """ -import json -import os +import sys from io import BytesIO from pathlib import Path from tusclient import client as tus - -def fail(message): - raise RuntimeError(message) - - -def load_scenario(): - configured_path = os.environ.get("API2_SDK_EXAMPLE_SCENARIO") - scenario_path = ( - Path(configured_path) if configured_path else Path(__file__).with_name("api2-scenario.json") - ) - with scenario_path.open(encoding="utf-8") as scenario_file: - return json.load(scenario_file) - - -def read_path(value, path_parts, label): - current = value - for part in path_parts: - if isinstance(current, list) and isinstance(part, int): - if part >= len(current): - fail("{} path {!r} index {} is out of range".format(label, path_parts, part)) - current = current[part] - continue - - if isinstance(current, dict) and isinstance(part, str): - if part not in current: - fail("{} path {!r} is missing key {!r}".format(label, path_parts, part)) - current = current[part] - continue - - fail("{} path {!r} cannot read {!r} from {!r}".format(label, path_parts, part, current)) - - return current - - -def resolve_value(value_spec, context, label): - if "value" in value_spec: - return value_spec["value"] - - source = value_spec.get("source") - if not isinstance(source, dict): - fail("{} value spec has no literal value or source".format(label)) - - root = source.get("root") - if root not in context: - fail("{} value source root {!r} is unavailable".format(label, root)) - - path_parts = source.get("path") or [] - if not isinstance(path_parts, list): - fail("{} value source path must be a list".format(label)) - - return read_path(context[root], path_parts, label) - - -def scenario_bytes(upload_config): - source = upload_config["source"] - if source["kind"] != "bytes": - fail("unsupported scenario source kind {!r}".format(source["kind"])) - if source["encoding"] != "utf8": - fail("unsupported scenario source encoding {!r}".format(source["encoding"])) - return source["value"].encode("utf-8") - - -def scalar_string(value): - if value is None: - return "null" - if isinstance(value, bool): - return "true" if value else "false" - return str(value) - - -def upload_metadata(upload_config, scenario, create_response): - context = {"createResponse": create_response, "scenario": scenario} - metadata = {} - for field in upload_config["metadata"]: - metadata[field["name"]] = scalar_string( - resolve_value(field["value"], context, field["name"]) - ) - return metadata +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + load_scenario, + scenario_bytes, + tus_url, + upload_metadata, + write_result, +) def upload_with_tus(scenario, create_response): upload_config = scenario["upload"] - context = {"createResponse": create_response, "scenario": scenario} - endpoint_url = scalar_string(resolve_value(upload_config["tusUrl"], context, "tusUrl")) + endpoint_url = tus_url(upload_config, scenario, create_response) content = scenario_bytes(upload_config) if upload_config["chunkSize"] != "full-file": fail("unsupported chunk size policy {!r}".format(upload_config["chunkSize"])) @@ -116,21 +45,11 @@ def upload_with_tus(scenario, create_response): return uploader.url -def write_result(upload_url): - result_path = os.environ.get("API2_SDK_EXAMPLE_RESULT") - if not result_path: - return - - with Path(result_path).open("w", encoding="utf-8") as result_file: - json.dump({"uploadUrl": upload_url}, result_file, indent=2) - result_file.write("\n") - - def main(): - scenario = load_scenario() + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) create_response = scenario["prepared"]["createResponse"] upload_url = upload_with_tus(scenario, create_response) - write_result(upload_url) + write_result({"uploadUrl": upload_url}) print( "Python TUS SDK devdock scenario {} uploaded to {}".format( scenario["scenarioId"], upload_url diff --git a/examples/api2-devdock-tus-resume-upload/main.py b/examples/api2-devdock-tus-resume-upload/main.py new file mode 100644 index 0000000..7c14b1c --- /dev/null +++ b/examples/api2-devdock-tus-resume-upload/main.py @@ -0,0 +1,144 @@ +"""Resume a Transloadit devdock TUS upload using tus-py-client.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus +from tusclient.fingerprint.interface import Fingerprint +from tusclient.storage.interface import Storage + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import fail, load_scenario, scenario_bytes, tus_url, upload_metadata, write_result + + +class StaticFingerprint(Fingerprint): + def __init__(self, value): + self.value = value + + def get_fingerprint(self, fs): + return self.value + + +class MemoryStorage(Storage): + def __init__(self): + self.urls = {} + + def get_item(self, key): + return self.urls.get(key) + + def set_item(self, key, value): + self.urls[key] = value + + def remove_item(self, key): + self.urls.pop(key, None) + + def count(self): + return len(self.urls) + + +def uploader_for(scenario, create_response, content, storage): + upload_config = scenario["upload"] + resume = upload_config["resume"] + return tus.TusClient(tus_url(upload_config, scenario, create_response)).uploader( + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=upload_metadata(upload_config, scenario, create_response), + store_url=True, + remove_fingerprint_on_success=resume["removeFingerprintOnSuccess"], + url_storage=storage, + fingerprinter=StaticFingerprint(resume["fingerprint"]), + retries=upload_config["retries"], + ) + + +def upload_first_chunk_and_pause(scenario, create_response, content, storage): + upload_config = scenario["upload"] + resume = upload_config["resume"] + chunk_size = upload_config["chunkSize"] + if chunk_size["kind"] != "fixed-bytes": + fail("unsupported chunk size policy {!r}".format(chunk_size["kind"])) + + uploader = uploader_for(scenario, create_response, content, storage) + uploader.chunk_size = chunk_size["bytes"] + uploader.upload(stop_at=resume["stopAfterAcceptedBytes"]) + + if uploader.offset != resume["stopAfterAcceptedBytes"]: + fail( + "first upload accepted {}, expected {}".format( + uploader.offset, + resume["stopAfterAcceptedBytes"], + ) + ) + if not uploader.url: + fail("first TUS upload did not expose an upload URL") + + return uploader.url + + +def resume_stored_upload(scenario, create_response, content, storage): + upload_config = scenario["upload"] + uploader = uploader_for(scenario, create_response, content, storage) + uploader.chunk_size = len(content) + uploader.upload() + + if not uploader.url: + fail("resumed TUS upload did not expose an upload URL") + if uploader.offset != len(content): + fail("resumed TUS upload offset {}, expected {}".format(uploader.offset, len(content))) + + return uploader.url + + +def upload_with_stored_resume(scenario, create_response): + upload_config = scenario["upload"] + resume = upload_config["resume"] + content = scenario_bytes(upload_config) + storage = MemoryStorage() + + first_upload_url = upload_first_chunk_and_pause(scenario, create_response, content, storage) + previous_upload_count = storage.count() + if previous_upload_count != resume["expectedPreviousUploadCount"]: + fail( + "stored upload count {}, expected {}".format( + previous_upload_count, + resume["expectedPreviousUploadCount"], + ) + ) + + upload_url = resume_stored_upload(scenario, create_response, content, storage) + if upload_url != first_upload_url: + fail("resumed upload URL {}, expected {}".format(upload_url, first_upload_url)) + + remaining_previous_upload_count = storage.count() + if remaining_previous_upload_count != resume["expectedRemainingPreviousUploadCount"]: + fail( + "remaining stored upload count {}, expected {}".format( + remaining_previous_upload_count, + resume["expectedRemainingPreviousUploadCount"], + ) + ) + + return { + "firstUploadUrl": first_upload_url, + "previousUploadCount": previous_upload_count, + "remainingPreviousUploadCount": remaining_previous_upload_count, + "uploadUrl": upload_url, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_with_stored_resume(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} resumed {}".format( + scenario["scenarioId"], + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py new file mode 100644 index 0000000..bf0edf6 --- /dev/null +++ b/examples/api2devdock.py @@ -0,0 +1,97 @@ +"""Shared helpers for API2 devdock examples.""" + +import json +import os +from pathlib import Path + + +def fail(message): + raise RuntimeError(message) + + +def load_scenario(default_path): + configured_path = os.environ.get("API2_SDK_EXAMPLE_SCENARIO") + scenario_path = Path(configured_path) if configured_path else Path(default_path) + with scenario_path.open(encoding="utf-8") as scenario_file: + return json.load(scenario_file) + + +def read_path(value, path_parts, label): + current = value + for part in path_parts: + if isinstance(current, list) and isinstance(part, int): + if part >= len(current): + fail("{} path {!r} index {} is out of range".format(label, path_parts, part)) + current = current[part] + continue + + if isinstance(current, dict) and isinstance(part, str): + if part not in current: + fail("{} path {!r} is missing key {!r}".format(label, path_parts, part)) + current = current[part] + continue + + fail("{} path {!r} cannot read {!r} from {!r}".format(label, path_parts, part, current)) + + return current + + +def resolve_value(value_spec, context, label): + if "value" in value_spec: + return value_spec["value"] + + source = value_spec.get("source") + if not isinstance(source, dict): + fail("{} value spec has no literal value or source".format(label)) + + root = source.get("root") + if root not in context: + fail("{} value source root {!r} is unavailable".format(label, root)) + + path_parts = source.get("path") or [] + if not isinstance(path_parts, list): + fail("{} value source path must be a list".format(label)) + + return read_path(context[root], path_parts, label) + + +def scenario_bytes(upload_config): + source = upload_config["source"] + if source["kind"] != "bytes": + fail("unsupported scenario source kind {!r}".format(source["kind"])) + if source["encoding"] != "utf8": + fail("unsupported scenario source encoding {!r}".format(source["encoding"])) + return source["value"].encode("utf-8") + + +def scalar_string(value): + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def tus_url(upload_config, scenario, create_response): + context = {"createResponse": create_response, "scenario": scenario} + return scalar_string(resolve_value(upload_config["tusUrl"], context, "tusUrl")) + + +def upload_metadata(upload_config, scenario, create_response): + context = {"createResponse": create_response, "scenario": scenario} + metadata = {} + for field in upload_config["metadata"]: + metadata[field["name"]] = scalar_string( + resolve_value(field["value"], context, field["name"]) + ) + return metadata + + +def write_result(result): + result_path = os.environ.get("API2_SDK_EXAMPLE_RESULT") + if not result_path: + return + + with Path(result_path).open("w", encoding="utf-8") as result_file: + json.dump(result, result_file, indent=2) + result_file.write("\n") From 9edef72f6bbd7c77222b2360954e1a2de7fc3ba3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 05:27:49 +0200 Subject: [PATCH 71/95] Add request lifecycle hooks and retry proof --- .../main.py | 146 ++++++++++++++++++ examples/api2devdock.py | 97 ++++++++++++ tests/test_request.py | 37 ++++- tests/test_uploader.py | 38 +++++ tusclient/client.py | 20 ++- tusclient/request.py | 9 +- tusclient/request_lifecycle.py | 15 ++ tusclient/uploader/baseuploader.py | 18 ++- tusclient/uploader/uploader.py | 9 +- 9 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 examples/api2-devdock-tus-retry-offset-recovery/main.py create mode 100644 tusclient/request_lifecycle.py diff --git a/examples/api2-devdock-tus-retry-offset-recovery/main.py b/examples/api2-devdock-tus-retry-offset-recovery/main.py new file mode 100644 index 0000000..8cca2dc --- /dev/null +++ b/examples/api2-devdock-tus-retry-offset-recovery/main.py @@ -0,0 +1,146 @@ +"""Recover a Transloadit devdock TUS upload offset after a retry.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus +from tusclient.request_lifecycle import RequestLifecycleHooks + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + fixed_chunk_size_bytes, + load_scenario, + retry_offset_recovery, + scenario_bytes, + scenario_id, + tus_url, + upload_metadata, + write_result, +) + + +def int_header(response, header_name): + value = response.headers.get(header_name) + try: + offset = int(value) + except (TypeError, ValueError): + fail( + "retry offset recovery expected numeric {} response header, got {!r}".format( + header_name, + value, + ) + ) + + if offset < 0: + fail("retry offset recovery expected non-negative offset, got {}".format(offset)) + + return offset + + +def assert_request_methods(actual, expected): + if actual != expected: + fail("retry offset recovery expected request methods {}, got {}".format(expected, actual)) + + +def upload_with_retry_offset_recovery(scenario, create_response): + upload_config = scenario["upload"] + retry = retry_offset_recovery(scenario) + content = scenario_bytes(upload_config) + chunk_size = fixed_chunk_size_bytes(scenario) + recovered_offsets = [] + request_methods = [] + failure_candidate_count = 0 + simulated_failure_count = 0 + + def before_request(context): + request_methods.append(context.method) + + def after_response(context, response): + nonlocal failure_candidate_count + nonlocal simulated_failure_count + + if context.method == retry["recoveryResponse"]["method"]: + recovered_offsets.append( + int_header(response, retry["recoveryResponse"]["offsetHeader"]) + ) + + if context.method != retry["failAfterResponse"]["method"]: + return + + failure_candidate_count += 1 + if failure_candidate_count != retry["failAfterResponse"]["occurrence"]: + return + + simulated_failure_count += 1 + response.status_code = 500 + response._content = retry["failAfterResponse"]["message"].encode("utf-8") + + client = tus.TusClient( + tus_url(upload_config, scenario, create_response), + request_hooks=RequestLifecycleHooks( + before_request=before_request, + after_response=after_response, + ), + ) + uploader = client.uploader( + file_stream=BytesIO(content), + chunk_size=chunk_size, + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + retry_delay=0, + ) + uploader.upload() + + if not uploader.url: + fail("retry offset recovery TUS upload did not expose an upload URL") + if uploader.offset != len(content): + fail("retry offset recovery upload offset {}, expected {}".format(uploader.offset, len(content))) + if simulated_failure_count != retry["expectedFailureCount"]: + fail( + "retry offset recovery expected {} simulated failure(s), got {}".format( + retry["expectedFailureCount"], + simulated_failure_count, + ) + ) + if len(recovered_offsets) != retry["expectedRecoveryRequestCount"]: + fail( + "retry offset recovery expected {} recovery request(s), got {}".format( + retry["expectedRecoveryRequestCount"], + len(recovered_offsets), + ) + ) + if recovered_offsets[0] != retry["expectedRecoveredOffset"]: + fail( + "retry offset recovery expected recovered offset {}, got {}".format( + retry["expectedRecoveredOffset"], + recovered_offsets[0], + ) + ) + assert_request_methods(request_methods, retry["expectedRequestMethods"]) + + return { + "recoveredOffsets": recovered_offsets, + "recoveryRequestCount": len(recovered_offsets), + "requestMethods": request_methods, + "simulatedFailureCount": simulated_failure_count, + "uploadUrl": uploader.url, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_with_retry_offset_recovery(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} recovered offset for {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index bf0edf6..24ac9fe 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -36,6 +36,32 @@ def read_path(value, path_parts, label): return current +def object_value(value, label): + if not isinstance(value, dict): + fail("{} must be an object".format(label)) + return value + + +def string_value(value, label): + if not isinstance(value, str): + fail("{} must be a string".format(label)) + return value + + +def int_value(value, label): + if not isinstance(value, int) or isinstance(value, bool): + fail("{} must be an integer".format(label)) + return value + + +def string_array_value(value, label): + if not isinstance(value, list): + fail("{} must be a list".format(label)) + for index, item in enumerate(value): + string_value(item, "{}[{}]".format(label, index)) + return value + + def resolve_value(value_spec, context, label): if "value" in value_spec: return value_spec["value"] @@ -64,6 +90,77 @@ def scenario_bytes(upload_config): return source["value"].encode("utf-8") +def fixed_chunk_size_bytes(scenario): + upload = object_value(scenario["upload"], "upload") + chunk_size = object_value(upload["chunkSize"], "upload.chunkSize") + kind = string_value(chunk_size["kind"], "upload.chunkSize.kind") + if kind != "fixed-bytes": + fail("unsupported chunk size kind {!r}".format(kind)) + bytes_value = int_value(chunk_size["bytes"], "upload.chunkSize.bytes") + if bytes_value <= 0: + fail("upload.chunkSize.bytes must be positive") + return bytes_value + + +def retry_offset_recovery(scenario): + upload = object_value(scenario["upload"], "upload") + retry = object_value(upload["retryOffsetRecovery"], "upload.retryOffsetRecovery") + fail_after_response = object_value( + retry["failAfterResponse"], + "upload.retryOffsetRecovery.failAfterResponse", + ) + recovery_response = object_value( + retry["recoveryResponse"], + "upload.retryOffsetRecovery.recoveryResponse", + ) + return { + "expectedFailureCount": int_value( + retry["expectedFailureCount"], + "upload.retryOffsetRecovery.expectedFailureCount", + ), + "expectedRecoveredOffset": int_value( + retry["expectedRecoveredOffset"], + "upload.retryOffsetRecovery.expectedRecoveredOffset", + ), + "expectedRecoveryRequestCount": int_value( + retry["expectedRecoveryRequestCount"], + "upload.retryOffsetRecovery.expectedRecoveryRequestCount", + ), + "expectedRequestMethods": string_array_value( + retry["expectedRequestMethods"], + "upload.retryOffsetRecovery.expectedRequestMethods", + ), + "failAfterResponse": { + "message": string_value( + fail_after_response["message"], + "upload.retryOffsetRecovery.failAfterResponse.message", + ), + "method": string_value( + fail_after_response["method"], + "upload.retryOffsetRecovery.failAfterResponse.method", + ), + "occurrence": int_value( + fail_after_response["occurrence"], + "upload.retryOffsetRecovery.failAfterResponse.occurrence", + ), + }, + "recoveryResponse": { + "method": string_value( + recovery_response["method"], + "upload.retryOffsetRecovery.recoveryResponse.method", + ), + "offsetHeader": string_value( + recovery_response["offsetHeader"], + "upload.retryOffsetRecovery.recoveryResponse.offsetHeader", + ), + }, + } + + +def scenario_id(scenario): + return string_value(scenario["scenarioId"], "scenarioId") + + def scalar_string(value): if value is None: return "null" diff --git a/tests/test_request.py b/tests/test_request.py index ac517e6..a40952b 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -5,6 +5,7 @@ import responses from tusclient import request +from tusclient.request_lifecycle import RequestLifecycleHooks from tests import mixin @@ -31,6 +32,41 @@ def test_perform(self, filename: str): self.request.perform() self.assertEqual(str(size), self.request.response_headers['upload-offset']) + def test_perform_request_lifecycle_hooks(self): + events = [] + + def before_request(context): + events.append(("before", context.method, context.url)) + context.headers["x-hook"] = "before" + + def after_response(context, response): + events.append(("after", context.method, response.status_code)) + response.headers["upload-offset"] = "7" + + def validate_headers(req): + self.assertEqual(req.headers["x-hook"], "before") + return (204, {"upload-offset": "5"}, "") + + self.client.set_request_hooks( + RequestLifecycleHooks( + before_request=before_request, + after_response=after_response, + ) + ) + + with responses.RequestsMock() as resps: + resps.add_callback(responses.PATCH, self.url, callback=validate_headers) + self.request.perform() + + self.assertEqual(self.request.response_headers["upload-offset"], "7") + self.assertEqual( + events, + [ + ("before", "PATCH", self.url), + ("after", "PATCH", 204), + ], + ) + def test_perform_checksum(self): self.uploader.upload_checksum = True tus_request = request.TusRequest(self.uploader) @@ -66,4 +102,3 @@ def validate_verify(req): resps.add_callback(responses.PATCH, self.url, callback=validate_verify) tus_request.perform() self.assertEqual(verify, False) - diff --git a/tests/test_uploader.py b/tests/test_uploader.py index 0c54226..c05d9be 100644 --- a/tests/test_uploader.py +++ b/tests/test_uploader.py @@ -12,6 +12,7 @@ from tusclient import exceptions from tusclient.fingerprint import fingerprint from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS +from tusclient.request_lifecycle import RequestLifecycleHooks from tusclient.storage import filestorage from tests import mixin @@ -47,6 +48,43 @@ def test_get_offset(self): adding_headers={"upload-offset": "300"}) self.assertEqual(self.uploader.get_offset(), 300) + @responses.activate + def test_get_offset_request_lifecycle_hooks(self): + events = [] + + def before_request(context): + events.append(("before", context.method, context.url)) + context.headers["x-hook"] = "before" + + def after_response(context, response): + events.append(("after", context.method, response.status_code)) + response.headers["upload-offset"] = "301" + + def validate_headers(req): + self.assertEqual(req.headers["x-hook"], "before") + return (200, {"upload-offset": "300"}, "") + + self.client.set_request_hooks( + RequestLifecycleHooks( + before_request=before_request, + after_response=after_response, + ) + ) + responses.add_callback( + responses.HEAD, + self.uploader.url, + callback=validate_headers, + ) + + self.assertEqual(self.uploader.get_offset(), 301) + self.assertEqual( + events, + [ + ("before", "HEAD", self.uploader.url), + ("after", "HEAD", 200), + ], + ) + def test_encode_metadata(self): self.uploader.metadata = {'foo': 'bar', 'red': 'blue'} encoded_metadata = ['foo' + ' ' + b64encode(b'bar').decode('ascii'), diff --git a/tusclient/client.py b/tusclient/client.py index f878d77..2273cf0 100644 --- a/tusclient/client.py +++ b/tusclient/client.py @@ -1,5 +1,6 @@ from typing import Dict, Optional, Tuple, Union +from tusclient.request_lifecycle import RequestLifecycleHooks from tusclient.uploader import Uploader, AsyncUploader @@ -27,10 +28,17 @@ class TusClient: - client_cert (Optional[str | Tuple[str, str]]) """ - def __init__(self, url: str, headers: Optional[Dict[str, str]] = None, client_cert: Optional[Union[str, Tuple[str, str]]] = None): + def __init__( + self, + url: str, + headers: Optional[Dict[str, str]] = None, + client_cert: Optional[Union[str, Tuple[str, str]]] = None, + request_hooks: Optional[RequestLifecycleHooks] = None, + ): self.url = url self.headers = headers or {} self.client_cert = client_cert + self.request_hooks = request_hooks def set_headers(self, headers: Dict[str, str]): """ @@ -45,6 +53,16 @@ def set_headers(self, headers: Dict[str, str]): """ self.headers.update(headers) + def set_request_hooks(self, request_hooks: Optional[RequestLifecycleHooks]): + """ + Set callbacks that are invoked around each HTTP request/response pair. + + :Args: + - request_hooks (Optional[RequestLifecycleHooks]): + callbacks to invoke before transport send and after transport response. + """ + self.request_hooks = request_hooks + def uploader(self, *args, **kwargs) -> Uploader: """ Return uploader instance pointing at current client instance. diff --git a/tusclient/request.py b/tusclient/request.py index 942d944..75073f4 100644 --- a/tusclient/request.py +++ b/tusclient/request.py @@ -40,6 +40,7 @@ class BaseTusRequest: """ def __init__(self, uploader): + self.uploader = uploader self._url = uploader.url self.status_code = None self.response_headers = {} @@ -88,14 +89,16 @@ def perform(self): headers = self._request_headers if stream_eof and self._upload_length_deferred: headers["upload-length"] = str(self._offset + len(chunk)) + context = self.uploader.run_before_request("PATCH", self._url, headers) resp = requests.patch( self._url, data=chunk, - headers=headers, + headers=context.headers, verify=self.verify_tls_cert, stream=True, cert=self.client_cert ) + self.uploader.run_after_response(context, resp) self.status_code = resp.status_code self.response_content = resp.content self.response_headers = {k.lower(): v for k, v in resp.headers.items()} @@ -129,9 +132,11 @@ async def perform(self): conn = aiohttp.TCPConnector(ssl=ssl_ctx) async with aiohttp.ClientSession(loop=self.io_loop, connector=conn) as session: verify_tls_cert = None if self.verify_tls_cert else False + context = self.uploader.run_before_request("PATCH", self._url, self._request_headers) async with session.patch( - self._url, data=chunk, headers=self._request_headers, ssl=verify_tls_cert + self._url, data=chunk, headers=context.headers, ssl=verify_tls_cert ) as resp: + self.uploader.run_after_response(context, resp) self.status_code = resp.status self.response_headers = { k.lower(): v for k, v in resp.headers.items() diff --git a/tusclient/request_lifecycle.py b/tusclient/request_lifecycle.py new file mode 100644 index 0000000..dc6333c --- /dev/null +++ b/tusclient/request_lifecycle.py @@ -0,0 +1,15 @@ +class TusRequestContext: + """Request context passed to lifecycle hooks before transport sends.""" + + def __init__(self, method, url, headers): + self.method = method + self.url = url + self.headers = headers + + +class RequestLifecycleHooks: + """Callbacks invoked around each HTTP request/response pair.""" + + def __init__(self, before_request=None, after_response=None): + self.before_request = before_request + self.after_response = after_response diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index d805b69..aad68fd 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -11,6 +11,7 @@ from tusclient.request import TusRequest, catch_requests_error from tusclient.fingerprint import fingerprint, interface from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS +from tusclient.request_lifecycle import TusRequestContext from tusclient.storage.interface import Storage if TYPE_CHECKING: @@ -180,6 +181,18 @@ def get_headers(self): client_headers = getattr(self.client, "headers", {}) return dict(self.DEFAULT_HEADERS, **client_headers) + def run_before_request(self, method, url, headers): + context = TusRequestContext(method, url, headers) + hooks = getattr(self.client, "request_hooks", None) + if hooks is not None and hooks.before_request is not None: + hooks.before_request(context) + return context + + def run_after_response(self, context, response): + hooks = getattr(self.client, "request_hooks", None) + if hooks is not None and hooks.after_response is not None: + hooks.after_response(context, response) + def get_url_creation_headers(self): """Return headers required to create upload url""" headers = self.get_headers() @@ -215,9 +228,12 @@ def get_offset(self): This is different from the instance attribute 'offset' because this makes an http request to the tus server to retrieve the offset. """ + headers = self.get_headers() + context = self.run_before_request("HEAD", self.url, headers) resp = requests.head( - self.url, headers=self.get_headers(), verify=self.verify_tls_cert, cert=self.client_cert + self.url, headers=context.headers, verify=self.verify_tls_cert, cert=self.client_cert ) + self.run_after_response(context, resp) offset = resp.headers.get("upload-offset") if offset is None: msg = "Attempt to retrieve offset fails with status {}".format( diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index bbec2ac..5731a15 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -78,12 +78,15 @@ def create_url(self): Makes request to tus server to create a new upload url for the required file upload. """ + headers = self.get_url_creation_headers() + context = self.run_before_request("POST", self.client.url, headers) resp = requests.post( self.client.url, - headers=self.get_url_creation_headers(), + headers=context.headers, verify=self.verify_tls_cert, cert=self.client_cert, ) + self.run_after_response(context, resp) url = resp.headers.get("location") if url is None: msg = "Attempt to retrieve create file url with status {}".format( @@ -182,10 +185,12 @@ async def create_url(self): conn = aiohttp.TCPConnector(ssl=ssl_ctx) async with aiohttp.ClientSession(connector=conn) as session: headers = self.get_url_creation_headers() + context = self.run_before_request("POST", self.client.url, headers) verify_tls_cert = None if self.verify_tls_cert else False async with session.post( - self.client.url, headers=headers, ssl=verify_tls_cert + self.client.url, headers=context.headers, ssl=verify_tls_cert ) as resp: + self.run_after_response(context, resp) url = resp.headers.get("location") if url is None: msg = ( From 14970da242d2397606b77a7be9480a11b6c2d779 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 07:08:10 +0200 Subject: [PATCH 72/95] Add TUS request lifecycle devdock proof --- .../main.py | 107 ++++++++++++++++++ examples/api2devdock.py | 27 +++++ 2 files changed, 134 insertions(+) create mode 100644 examples/api2-devdock-tus-request-lifecycle-hooks/main.py diff --git a/examples/api2-devdock-tus-request-lifecycle-hooks/main.py b/examples/api2-devdock-tus-request-lifecycle-hooks/main.py new file mode 100644 index 0000000..6e135d7 --- /dev/null +++ b/examples/api2-devdock-tus-request-lifecycle-hooks/main.py @@ -0,0 +1,107 @@ +"""Observe Transloadit devdock TUS request lifecycle hooks.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus +from tusclient.request_lifecycle import RequestLifecycleHooks + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + load_scenario, + request_lifecycle_hooks, + scenario_bytes, + scenario_id, + tus_url, + upload_metadata, + write_result, +) + + +def assert_equal(actual, expected, label): + if actual != expected: + fail("request lifecycle hooks expected {} {}, got {}".format(label, expected, actual)) + + +def upload_with_request_lifecycle_hooks(scenario, create_response): + upload_config = scenario["upload"] + hooks = request_lifecycle_hooks(scenario) + content = scenario_bytes(upload_config) + before_request_methods = [] + after_response_methods = [] + after_response_status_codes = [] + if upload_config["chunkSize"] != "full-file": + fail("unsupported chunk size policy {!r}".format(upload_config["chunkSize"])) + + def before_request(context): + before_request_methods.append(context.method) + + def after_response(context, response): + after_response_methods.append(context.method) + after_response_status_codes.append(response.status_code) + + uploader = tus.TusClient( + tus_url(upload_config, scenario, create_response), + request_hooks=RequestLifecycleHooks( + before_request=before_request, + after_response=after_response, + ), + ).uploader( + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + ) + uploader.upload() + + if not uploader.url: + fail("request lifecycle hooks upload did not expose an upload URL") + if uploader.offset != len(content): + fail( + "request lifecycle hooks upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + + assert_equal( + before_request_methods, + hooks["expectedBeforeRequestMethods"], + "before request methods", + ) + assert_equal( + after_response_methods, + hooks["expectedAfterResponseMethods"], + "after response methods", + ) + assert_equal( + after_response_status_codes, + hooks["expectedAfterResponseStatusCodes"], + "after response status codes", + ) + + return { + "afterResponseMethods": after_response_methods, + "afterResponseStatusCodes": after_response_status_codes, + "beforeRequestMethods": before_request_methods, + "uploadUrl": uploader.url, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_with_request_lifecycle_hooks(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} observed lifecycle hooks for {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index 24ac9fe..be19776 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -62,6 +62,14 @@ def string_array_value(value, label): return value +def int_array_value(value, label): + if not isinstance(value, list): + fail("{} must be a list".format(label)) + for index, item in enumerate(value): + int_value(item, "{}[{}]".format(label, index)) + return value + + def resolve_value(value_spec, context, label): if "value" in value_spec: return value_spec["value"] @@ -157,6 +165,25 @@ def retry_offset_recovery(scenario): } +def request_lifecycle_hooks(scenario): + upload = object_value(scenario["upload"], "upload") + hooks = object_value(upload["requestLifecycleHooks"], "upload.requestLifecycleHooks") + return { + "expectedAfterResponseMethods": string_array_value( + hooks["expectedAfterResponseMethods"], + "upload.requestLifecycleHooks.expectedAfterResponseMethods", + ), + "expectedAfterResponseStatusCodes": int_array_value( + hooks["expectedAfterResponseStatusCodes"], + "upload.requestLifecycleHooks.expectedAfterResponseStatusCodes", + ), + "expectedBeforeRequestMethods": string_array_value( + hooks["expectedBeforeRequestMethods"], + "upload.requestLifecycleHooks.expectedBeforeRequestMethods", + ), + } + + def scenario_id(scenario): return string_value(scenario["scenarioId"], "scenarioId") From 7d64c2e54f1fb616442abc1e6ea1cf18e2f98c08 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 09:33:18 +0200 Subject: [PATCH 73/95] Add API2 upload callback proof --- .../api2-devdock-tus-upload-callbacks/main.py | 122 ++++++++++++++++ examples/api2devdock.py | 134 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 examples/api2-devdock-tus-upload-callbacks/main.py diff --git a/examples/api2-devdock-tus-upload-callbacks/main.py b/examples/api2-devdock-tus-upload-callbacks/main.py new file mode 100644 index 0000000..48132a1 --- /dev/null +++ b/examples/api2-devdock-tus-upload-callbacks/main.py @@ -0,0 +1,122 @@ +"""Observe Transloadit devdock TUS upload callbacks.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + load_scenario, + match_upload_callback_event_keys, + scenario_bytes, + scenario_id, + tus_url, + upload_callback_event_key, + upload_callback_event_key_number, + upload_callback_event_key_total, + upload_callbacks, + upload_metadata, + write_result, +) + + +class EventRecordingBytesIO(BytesIO): + def __init__(self, content, callbacks, events): + super().__init__(content) + self.callbacks = callbacks + self.events = events + + def close(self): + self.events.append( + upload_callback_event_key( + self.callbacks, + self.callbacks["eventKinds"]["sourceClose"], + ) + ) + super().close() + + +def upload_with_callbacks(scenario, create_response): + upload_config = scenario["upload"] + callbacks = upload_callbacks(scenario) + content = scenario_bytes(upload_config) + events = [] + if upload_config["chunkSize"] != "full-file": + fail("unsupported chunk size policy {!r}".format(upload_config["chunkSize"])) + + source = EventRecordingBytesIO(content, callbacks, events) + + def on_progress(bytes_sent, bytes_total): + events.append( + upload_callback_event_key( + callbacks, + callbacks["eventKinds"]["progress"], + upload_callback_event_key_number(bytes_sent), + upload_callback_event_key_total(bytes_total), + ) + ) + + def on_chunk_complete(chunk_size, bytes_accepted, bytes_total): + events.append( + upload_callback_event_key( + callbacks, + callbacks["eventKinds"]["chunkComplete"], + upload_callback_event_key_number(chunk_size), + upload_callback_event_key_number(bytes_accepted), + upload_callback_event_key_total(bytes_total), + ) + ) + + uploader = tus.TusClient(tus_url(upload_config, scenario, create_response)).uploader( + file_stream=source, + chunk_size=len(content), + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + on_progress=on_progress, + on_chunk_complete=on_chunk_complete, + ) + uploader.set_url(uploader.create_url()) + uploader.offset = 0 + events.append( + upload_callback_event_key( + callbacks, + callbacks["eventKinds"]["uploadUrlAvailable"], + ) + ) + + uploader.upload() + + if not uploader.url: + fail("upload callbacks TUS upload did not expose an upload URL") + if uploader.offset != len(content): + fail("upload callbacks upload offset {}, expected {}".format(uploader.offset, len(content))) + + events.append(upload_callback_event_key(callbacks, callbacks["eventKinds"]["success"])) + source.close() + matched_events = match_upload_callback_event_keys(callbacks, events) + + return { + "eventKeys": matched_events, + "rawEventKeys": events, + "uploadUrl": uploader.url, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_with_callbacks(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} observed upload callbacks for {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index be19776..1fdfcd1 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -70,6 +70,14 @@ def int_array_value(value, label): return value +def string_array_array_value(value, label): + if not isinstance(value, list): + fail("{} must be a list".format(label)) + for index, item in enumerate(value): + string_array_value(item, "{}[{}]".format(label, index)) + return value + + def resolve_value(value_spec, context, label): if "value" in value_spec: return value_spec["value"] @@ -184,6 +192,132 @@ def request_lifecycle_hooks(scenario): } +def upload_callbacks(scenario): + upload = object_value(scenario["upload"], "upload") + callbacks = object_value(upload["uploadCallbacks"], "upload.uploadCallbacks") + event_kinds = object_value( + callbacks["eventKinds"], + "upload.uploadCallbacks.eventKinds", + ) + return { + "allowedExtraEventKeyPrefixes": string_array_value( + callbacks["allowedExtraEventKeyPrefixes"], + "upload.uploadCallbacks.allowedExtraEventKeyPrefixes", + ), + "eventKeyAlternativeGroups": string_array_array_value( + callbacks["eventKeyAlternativeGroups"], + "upload.uploadCallbacks.eventKeyAlternativeGroups", + ), + "eventKinds": { + "chunkComplete": string_value( + event_kinds["chunkComplete"], + "upload.uploadCallbacks.eventKinds.chunkComplete", + ), + "progress": string_value( + event_kinds["progress"], + "upload.uploadCallbacks.eventKinds.progress", + ), + "sourceClose": string_value( + event_kinds["sourceClose"], + "upload.uploadCallbacks.eventKinds.sourceClose", + ), + "success": string_value( + event_kinds["success"], + "upload.uploadCallbacks.eventKinds.success", + ), + "uploadUrlAvailable": string_value( + event_kinds["uploadUrlAvailable"], + "upload.uploadCallbacks.eventKinds.uploadUrlAvailable", + ), + }, + "eventKeyPartSeparator": string_value( + callbacks["eventKeyPartSeparator"], + "upload.uploadCallbacks.eventKeyPartSeparator", + ), + "eventKeys": string_array_value( + callbacks["eventKeys"], + "upload.uploadCallbacks.eventKeys", + ), + "eventPolicyMatching": string_value( + callbacks["eventPolicyMatching"], + "upload.uploadCallbacks.eventPolicyMatching", + ), + } + + +def upload_callback_event_key(callbacks, *parts): + return callbacks["eventKeyPartSeparator"].join(parts) + + +def upload_callback_event_key_number(value): + return str(value) + + +def upload_callback_event_key_total(value): + return scalar_string(value) + + +def upload_callback_event_matches_expected(callbacks, expected_index, actual): + if actual == callbacks["eventKeys"][expected_index]: + return True + + if expected_index >= len(callbacks["eventKeyAlternativeGroups"]): + return False + + return actual in callbacks["eventKeyAlternativeGroups"][expected_index] + + +def has_allowed_upload_callback_extra_event_prefix(callbacks, event): + for prefix in callbacks["allowedExtraEventKeyPrefixes"]: + if event.startswith(prefix): + return True + + return False + + +def match_upload_callback_event_keys(callbacks, actual): + policy = callbacks["eventPolicyMatching"] + if policy not in ("exact", "exact-except-allowed-extra-events"): + fail("unsupported upload callback event policy {!r}".format(policy)) + + expected_index = 0 + matched = [] + for event in actual: + if expected_index < len(callbacks["eventKeys"]) and upload_callback_event_matches_expected( + callbacks, + expected_index, + event, + ): + matched.append(callbacks["eventKeys"][expected_index]) + expected_index += 1 + continue + + if policy == "exact-except-allowed-extra-events" and has_allowed_upload_callback_extra_event_prefix( + callbacks, + event, + ): + continue + + fail( + "upload callback events emitted unexpected extra event {!r}; allowed prefixes {}; expected {}, got {}".format( + event, + callbacks["allowedExtraEventKeyPrefixes"], + callbacks["eventKeys"], + actual, + ) + ) + + if expected_index != len(callbacks["eventKeys"]): + fail( + "upload callback events did not emit every expected non-extra event; expected {}, got {}".format( + callbacks["eventKeys"], + actual, + ) + ) + + return matched + + def scenario_id(scenario): return string_value(scenario["scenarioId"], "scenarioId") From 895822439182b2ecaa4ec8b4714f378cb48651ba Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 11:40:23 +0200 Subject: [PATCH 74/95] Add API2 custom request headers proof --- .../main.py | 99 +++++++++++++++++++ examples/api2devdock.py | 14 +++ 2 files changed, 113 insertions(+) create mode 100644 examples/api2-devdock-tus-custom-request-headers/main.py diff --git a/examples/api2-devdock-tus-custom-request-headers/main.py b/examples/api2-devdock-tus-custom-request-headers/main.py new file mode 100644 index 0000000..f09362c --- /dev/null +++ b/examples/api2-devdock-tus-custom-request-headers/main.py @@ -0,0 +1,99 @@ +"""Send Transloadit devdock TUS custom request headers.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus +from tusclient.request_lifecycle import RequestLifecycleHooks + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + load_scenario, + scenario_bytes, + scenario_id, + tus_url, + upload_headers, + upload_metadata, + write_result, +) + + +def record_custom_request_headers(context, expected_headers): + observed = {} + for header_name, expected_value in expected_headers.items(): + actual_value = context.headers.get(header_name) + if actual_value != expected_value: + fail( + "custom request header {} expected {!r}, got {!r}".format( + header_name, + expected_value, + actual_value, + ) + ) + observed[header_name] = actual_value + return observed + + +def upload_with_custom_request_headers(scenario, create_response): + upload_config = scenario["upload"] + expected_headers = upload_headers(scenario) + content = scenario_bytes(upload_config) + headers_by_method = {} + if upload_config["chunkSize"] != "full-file": + fail("unsupported chunk size policy {!r}".format(upload_config["chunkSize"])) + + def before_request(context): + if context.method in ("POST", "PATCH"): + headers_by_method[context.method] = record_custom_request_headers( + context, + expected_headers, + ) + + uploader = tus.TusClient( + tus_url(upload_config, scenario, create_response), + headers=expected_headers, + request_hooks=RequestLifecycleHooks(before_request=before_request), + ).uploader( + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + ) + uploader.upload() + + if not uploader.url: + fail("custom request headers upload did not expose an upload URL") + if uploader.offset != len(content): + fail( + "custom request headers upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + for method in ("POST", "PATCH"): + if method not in headers_by_method: + fail("custom request headers did not observe {} request".format(method)) + + return { + "headersByMethod": headers_by_method, + "uploadUrl": uploader.url, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_with_custom_request_headers(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} sent custom request headers for {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index 1fdfcd1..b863e71 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -78,6 +78,15 @@ def string_array_array_value(value, label): return value +def string_map_value(value, label): + if not isinstance(value, dict): + fail("{} must be an object".format(label)) + for key, item in value.items(): + string_value(key, "{} key".format(label)) + string_value(item, "{}.{}".format(label, key)) + return value + + def resolve_value(value_spec, context, label): if "value" in value_spec: return value_spec["value"] @@ -192,6 +201,11 @@ def request_lifecycle_hooks(scenario): } +def upload_headers(scenario): + upload = object_value(scenario["upload"], "upload") + return string_map_value(upload["headers"], "upload.headers") + + def upload_callbacks(scenario): upload = object_value(scenario["upload"], "upload") callbacks = object_value(upload["uploadCallbacks"], "upload.uploadCallbacks") From 91a9c70830b346530046b326458ef78161442227 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 14:39:39 +0200 Subject: [PATCH 75/95] Add API2 request ID headers proof --- .../main.py | 110 ++++++++++++++++++ examples/api2devdock.py | 13 +++ tests/test_client.py | 8 ++ tests/test_uploader.py | 27 ++++- tusclient/client.py | 8 ++ tusclient/protocol_generated.py | 32 +++++ tusclient/request.py | 20 ++-- tusclient/uploader/baseuploader.py | 18 +-- 8 files changed, 219 insertions(+), 17 deletions(-) create mode 100644 examples/api2-devdock-tus-request-id-headers/main.py diff --git a/examples/api2-devdock-tus-request-id-headers/main.py b/examples/api2-devdock-tus-request-id-headers/main.py new file mode 100644 index 0000000..58b2f43 --- /dev/null +++ b/examples/api2-devdock-tus-request-id-headers/main.py @@ -0,0 +1,110 @@ +"""Send Transloadit devdock TUS request ID headers.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus +from tusclient.request_lifecycle import RequestLifecycleHooks + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + load_scenario, + scenario_bytes, + scenario_id, + tus_url, + upload_add_request_id, + upload_headers, + upload_metadata, + upload_request_id_header_name, + write_result, +) + + +def record_request_id_header(context, request_id_header_name, custom_request_id): + actual_value = context.headers.get(request_id_header_name) + if actual_value is None: + fail("request ID header {} was not sent".format(request_id_header_name)) + if actual_value == custom_request_id: + fail( + "request ID header {} was not generated; saw custom value {!r}".format( + request_id_header_name, + custom_request_id, + ) + ) + if len(actual_value) != 36 or "-" not in actual_value: + fail( + "request ID header {} expected generated UUID shape, got {!r}".format( + request_id_header_name, + actual_value, + ) + ) + return {request_id_header_name: actual_value} + + +def upload_with_request_id_headers(scenario, create_response): + upload_config = scenario["upload"] + request_id_header_name = upload_request_id_header_name(scenario) + custom_headers = upload_headers(scenario) + custom_request_id = custom_headers[request_id_header_name] + content = scenario_bytes(upload_config) + headers_by_method = {} + if upload_config["chunkSize"] != "full-file": + fail("unsupported chunk size policy {!r}".format(upload_config["chunkSize"])) + + def before_request(context): + if context.method in ("POST", "PATCH"): + headers_by_method[context.method] = record_request_id_header( + context, + request_id_header_name, + custom_request_id, + ) + + uploader = tus.TusClient( + tus_url(upload_config, scenario, create_response), + headers=custom_headers, + request_hooks=RequestLifecycleHooks(before_request=before_request), + add_request_id=upload_add_request_id(scenario), + ).uploader( + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + ) + uploader.upload() + + if not uploader.url: + fail("request ID headers upload did not expose an upload URL") + if uploader.offset != len(content): + fail( + "request ID headers upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + for method in ("POST", "PATCH"): + if method not in headers_by_method: + fail("request ID headers did not observe {} request".format(method)) + + return { + "headersByMethod": headers_by_method, + "uploadUrl": uploader.url, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_with_request_id_headers(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} sent request ID headers for {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index b863e71..1d03b7b 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -206,6 +206,19 @@ def upload_headers(scenario): return string_map_value(upload["headers"], "upload.headers") +def upload_add_request_id(scenario): + upload = object_value(scenario["upload"], "upload") + value = upload["addRequestId"] + if not isinstance(value, bool): + fail("upload.addRequestId must be a boolean") + return value + + +def upload_request_id_header_name(scenario): + upload = object_value(scenario["upload"], "upload") + return string_value(upload["requestIdHeaderName"], "upload.requestIdHeaderName") + + def upload_callbacks(scenario): upload = object_value(scenario["upload"], "upload") callbacks = object_value(upload["uploadCallbacks"], "upload.uploadCallbacks") diff --git a/tests/test_client.py b/tests/test_client.py index b55b862..385e47b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -14,6 +14,7 @@ def setUp(self): def test_instance_attributes(self): self.assertEqual(self.client.url, 'http://tusd.tusdemo.net/files/') self.assertEqual(self.client.headers, {'foo': 'bar'}) + self.assertFalse(self.client.add_request_id) def test_set_headers(self): self.client.set_headers({'foo': 'bar tender'}) @@ -23,6 +24,13 @@ def test_set_headers(self): self.client.set_headers({'food': 'at the bar'}) self.assertEqual(self.client.headers, {'foo': 'bar tender', 'food': 'at the bar'}) + def test_request_id_header_toggle(self): + self.client.enable_request_id_header() + self.assertTrue(self.client.add_request_id) + + self.client.disable_request_id_header() + self.assertFalse(self.client.add_request_id) + @responses.activate def test_uploader(self): url = 'http://tusd.tusdemo.net/files/15acd89eabdf5738ffc' diff --git a/tests/test_uploader.py b/tests/test_uploader.py index c05d9be..aba0c25 100644 --- a/tests/test_uploader.py +++ b/tests/test_uploader.py @@ -11,7 +11,7 @@ from tusclient import exceptions from tusclient.fingerprint import fingerprint -from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS +from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS, REQUEST_ID_HEADER_NAME from tusclient.request_lifecycle import RequestLifecycleHooks from tusclient.storage import filestorage from tests import mixin @@ -42,6 +42,31 @@ def test_headers(self): self.client.set_headers({'foo': 'bar'}) self.assertEqual(self.uploader.get_headers(), dict(DEFAULT_REQUEST_HEADERS, foo='bar')) + self.client.set_headers({REQUEST_ID_HEADER_NAME: 'custom-request-id'}) + self.client.enable_request_id_header() + request_headers = self.uploader.get_headers() + self.assertEqual(request_headers['foo'], 'bar') + self.assertNotEqual(request_headers[REQUEST_ID_HEADER_NAME], 'custom-request-id') + self.assertEqual(len(request_headers[REQUEST_ID_HEADER_NAME]), 36) + self.assertIn('-', request_headers[REQUEST_ID_HEADER_NAME]) + + def test_prepare_request_headers_applies_operation_headers_before_custom_headers(self): + self.client.set_headers({'upload-offset': 'custom-offset'}) + request_headers = self.uploader.prepare_request_headers({'upload-offset': '1'}) + self.assertEqual(request_headers['upload-offset'], 'custom-offset') + + def test_prepare_request_headers_applies_request_id_after_custom_headers(self): + self.client.set_headers({REQUEST_ID_HEADER_NAME: 'custom-request-id'}) + self.client.enable_request_id_header() + request_headers = self.uploader.prepare_request_headers( + {REQUEST_ID_HEADER_NAME: 'operation-request-id'} + ) + + self.assertNotEqual(request_headers[REQUEST_ID_HEADER_NAME], 'operation-request-id') + self.assertNotEqual(request_headers[REQUEST_ID_HEADER_NAME], 'custom-request-id') + self.assertEqual(len(request_headers[REQUEST_ID_HEADER_NAME]), 36) + self.assertIn('-', request_headers[REQUEST_ID_HEADER_NAME]) + @responses.activate def test_get_offset(self): responses.add(responses.HEAD, self.uploader.url, diff --git a/tusclient/client.py b/tusclient/client.py index 2273cf0..9bc4137 100644 --- a/tusclient/client.py +++ b/tusclient/client.py @@ -34,11 +34,13 @@ def __init__( headers: Optional[Dict[str, str]] = None, client_cert: Optional[Union[str, Tuple[str, str]]] = None, request_hooks: Optional[RequestLifecycleHooks] = None, + add_request_id: bool = False, ): self.url = url self.headers = headers or {} self.client_cert = client_cert self.request_hooks = request_hooks + self.add_request_id = add_request_id def set_headers(self, headers: Dict[str, str]): """ @@ -63,6 +65,12 @@ def set_request_hooks(self, request_hooks: Optional[RequestLifecycleHooks]): """ self.request_hooks = request_hooks + def enable_request_id_header(self): + self.add_request_id = True + + def disable_request_id_header(self): + self.add_request_id = False + def uploader(self, *args, **kwargs) -> Uploader: """ Return uploader instance pointing at current client instance. diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index 4ef6b3d..ba4f8cf 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -9,3 +9,35 @@ DEFAULT_RESPONSE_HEADERS = { 'Tus-Resumable': '1.0.0', } +REQUEST_ID_HEADER_NAME = 'X-Request-ID' + + +def prepare_request_headers(operation_headers=None, custom_headers=None, add_request_id=False): + headers = {} + add_operation_request_headers(headers, operation_headers) + add_custom_request_headers(headers, custom_headers) + add_request_id_header(headers, add_request_id) + return headers + + +def add_operation_request_headers(headers, operation_headers): + headers.update(DEFAULT_REQUEST_HEADERS) + if operation_headers: + headers.update(operation_headers) + + +def add_custom_request_headers(headers, custom_headers): + if custom_headers: + headers.update(custom_headers) + + +def add_request_id_header(headers, add_request_id): + if not add_request_id: + return + headers[REQUEST_ID_HEADER_NAME] = generated_request_id() + + +def generated_request_id(): + import uuid + + return str(uuid.uuid4()) diff --git a/tusclient/request.py b/tusclient/request.py index 75073f4..df5a795 100644 --- a/tusclient/request.py +++ b/tusclient/request.py @@ -51,21 +51,20 @@ def __init__(self, uploader): self.file.seek(uploader.offset) self.client_cert = uploader.client_cert - self._request_headers = { + self._operation_headers = { "upload-offset": str(uploader.offset), "Content-Type": "application/offset+octet-stream", } self._offset = uploader.offset self._upload_length_deferred = uploader.upload_length_deferred - self._request_headers.update(uploader.get_headers()) self._content_length = uploader.get_request_length() self._upload_checksum = uploader.upload_checksum self._checksum_algorithm = uploader.checksum_algorithm self._checksum_algorithm_name = uploader.checksum_algorithm_name - def add_checksum(self, chunk: bytes): + def add_checksum(self, headers, chunk: bytes): if self._upload_checksum: - self._request_headers["upload-checksum"] = " ".join( + headers["upload-checksum"] = " ".join( ( self._checksum_algorithm_name, base64.b64encode(self._checksum_algorithm(chunk).digest()).decode( @@ -85,10 +84,11 @@ def perform(self): try: chunk = self.file.read(self._content_length) stream_eof = len(chunk) < self._content_length - self.add_checksum(chunk) - headers = self._request_headers + operation_headers = dict(self._operation_headers) + self.add_checksum(operation_headers, chunk) if stream_eof and self._upload_length_deferred: - headers["upload-length"] = str(self._offset + len(chunk)) + operation_headers["upload-length"] = str(self._offset + len(chunk)) + headers = self.uploader.prepare_request_headers(operation_headers) context = self.uploader.run_before_request("PATCH", self._url, headers) resp = requests.patch( self._url, @@ -121,7 +121,8 @@ async def perform(self): Perform actual request. """ chunk = self.file.read(self._content_length) - self.add_checksum(chunk) + operation_headers = dict(self._operation_headers) + self.add_checksum(operation_headers, chunk) try: ssl_ctx = ssl.create_default_context() if (self.client_cert is not None): @@ -132,7 +133,8 @@ async def perform(self): conn = aiohttp.TCPConnector(ssl=ssl_ctx) async with aiohttp.ClientSession(loop=self.io_loop, connector=conn) as session: verify_tls_cert = None if self.verify_tls_cert else False - context = self.uploader.run_before_request("PATCH", self._url, self._request_headers) + headers = self.uploader.prepare_request_headers(operation_headers) + context = self.uploader.run_before_request("PATCH", self._url, headers) async with session.patch( self._url, data=chunk, headers=context.headers, ssl=verify_tls_cert ) as resp: diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index aad68fd..45cb134 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -10,7 +10,7 @@ from tusclient.exceptions import TusCommunicationError from tusclient.request import TusRequest, catch_requests_error from tusclient.fingerprint import fingerprint, interface -from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS +from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS, prepare_request_headers from tusclient.request_lifecycle import TusRequestContext from tusclient.storage.interface import Storage @@ -178,8 +178,12 @@ def get_headers(self): Return headers of the uploader instance. This would include the headers of the client instance. """ + return self.prepare_request_headers() + + def prepare_request_headers(self, operation_headers=None): client_headers = getattr(self.client, "headers", {}) - return dict(self.DEFAULT_HEADERS, **client_headers) + add_request_id = getattr(self.client, "add_request_id", False) + return prepare_request_headers(operation_headers, client_headers, add_request_id) def run_before_request(self, method, url, headers): context = TusRequestContext(method, url, headers) @@ -195,13 +199,13 @@ def run_after_response(self, context, response): def get_url_creation_headers(self): """Return headers required to create upload url""" - headers = self.get_headers() + operation_headers = {} if self.upload_length_deferred: - headers['upload-defer-length'] = '1' + operation_headers['upload-defer-length'] = '1' else: - headers["upload-length"] = str(self.file_size) - headers["upload-metadata"] = ",".join(self.encode_metadata()) - return headers + operation_headers["upload-length"] = str(self.file_size) + operation_headers["upload-metadata"] = ",".join(self.encode_metadata()) + return self.prepare_request_headers(operation_headers) @property def checksum_algorithm(self): From ed7fedfb9f0a6e9ba162db5d6aac92a2121ff230 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 16:21:37 +0200 Subject: [PATCH 76/95] Add API2 upload body headers proof --- .../main.py | 99 +++++++++++++++++++ examples/api2devdock.py | 16 +++ 2 files changed, 115 insertions(+) create mode 100644 examples/api2-devdock-tus-upload-body-headers/main.py diff --git a/examples/api2-devdock-tus-upload-body-headers/main.py b/examples/api2-devdock-tus-upload-body-headers/main.py new file mode 100644 index 0000000..b20c435 --- /dev/null +++ b/examples/api2-devdock-tus-upload-body-headers/main.py @@ -0,0 +1,99 @@ +"""Observe Transloadit devdock TUS upload body headers.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus +from tusclient.request_lifecycle import RequestLifecycleHooks + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + load_scenario, + scenario_bytes, + scenario_id, + tus_url, + upload_body_headers_by_method, + upload_metadata, + write_result, +) + + +def record_body_headers(context, expected_headers): + observed = {} + for header_name, expected_value in expected_headers.items(): + actual_value = context.headers.get(header_name) + if actual_value != expected_value: + fail( + "upload body header {} for {} expected {!r}, got {!r}".format( + header_name, + context.method, + expected_value, + actual_value, + ) + ) + observed[header_name] = actual_value + return observed + + +def upload_with_body_headers(scenario, create_response): + upload_config = scenario["upload"] + expected_headers_by_method = upload_body_headers_by_method(scenario) + content = scenario_bytes(upload_config) + body_headers_by_method = {} + if upload_config["chunkSize"] != "full-file": + fail("unsupported chunk size policy {!r}".format(upload_config["chunkSize"])) + + def before_request(context): + if context.method in expected_headers_by_method: + body_headers_by_method[context.method] = record_body_headers( + context, + expected_headers_by_method[context.method], + ) + + uploader = tus.TusClient( + tus_url(upload_config, scenario, create_response), + request_hooks=RequestLifecycleHooks(before_request=before_request), + ).uploader( + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + ) + uploader.upload() + + if not uploader.url: + fail("upload body headers TUS upload did not expose an upload URL") + if uploader.offset != len(content): + fail( + "upload body headers upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + for method in expected_headers_by_method: + if method not in body_headers_by_method: + fail("upload body headers did not observe {} request".format(method)) + + return { + "bodyHeadersByMethod": body_headers_by_method, + "uploadUrl": uploader.url, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_with_body_headers(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} observed upload body headers for {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index 1d03b7b..7dd4d76 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -206,6 +206,22 @@ def upload_headers(scenario): return string_map_value(upload["headers"], "upload.headers") +def upload_body_headers_by_method(scenario): + upload = object_value(scenario["upload"], "upload") + body_headers_by_method = object_value( + upload["bodyHeadersByMethod"], + "upload.bodyHeadersByMethod", + ) + result = {} + for method, headers in body_headers_by_method.items(): + string_value(method, "upload.bodyHeadersByMethod key") + result[method] = string_map_value( + headers, + "upload.bodyHeadersByMethod.{}".format(method), + ) + return result + + def upload_add_request_id(scenario): upload = object_value(scenario["upload"], "upload") value = upload["addRequestId"] From 62e2e3b1afee3ee47885be261436828d6039d6a3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 17:27:56 +0200 Subject: [PATCH 77/95] Add API2 terminate upload proof --- .../api2-devdock-tus-terminate-upload/main.py | 116 ++++++++++++++++++ examples/api2devdock.py | 27 ++++ tests/test_client.py | 50 ++++++++ tusclient/client.py | 40 ++++++ tusclient/protocol_generated.py | 12 ++ 5 files changed, 245 insertions(+) create mode 100644 examples/api2-devdock-tus-terminate-upload/main.py diff --git a/examples/api2-devdock-tus-terminate-upload/main.py b/examples/api2-devdock-tus-terminate-upload/main.py new file mode 100644 index 0000000..bb99508 --- /dev/null +++ b/examples/api2-devdock-tus-terminate-upload/main.py @@ -0,0 +1,116 @@ +"""Terminate a Transloadit devdock TUS upload.""" + +import sys +from io import BytesIO +from pathlib import Path + +import requests +from tusclient import client as tus +from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS +from tusclient.request_lifecycle import RequestLifecycleHooks + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + fixed_chunk_size_bytes, + load_scenario, + scenario_bytes, + scenario_id, + termination, + tus_url, + upload_metadata, + write_result, +) + + +def count_method(methods, expected_method): + count = 0 + for method in methods: + if method == expected_method: + count += 1 + return count + + +def verify_terminated_upload(termination_plan, upload_url): + response = requests.request( + termination_plan["verificationMethod"], + upload_url, + headers=DEFAULT_REQUEST_HEADERS, + ) + return response.status_code + + +def upload_and_terminate(scenario, create_response): + upload_config = scenario["upload"] + termination_plan = termination(scenario) + content = scenario_bytes(upload_config) + chunk_size = fixed_chunk_size_bytes(scenario) + request_methods = [] + + if termination_plan["stopAfterAcceptedBytes"] > len(content): + fail( + "terminate upload stop-after bytes {} exceeds content length {}".format( + termination_plan["stopAfterAcceptedBytes"], + len(content), + ) + ) + + def before_request(context): + request_methods.append(context.method) + + uploader = tus.TusClient( + tus_url(upload_config, scenario, create_response), + request_hooks=RequestLifecycleHooks(before_request=before_request), + ).uploader( + file_stream=BytesIO(content), + chunk_size=chunk_size, + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + ) + uploader.upload(stop_at=termination_plan["stopAfterAcceptedBytes"]) + + if not uploader.url: + fail("terminate upload did not expose an upload URL") + if uploader.offset != termination_plan["stopAfterAcceptedBytes"]: + fail( + "terminate upload accepted {} bytes, expected {}".format( + uploader.offset, + termination_plan["stopAfterAcceptedBytes"], + ) + ) + + uploader.client.terminate_upload(uploader.url) + verification_status = verify_terminated_upload(termination_plan, uploader.url) + if verification_status != termination_plan["expectedVerificationStatus"]: + fail( + "terminate upload verification status {}, expected {}".format( + verification_status, + termination_plan["expectedVerificationStatus"], + ) + ) + + return { + "acceptedBytes": uploader.offset, + "deleteRequestCount": count_method(request_methods, termination_plan["method"]), + "requestMethods": request_methods, + "terminated": True, + "uploadUrl": uploader.url, + "verificationStatus": verification_status, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_and_terminate(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} terminated {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index 7dd4d76..433ecc3 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -288,6 +288,33 @@ def upload_callbacks(scenario): } +def termination(scenario): + upload = object_value(scenario["upload"], "upload") + termination_config = object_value(upload["termination"], "upload.termination") + return { + "expectedVerificationStatus": int_value( + termination_config["expectedVerificationStatus"], + "upload.termination.expectedVerificationStatus", + ), + "method": string_value( + termination_config["method"], + "upload.termination.method", + ), + "minimumDeleteRequestCount": int_value( + termination_config["minimumDeleteRequestCount"], + "upload.termination.minimumDeleteRequestCount", + ), + "stopAfterAcceptedBytes": int_value( + termination_config["stopAfterAcceptedBytes"], + "upload.termination.stopAfterAcceptedBytes", + ), + "verificationMethod": string_value( + termination_config["verificationMethod"], + "upload.termination.verificationMethod", + ), + } + + def upload_callback_event_key(callbacks, *parts): return callbacks["eventKeyPartSeparator"].join(parts) diff --git a/tests/test_client.py b/tests/test_client.py index 385e47b..956e17a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,6 +3,9 @@ import responses from tusclient import client +from tusclient.exceptions import TusCommunicationError +from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS, TERMINATE_UPLOAD_METHOD +from tusclient.request_lifecycle import RequestLifecycleHooks from tusclient.uploader import Uploader, AsyncUploader @@ -31,6 +34,53 @@ def test_request_id_header_toggle(self): self.client.disable_request_id_header() self.assertFalse(self.client.add_request_id) + @responses.activate + def test_terminate_upload(self): + url = 'http://tusd.tusdemo.net/files/15acd89eabdf5738ffc' + events = [] + + def before_request(context): + events.append(('before', context.method, context.url)) + context.headers['x-hook'] = 'before' + + def after_response(context, response): + events.append(('after', context.method, response.status_code)) + + self.client.set_request_hooks( + RequestLifecycleHooks( + before_request=before_request, + after_response=after_response, + ) + ) + responses.add(TERMINATE_UPLOAD_METHOD, url, status=204) + + response = self.client.terminate_upload(url) + request = responses.calls[0].request + + self.assertEqual(response.status_code, 204) + self.assertEqual(request.method, TERMINATE_UPLOAD_METHOD) + for header_name, header_value in DEFAULT_REQUEST_HEADERS.items(): + self.assertEqual(request.headers[header_name], header_value) + self.assertEqual(request.headers['x-hook'], 'before') + self.assertEqual( + events, + [ + ('before', TERMINATE_UPLOAD_METHOD, url), + ('after', TERMINATE_UPLOAD_METHOD, 204), + ], + ) + + @responses.activate + def test_terminate_upload_non_success_status(self): + url = 'http://tusd.tusdemo.net/files/15acd89eabdf5738ffc' + responses.add(TERMINATE_UPLOAD_METHOD, url, status=404, body='gone') + + with self.assertRaises(TusCommunicationError) as context: + self.client.terminate_upload(url) + + self.assertEqual(context.exception.status_code, 404) + self.assertEqual(context.exception.response_content, b'gone') + @responses.activate def test_uploader(self): url = 'http://tusd.tusdemo.net/files/15acd89eabdf5738ffc' diff --git a/tusclient/client.py b/tusclient/client.py index 9bc4137..d228ad3 100644 --- a/tusclient/client.py +++ b/tusclient/client.py @@ -1,6 +1,15 @@ from typing import Dict, Optional, Tuple, Union +import requests + +from tusclient.exceptions import TusCommunicationError +from tusclient.protocol_generated import ( + TERMINATE_UPLOAD_METHOD, + is_successful_response_status, + prepare_request_headers, +) from tusclient.request_lifecycle import RequestLifecycleHooks +from tusclient.request_lifecycle import TusRequestContext from tusclient.uploader import Uploader, AsyncUploader @@ -71,6 +80,37 @@ def enable_request_id_header(self): def disable_request_id_header(self): self.add_request_id = False + def terminate_upload(self, upload_url: str, verify_tls_cert: bool = True): + headers = prepare_request_headers(None, self.headers, self.add_request_id) + context = TusRequestContext(TERMINATE_UPLOAD_METHOD, upload_url, headers) + if self.request_hooks is not None and self.request_hooks.before_request is not None: + self.request_hooks.before_request(context) + + try: + response = requests.request( + TERMINATE_UPLOAD_METHOD, + upload_url, + headers=context.headers, + verify=verify_tls_cert, + cert=self.client_cert, + ) + except requests.exceptions.RequestException as error: + raise TusCommunicationError(error) + + if self.request_hooks is not None and self.request_hooks.after_response is not None: + self.request_hooks.after_response(context, response) + + if not is_successful_response_status(response.status_code): + raise TusCommunicationError( + "unexpected status code ({}) while terminating upload".format( + response.status_code + ), + response.status_code, + response.content, + ) + + return response + def uploader(self, *args, **kwargs) -> Uploader: """ Return uploader instance pointing at current client instance. diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index ba4f8cf..bfacca4 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -2,6 +2,7 @@ # If it looks wrong, please report the issue instead of editing this file by hand; # the source fix belongs in the protocol contract generator so all TUS clients stay in sync. +CREATE_UPLOAD_METHOD = 'POST' DEFAULT_PROTOCOL_VERSION = '1.0.0' DEFAULT_REQUEST_HEADERS = { 'Tus-Resumable': '1.0.0', @@ -9,7 +10,18 @@ DEFAULT_RESPONSE_HEADERS = { 'Tus-Resumable': '1.0.0', } +OFFSET_DISCOVERY_METHOD = 'HEAD' REQUEST_ID_HEADER_NAME = 'X-Request-ID' +SUCCESS_RESPONSE_STATUS_CATEGORY = 200 +TERMINATE_UPLOAD_METHOD = 'DELETE' +UPLOAD_CHUNK_METHOD = 'PATCH' + + +def is_successful_response_status(response_status_code): + return ( + response_status_code >= SUCCESS_RESPONSE_STATUS_CATEGORY + and response_status_code < SUCCESS_RESPONSE_STATUS_CATEGORY + 100 + ) def prepare_request_headers(operation_headers=None, custom_headers=None, add_request_id=False): From c275fecdbc9acbfe1b097a9b6cfa2bcdbde56bcb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 18:16:48 +0200 Subject: [PATCH 78/95] Add TUS creation-with-upload proof --- .../main.py | 71 ++++++++++++ tests/test_client.py | 89 ++++++++++++++- tusclient/client.py | 13 +++ tusclient/protocol_generated.py | 7 ++ tusclient/uploader/uploader.py | 103 ++++++++++++++++++ 5 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 examples/api2-devdock-tus-creation-with-upload/main.py diff --git a/examples/api2-devdock-tus-creation-with-upload/main.py b/examples/api2-devdock-tus-creation-with-upload/main.py new file mode 100644 index 0000000..6828381 --- /dev/null +++ b/examples/api2-devdock-tus-creation-with-upload/main.py @@ -0,0 +1,71 @@ +"""Create a Transloadit devdock TUS upload with bytes in the creation request.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + load_scenario, + scenario_bytes, + scenario_id, + tus_url, + upload_metadata, + write_result, +) + + +def upload_with_creation_body(scenario, create_response): + upload_config = scenario["upload"] + content = scenario_bytes(upload_config) + + if upload_config["chunkSize"] != "full-file": + fail("unsupported chunk size policy {!r}".format(upload_config["chunkSize"])) + if not upload_config["uploadDataDuringCreation"]: + fail("scenario does not enable uploadDataDuringCreation") + + uploader = tus.TusClient( + tus_url(upload_config, scenario, create_response) + ).create_upload_with_data( + len(content), + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + ) + uploader.upload() + + if not uploader.url: + fail("creation-with-upload did not expose an upload URL") + if uploader.offset != len(content): + fail( + "creation-with-upload accepted {} bytes, expected {}".format( + uploader.offset, + len(content), + ) + ) + + return { + "acceptedBytes": uploader.offset, + "uploadUrl": uploader.url, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_with_creation_body(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} uploaded during creation to {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_client.py b/tests/test_client.py index 956e17a..e785815 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,10 +1,20 @@ import unittest +from io import BytesIO import responses from tusclient import client from tusclient.exceptions import TusCommunicationError -from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS, TERMINATE_UPLOAD_METHOD +from tusclient.protocol_generated import ( + DEFAULT_REQUEST_HEADERS, + CREATE_UPLOAD_METHOD, + LOCATION_HEADER_NAME, + TERMINATE_UPLOAD_METHOD, + UPLOAD_BODY_CONTENT_TYPE, + UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, + UPLOAD_LENGTH_HEADER_NAME, + UPLOAD_OFFSET_HEADER_NAME, +) from tusclient.request_lifecycle import RequestLifecycleHooks from tusclient.uploader import Uploader, AsyncUploader @@ -81,6 +91,83 @@ def test_terminate_upload_non_success_status(self): self.assertEqual(context.exception.status_code, 404) self.assertEqual(context.exception.response_content, b'gone') + @responses.activate + def test_create_upload_with_data(self): + upload_url = 'http://tusd.tusdemo.net/files/creation-with-upload' + events = [] + + def before_request(context): + events.append(('before', context.method, context.url)) + context.headers['x-hook'] = 'before' + + def after_response(context, response): + events.append(('after', context.method, response.status_code)) + + def validate_create_request(request): + self.assertEqual(request.body, b'hello') + self.assertEqual(request.headers[UPLOAD_LENGTH_HEADER_NAME], '5') + self.assertEqual( + request.headers[UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME], + UPLOAD_BODY_CONTENT_TYPE, + ) + self.assertEqual(request.headers['x-hook'], 'before') + for header_name, header_value in DEFAULT_REQUEST_HEADERS.items(): + self.assertEqual(request.headers[header_name], header_value) + + return ( + 201, + { + LOCATION_HEADER_NAME: upload_url, + UPLOAD_OFFSET_HEADER_NAME: '5', + }, + '', + ) + + self.client.set_request_hooks( + RequestLifecycleHooks( + before_request=before_request, + after_response=after_response, + ) + ) + responses.add_callback( + CREATE_UPLOAD_METHOD, + self.client.url, + callback=validate_create_request, + ) + + uploader = self.client.create_upload_with_data( + 5, + file_stream=BytesIO(b'hello'), + chunk_size=5, + metadata={}, + ) + + self.assertIsInstance(uploader, Uploader) + self.assertEqual(uploader.url, upload_url) + self.assertEqual(uploader.offset, 5) + self.assertEqual( + events, + [ + ('before', CREATE_UPLOAD_METHOD, self.client.url), + ('after', CREATE_UPLOAD_METHOD, 201), + ], + ) + + @responses.activate + def test_create_upload_with_data_non_success_status(self): + responses.add(CREATE_UPLOAD_METHOD, self.client.url, status=400, body='bad') + + with self.assertRaises(TusCommunicationError) as context: + self.client.create_upload_with_data( + 5, + file_stream=BytesIO(b'hello'), + chunk_size=5, + metadata={}, + ) + + self.assertEqual(context.exception.status_code, 400) + self.assertEqual(context.exception.response_content, b'bad') + @responses.activate def test_uploader(self): url = 'http://tusd.tusdemo.net/files/15acd89eabdf5738ffc' diff --git a/tusclient/client.py b/tusclient/client.py index d228ad3..50e2830 100644 --- a/tusclient/client.py +++ b/tusclient/client.py @@ -124,6 +124,19 @@ def uploader(self, *args, **kwargs) -> Uploader: kwargs["client"] = self return Uploader(*args, **kwargs) + def create_upload_with_data(self, bytes_to_upload: int, *args, **kwargs) -> Uploader: + """ + Create an upload URL while sending the first bytes in the creation request. + + :Args: + - bytes_to_upload (int): + Number of bytes to send during upload creation. + see tusclient.uploader.Uploader for remaining arguments. + """ + uploader = self.uploader(*args, **kwargs) + uploader.create_url_with_upload(bytes_to_upload) + return uploader + def async_uploader(self, *args, **kwargs) -> AsyncUploader: kwargs["client"] = self return AsyncUploader(*args, **kwargs) diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index bfacca4..812a6ec 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -10,11 +10,18 @@ DEFAULT_RESPONSE_HEADERS = { 'Tus-Resumable': '1.0.0', } +LOCATION_HEADER_NAME = 'Location' +METADATA_HEADER_NAME = 'Upload-Metadata' OFFSET_DISCOVERY_METHOD = 'HEAD' REQUEST_ID_HEADER_NAME = 'X-Request-ID' SUCCESS_RESPONSE_STATUS_CATEGORY = 200 TERMINATE_UPLOAD_METHOD = 'DELETE' +UPLOAD_BODY_CONTENT_TYPE = 'application/offset+octet-stream' +UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME = 'Content-Type' UPLOAD_CHUNK_METHOD = 'PATCH' +UPLOAD_DEFER_LENGTH_HEADER_NAME = 'Upload-Defer-Length' +UPLOAD_LENGTH_HEADER_NAME = 'Upload-Length' +UPLOAD_OFFSET_HEADER_NAME = 'Upload-Offset' def is_successful_response_status(response_status_code): diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index 5731a15..3107c17 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -10,6 +10,14 @@ from tusclient.uploader.baseuploader import BaseUploader from tusclient.exceptions import TusUploadFailed, TusCommunicationError +from tusclient.protocol_generated import ( + CREATE_UPLOAD_METHOD, + LOCATION_HEADER_NAME, + UPLOAD_BODY_CONTENT_TYPE, + UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, + UPLOAD_OFFSET_HEADER_NAME, + is_successful_response_status, +) from tusclient.request import TusRequest, AsyncTusRequest, catch_requests_error @@ -21,6 +29,101 @@ def _verify_upload(request: TusRequest): class Uploader(BaseUploader): + @catch_requests_error + def create_url_with_upload(self, bytes_to_upload: int): + """ + Create a new upload URL and send the first bytes in the creation request. + """ + if bytes_to_upload < 0: + raise ValueError("bytes_to_upload must be non-negative") + if self.upload_length_deferred: + raise ValueError( + "create_url_with_upload cannot be used with upload_length_deferred" + ) + if bytes_to_upload > self.get_file_size(): + raise ValueError("bytes_to_upload cannot exceed the upload size") + + stream = self.get_file_stream() + try: + chunk = stream.read(bytes_to_upload) + finally: + if self.file_stream is None: + stream.close() + + if len(chunk) != bytes_to_upload: + raise ValueError( + "Could only read {} of {} requested upload bytes".format( + len(chunk), + bytes_to_upload, + ) + ) + + headers = self.get_url_creation_headers() + headers[UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME] = UPLOAD_BODY_CONTENT_TYPE + context = self.run_before_request(CREATE_UPLOAD_METHOD, self.client.url, headers) + resp = requests.request( + CREATE_UPLOAD_METHOD, + self.client.url, + data=chunk, + headers=context.headers, + verify=self.verify_tls_cert, + cert=self.client_cert, + ) + self.run_after_response(context, resp) + + if not is_successful_response_status(resp.status_code): + raise TusCommunicationError( + "Attempt to create upload with data fails with status {}".format( + resp.status_code + ), + resp.status_code, + resp.content, + ) + + url = resp.headers.get(LOCATION_HEADER_NAME) + if url is None: + raise TusCommunicationError( + "Attempt to retrieve create file url with status {}".format( + resp.status_code + ), + resp.status_code, + resp.content, + ) + + offset = resp.headers.get(UPLOAD_OFFSET_HEADER_NAME) + if offset is None: + raise TusCommunicationError( + "Attempt to retrieve accepted upload offset with status {}".format( + resp.status_code + ), + resp.status_code, + resp.content, + ) + + try: + accepted_offset = int(offset) + except ValueError: + raise TusCommunicationError( + "Unexpected accepted upload offset {}".format(offset), + resp.status_code, + resp.content, + ) + if accepted_offset < 0 or accepted_offset > bytes_to_upload: + raise TusCommunicationError( + "Unexpected accepted upload offset {}".format(accepted_offset), + resp.status_code, + resp.content, + ) + + previous_offset = self.offset + self.set_url(urljoin(self.client.url, url)) + self.offset = accepted_offset + self.notify_progress(previous_offset) + self.notify_progress(self.offset) + self.notify_chunk_complete(self.offset - previous_offset, self.offset) + self.remove_url_on_success() + return self.url + def upload(self, stop_at: Optional[int] = None): """ Perform file upload. From 9f6638c59dc6f4f3f0d4e3c72af6e0e2fcb6b933 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 18:34:57 +0200 Subject: [PATCH 79/95] Add deferred-length TUS devdock proof --- .../main.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 examples/api2-devdock-tus-deferred-length-upload/main.py diff --git a/examples/api2-devdock-tus-deferred-length-upload/main.py b/examples/api2-devdock-tus-deferred-length-upload/main.py new file mode 100644 index 0000000..68e87a5 --- /dev/null +++ b/examples/api2-devdock-tus-deferred-length-upload/main.py @@ -0,0 +1,83 @@ +"""Create a Transloadit devdock TUS upload with deferred upload length.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + fixed_chunk_size_bytes, + load_scenario, + scenario_bytes, + scenario_id, + tus_url, + upload_metadata, + write_result, +) + + +def upload_with_deferred_length(scenario, create_response): + upload_config = scenario["upload"] + content = scenario_bytes(upload_config) + chunk_size = fixed_chunk_size_bytes(scenario) + + if not upload_config["uploadLengthDeferred"]: + fail("scenario does not enable uploadLengthDeferred") + if chunk_size <= len(content): + fail( + "deferred-length scenario chunk size {} must exceed payload size {}".format( + chunk_size, + len(content), + ) + ) + + uploader = tus.TusClient(tus_url(upload_config, scenario, create_response)).uploader( + file_stream=BytesIO(content), + chunk_size=chunk_size, + metadata=upload_metadata(upload_config, scenario, create_response), + retries=upload_config["retries"], + upload_length_deferred=True, + ) + uploader.upload() + + if not uploader.url: + fail("deferred-length TUS upload did not expose an upload URL") + if uploader.offset != len(content): + fail( + "deferred-length upload accepted {} bytes, expected {}".format( + uploader.offset, + len(content), + ) + ) + if uploader.file_size != len(content): + fail( + "deferred-length upload declared final size {}, expected {}".format( + uploader.file_size, + len(content), + ) + ) + + return { + "acceptedBytes": uploader.offset, + "uploadUrl": uploader.url, + } + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + create_response = scenario["prepared"]["createResponse"] + result = upload_with_deferred_length(scenario, create_response) + write_result(result) + print( + "Python TUS SDK devdock scenario {} deferred length for {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() From 1961c823ddf27585fec2d1ff879cbea99e44da70 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 21:52:03 +0200 Subject: [PATCH 80/95] Add start option validation proof --- .../main.py | 155 ++++++++++ tests/test_start_validation.py | 43 +++ tusclient/client.py | 1 + tusclient/protocol_generated.py | 271 ++++++++++++++++++ tusclient/start_validation.py | 147 ++++++++++ tusclient/uploader/baseuploader.py | 27 ++ 6 files changed, 644 insertions(+) create mode 100644 examples/api2-devdock-tus-start-option-validation/main.py create mode 100644 tests/test_start_validation.py create mode 100644 tusclient/start_validation.py diff --git a/examples/api2-devdock-tus-start-option-validation/main.py b/examples/api2-devdock-tus-start-option-validation/main.py new file mode 100644 index 0000000..d12f4dd --- /dev/null +++ b/examples/api2-devdock-tus-start-option-validation/main.py @@ -0,0 +1,155 @@ +"""Validate conflicting TUS start options before transport.""" + +import sys +from io import BytesIO +from pathlib import Path + +import requests +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + int_value, + load_scenario, + object_value, + scenario_id, + string_value, + write_result, +) + + +def bool_value(value, label): + if not isinstance(value, bool): + fail("{} must be a boolean".format(label)) + return value + + +def conformance_input_options(conformance_scenario): + entries = conformance_scenario["inputOptionEntries"] + if not isinstance(entries, list): + fail("conformanceScenario.inputOptionEntries must be a list") + + result = {} + for index, entry in enumerate(entries): + option = object_value( + entry, + "conformanceScenario.inputOptionEntries[{}]".format(index), + ) + key = string_value( + option["key"], + "conformanceScenario.inputOptionEntries[{}].key".format(index), + ) + result[key] = option["value"] + + return result + + +def conformance_input_source_bytes(conformance_scenario): + input_source = object_value( + conformance_scenario["inputSource"], + "conformanceScenario.inputSource", + ) + kind = string_value(input_source["kind"], "conformanceScenario.inputSource.kind") + if kind != "blob": + fail("unsupported conformance input source kind {!r}".format(kind)) + + return string_value( + input_source["content"], + "conformanceScenario.inputSource.content", + ).encode("utf-8") + + +def conformance_completion(conformance_scenario): + return object_value( + conformance_scenario["completion"], + "conformanceScenario.completion", + ) + + +def run_with_request_counter(operation): + request_count = 0 + original_head = requests.head + original_patch = requests.patch + original_post = requests.post + original_request = requests.request + + def record_request(*args, **kwargs): + nonlocal request_count + request_count += 1 + fail("start option validation made an unexpected HTTP request") + + requests.head = record_request + requests.patch = record_request + requests.post = record_request + requests.request = record_request + try: + return operation(), request_count + finally: + requests.head = original_head + requests.patch = original_patch + requests.post = original_post + requests.request = original_request + + +def validate_start_options(scenario): + conformance_scenario = object_value( + scenario["conformanceScenario"], + "conformanceScenario", + ) + completion = conformance_completion(conformance_scenario) + input_options = conformance_input_options(conformance_scenario) + content = conformance_input_source_bytes(conformance_scenario) + expected_message = string_value( + completion["message"], + "conformanceScenario.completion.message", + ) + + def start_upload(): + try: + tus.TusClient(input_options["endpointUrl"]).uploader( + file_stream=BytesIO(content), + parallel_uploads=int_value( + input_options["parallelUploads"], + "conformanceScenario.inputOptionEntries.parallelUploads", + ), + upload_data_during_creation=bool_value( + input_options.get("uploadDataDuringCreation", False), + "conformanceScenario.inputOptionEntries.uploadDataDuringCreation", + ), + url=input_options.get("uploadUrl"), + ) + except ValueError as error: + return { + "errorCaught": True, + "errorMessage": str(error), + } + + fail("start option validation unexpectedly accepted conflicting options") + + result, request_count = run_with_request_counter(start_upload) + result["requestCount"] = request_count + if result["errorMessage"] != expected_message: + fail( + "start option validation expected error {!r}, got {!r}".format( + expected_message, + result["errorMessage"], + ) + ) + + return result + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + result = validate_start_options(scenario) + write_result(result) + print( + "Python TUS SDK devdock scenario {} rejected conflicting start options".format( + scenario_id(scenario), + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_start_validation.py b/tests/test_start_validation.py new file mode 100644 index 0000000..4a58527 --- /dev/null +++ b/tests/test_start_validation.py @@ -0,0 +1,43 @@ +from io import BytesIO + +import pytest +import responses + +from tusclient import client +from tusclient.start_validation import validate_upload_start + + +def test_start_validation_uses_generated_message_for_parallel_creation_upload(): + tus_client = client.TusClient("http://tusd.tusdemo.net/files/") + + validation = validate_upload_start( + client=tus_client, + file_stream=BytesIO(b"hello world"), + parallel_uploads=2, + upload_data_during_creation=True, + ) + + assert validation == { + "message": "tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled", + "ok": False, + "reason": "parallelUploadsWithUploadDataDuringCreation", + } + + +@responses.activate +def test_parallel_uploads_with_upload_url_fails_before_offset_request(): + tus_client = client.TusClient("http://tusd.tusdemo.net/files/") + upload_url = "http://tusd.tusdemo.net/files/15acd89eabdf5738ffc" + responses.add(responses.HEAD, upload_url, adding_headers={"upload-offset": "0"}) + + with pytest.raises(ValueError) as error: + tus_client.uploader( + file_stream=BytesIO(b"hello world"), + parallel_uploads=2, + url=upload_url, + ) + + assert str(error.value) == ( + "tus: cannot use the `uploadUrl` option when parallelUploads is enabled" + ) + assert len(responses.calls) == 0 diff --git a/tusclient/client.py b/tusclient/client.py index 50e2830..f537d47 100644 --- a/tusclient/client.py +++ b/tusclient/client.py @@ -133,6 +133,7 @@ def create_upload_with_data(self, bytes_to_upload: int, *args, **kwargs) -> Uplo Number of bytes to send during upload creation. see tusclient.uploader.Uploader for remaining arguments. """ + kwargs["upload_data_during_creation"] = True uploader = self.uploader(*args, **kwargs) uploader.create_url_with_upload(bytes_to_upload) return uploader diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index 812a6ec..87f535d 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -14,8 +14,279 @@ METADATA_HEADER_NAME = 'Upload-Metadata' OFFSET_DISCOVERY_METHOD = 'HEAD' REQUEST_ID_HEADER_NAME = 'X-Request-ID' +START_VALIDATION_CLIENT_FLOW_VALUES = { + 'minimumParallelUploads': 2, +} +START_VALIDATION_MESSAGES = { + 'configuredUploadSizeMismatch': 'upload was configured with a size of {expectedSize} bytes, but the source is done after {actualSize} bytes', + 'cannotDeriveUploadSize': 'tus: cannot automatically derive upload\'s size from input. Specify it manually using the `uploadSize` option or use the `uploadLengthDeferred` option', + 'createMissingEndpoint': 'tus: unable to create upload because no endpoint is provided', + 'createMissingSize': 'tus: expected _size to be set', + 'createUploadRequestFailed': 'tus: failed to create upload', + 'createdUpload': 'Created upload at {uploadUrl}', + 'finalUploadMissingPartialUrls': 'tus: Expected _parallelUploadUrls to be set', + 'finalUploadRequestFailed': 'tus: failed to concatenate parallel uploads', + 'fingerprintCalculated': 'Calculated fingerprint: {fingerprint}', + 'fingerprintUnavailable': 'tus: unable to calculate fingerprint for this input file', + 'fingerprintUnavailableForStorage': 'No fingerprint was calculated meaning that the upload cannot be stored in the URL storage.', + 'invalidUploadSize': 'tus: cannot convert `uploadSize` option into a number', + 'invalidChunkOffset': 'tus: invalid or missing offset value', + 'invalidResumeLength': 'tus: invalid or missing length value', + 'invalidResumeOffset': 'tus: invalid Upload-Offset header', + 'lockedUpload': 'tus: upload is currently locked; retry later', + 'nonErrorThrownValue': 'tus: value thrown that is not an error: {value}', + 'missingEndpointOrUploadUrl': 'tus: neither an endpoint or an upload URL is provided', + 'missingInput': 'tus: no file or stream to upload provided', + 'missingPatchUrl': 'tus: Expected url to be set', + 'missingResumeOffset': 'tus: missing Upload-Offset header', + 'removedResumeOption': 'tus: The `resume` option has been removed in tus-js-client v2. Please use the URL storage API instead.', + 'parallelBoundariesLengthMismatch': 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', + 'parallelBoundariesWithoutParallelUploads': 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', + 'parallelUploadMissingSize': 'tus: Expected _size to be set', + 'parallelUploadsWithDeferredLength': 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', + 'parallelUploadsWithUploadDataDuringCreation': 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', + 'parallelUploadsWithUploadSize': 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', + 'parallelUploadsWithUploadUrl': 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + 'parallelUploadSliceMissingValue': 'tus: no value returned while slicing file for parallel uploads', + 'reactNativeUriBlobFetchFailed': 'tus: cannot fetch `file.uri` as Blob, make sure the uri is correct and accessible. {error}', + 'reactNativeUriUnsupported': 'tus: file objects with `uri` property is only supported in React Native', + 'resumeUploadRequestFailed': 'tus: failed to resume upload', + 'resumeWithoutEndpoint': 'tus: unable to resume upload (new upload cannot be created without an endpoint)', + 'retryDelaysNotArray': 'tus: the `retryDelays` option must either be an array or null', + 'storageMissingParallelUploadUrls': 'tus: cannot store parallel upload because no partial upload URLs are available', + 'storageMissingUploadUrl': 'tus: cannot store upload because no upload URL is available', + 'terminateUploadRequestFailed': 'tus: failed to terminate upload', + 'unexpectedChunkResponse': 'tus: unexpected response while uploading chunk', + 'unexpectedCreateResponse': 'tus: unexpected response while creating upload', + 'unexpectedResumeResponse': 'tus: unexpected response while resuming upload', + 'unexpectedTerminateResponse': 'tus: unexpected response while terminating upload', + 'uploadChunkRequestFailed': 'tus: failed to upload chunk at offset {offset}', + 'uploadLocationMissing': 'tus: invalid or missing Location header', + 'unsupportedProtocolPrefix': 'tus: unsupported protocol ', +} +START_VALIDATION_RULES = [ + { + 'message': { + 'key': 'missingInput', + 'kind': 'client-flow-message', + }, + 'predicate': { + 'equals': False, + 'input': 'hasFile', + 'kind': 'boolean-input', + }, + 'reason': 'missingInput', + 'expectedError': 'tus: no file or stream to upload provided', + 'scenarioId': 'startValidationMissingInput', + }, + { + 'message': { + 'input': 'protocol', + 'key': 'unsupportedProtocolPrefix', + 'kind': 'client-flow-message-with-input-suffix', + }, + 'predicate': { + 'equals': False, + 'input': 'protocol', + 'kind': 'supported-protocol', + }, + 'reason': 'unsupportedProtocol', + 'expectedError': 'tus: unsupported protocol tus-v9', + 'scenarioId': 'startValidationUnsupportedProtocol', + }, + { + 'message': { + 'key': 'missingEndpointOrUploadUrl', + 'kind': 'client-flow-message', + }, + 'predicate': { + 'kind': 'all', + 'predicates': [ + { + 'equals': False, + 'input': 'hasEndpoint', + 'kind': 'boolean-input', + }, + { + 'equals': False, + 'input': 'hasUploadUrl', + 'kind': 'boolean-input', + }, + { + 'equals': False, + 'input': 'hasCurrentUrl', + 'kind': 'boolean-input', + }, + ], + }, + 'reason': 'missingEndpointOrUploadUrl', + 'expectedError': 'tus: neither an endpoint or an upload URL is provided', + 'scenarioId': 'startValidationMissingEndpointOrUploadUrl', + }, + { + 'message': { + 'key': 'retryDelaysNotArray', + 'kind': 'client-flow-message', + }, + 'predicate': { + 'equals': False, + 'input': 'retryDelays', + 'kind': 'array-or-null', + }, + 'reason': 'retryDelaysNotArray', + 'expectedError': 'tus: the `retryDelays` option must either be an array or null', + 'scenarioId': 'startValidationRetryDelaysNotArray', + }, + { + 'message': { + 'key': 'parallelUploadsWithUploadUrl', + 'kind': 'client-flow-message', + }, + 'predicate': { + 'kind': 'all', + 'predicates': [ + { + 'input': 'parallelUploads', + 'kind': 'number-input-gte-client-flow-value', + 'value': 'minimumParallelUploads', + }, + { + 'equals': True, + 'input': 'hasUploadUrl', + 'kind': 'boolean-input', + }, + ], + }, + 'reason': 'parallelUploadsWithUploadUrl', + 'expectedError': 'tus: cannot use the `uploadUrl` option when parallelUploads is enabled', + 'scenarioId': 'startValidationParallelUploadsWithUploadUrl', + }, + { + 'message': { + 'key': 'parallelUploadsWithUploadSize', + 'kind': 'client-flow-message', + }, + 'predicate': { + 'kind': 'all', + 'predicates': [ + { + 'input': 'parallelUploads', + 'kind': 'number-input-gte-client-flow-value', + 'value': 'minimumParallelUploads', + }, + { + 'equals': True, + 'input': 'hasUploadSize', + 'kind': 'boolean-input', + }, + ], + }, + 'reason': 'parallelUploadsWithUploadSize', + 'expectedError': 'tus: cannot use the `uploadSize` option when parallelUploads is enabled', + 'scenarioId': 'startValidationParallelUploadsWithUploadSize', + }, + { + 'message': { + 'key': 'parallelUploadsWithDeferredLength', + 'kind': 'client-flow-message', + }, + 'predicate': { + 'kind': 'all', + 'predicates': [ + { + 'input': 'parallelUploads', + 'kind': 'number-input-gte-client-flow-value', + 'value': 'minimumParallelUploads', + }, + { + 'equals': True, + 'input': 'uploadLengthDeferred', + 'kind': 'boolean-input', + }, + ], + }, + 'reason': 'parallelUploadsWithDeferredLength', + 'expectedError': 'tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled', + 'scenarioId': 'startValidationParallelUploadsWithDeferredLength', + }, + { + 'message': { + 'key': 'parallelUploadsWithUploadDataDuringCreation', + 'kind': 'client-flow-message', + }, + 'predicate': { + 'kind': 'all', + 'predicates': [ + { + 'input': 'parallelUploads', + 'kind': 'number-input-gte-client-flow-value', + 'value': 'minimumParallelUploads', + }, + { + 'equals': True, + 'input': 'uploadDataDuringCreation', + 'kind': 'boolean-input', + }, + ], + }, + 'reason': 'parallelUploadsWithUploadDataDuringCreation', + 'expectedError': 'tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled', + 'scenarioId': 'startValidationParallelUploadsWithUploadDataDuringCreation', + }, + { + 'message': { + 'key': 'parallelBoundariesWithoutParallelUploads', + 'kind': 'client-flow-message', + }, + 'predicate': { + 'kind': 'all', + 'predicates': [ + { + 'input': 'parallelUploadBoundariesCount', + 'kind': 'number-input-not-null', + }, + { + 'input': 'parallelUploads', + 'kind': 'number-input-lt-client-flow-value', + 'value': 'minimumParallelUploads', + }, + ], + }, + 'reason': 'parallelBoundariesWithoutParallelUploads', + 'expectedError': 'tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled', + 'scenarioId': 'startValidationParallelBoundariesWithoutParallelUploads', + }, + { + 'message': { + 'key': 'parallelBoundariesLengthMismatch', + 'kind': 'client-flow-message', + }, + 'predicate': { + 'kind': 'all', + 'predicates': [ + { + 'input': 'parallelUploadBoundariesCount', + 'kind': 'number-input-not-null', + }, + { + 'kind': 'number-input-not-equals-number-input', + 'left': 'parallelUploads', + 'right': 'parallelUploadBoundariesCount', + }, + ], + }, + 'reason': 'parallelBoundariesLengthMismatch', + 'expectedError': 'tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`', + 'scenarioId': 'startValidationParallelBoundariesLengthMismatch', + }, +] SUCCESS_RESPONSE_STATUS_CATEGORY = 200 TERMINATE_UPLOAD_METHOD = 'DELETE' +TUS_SUPPORTED_PROTOCOLS = [ + 'tus-v1', + 'ietf-draft-03', + 'ietf-draft-05', +] UPLOAD_BODY_CONTENT_TYPE = 'application/offset+octet-stream' UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME = 'Content-Type' UPLOAD_CHUNK_METHOD = 'PATCH' diff --git a/tusclient/start_validation.py b/tusclient/start_validation.py new file mode 100644 index 0000000..c1f5623 --- /dev/null +++ b/tusclient/start_validation.py @@ -0,0 +1,147 @@ +from tusclient.protocol_generated import ( + START_VALIDATION_CLIENT_FLOW_VALUES, + START_VALIDATION_MESSAGES, + START_VALIDATION_RULES, + TUS_SUPPORTED_PROTOCOLS, +) + + +def validate_upload_start( + file_path=None, + file_stream=None, + client=None, + url=None, + upload_size=None, + upload_data_during_creation=False, + upload_length_deferred=False, + parallel_uploads=None, + parallel_upload_boundaries=None, + protocol=None, + retry_delays=None, +): + input_values = { + "hasCurrentUrl": url is not None, + "hasEndpoint": client is not None and getattr(client, "url", None) is not None, + "hasFile": file_path is not None or file_stream is not None, + "hasUploadSize": upload_size is not None, + "hasUploadUrl": url is not None, + "parallelUploadBoundariesCount": ( + len(parallel_upload_boundaries) + if parallel_upload_boundaries is not None + else None + ), + "parallelUploads": _default_parallel_uploads(parallel_uploads), + "protocol": _default_protocol(protocol), + "retryDelays": retry_delays, + "uploadDataDuringCreation": upload_data_during_creation, + "uploadLengthDeferred": upload_length_deferred, + } + + for rule in START_VALIDATION_RULES: + if _evaluate_predicate(rule["predicate"], input_values): + return { + "message": _resolve_message(rule["message"], input_values), + "ok": False, + "reason": rule["reason"], + } + + return {"ok": True} + + +def validate_upload_start_or_raise(**kwargs): + validation = validate_upload_start(**kwargs) + if validation["ok"]: + return validation + + raise ValueError(validation["message"]) + + +def _default_parallel_uploads(value): + if value is not None: + return value + + return START_VALIDATION_CLIENT_FLOW_VALUES["minimumParallelUploads"] - 1 + + +def _default_protocol(value): + if value is not None: + return value + + return TUS_SUPPORTED_PROTOCOLS[0] + + +def _evaluate_predicate(predicate, input_values): + kind = predicate["kind"] + + if kind == "all": + return all( + _evaluate_predicate(child, input_values) + for child in predicate.get("predicates", []) + ) + + if kind == "any": + return any( + _evaluate_predicate(child, input_values) + for child in predicate.get("predicates", []) + ) + + if kind == "array-or-null": + value = input_values[predicate["input"]] + result = value is None or isinstance(value, list) + return result == predicate["equals"] + + if kind == "boolean-input": + return bool(input_values[predicate["input"]]) == predicate["equals"] + + if kind == "not": + return not _evaluate_predicate(predicate["predicate"], input_values) + + if kind == "number-input-gte-client-flow-value": + return _number_input(input_values, predicate["input"]) >= _client_flow_value( + predicate["value"] + ) + + if kind == "number-input-lt-client-flow-value": + return _number_input(input_values, predicate["input"]) < _client_flow_value( + predicate["value"] + ) + + if kind == "number-input-not-equals-number-input": + return _number_input(input_values, predicate["left"]) != _number_input( + input_values, + predicate["right"], + ) + + if kind == "number-input-not-null": + return input_values[predicate["input"]] is not None + + if kind == "supported-protocol": + result = input_values[predicate["input"]] in TUS_SUPPORTED_PROTOCOLS + return result == predicate["equals"] + + raise ValueError("Unsupported generated start validation predicate {}".format(kind)) + + +def _client_flow_value(name): + return START_VALIDATION_CLIENT_FLOW_VALUES[name] + + +def _number_input(input_values, name): + value = input_values[name] + if value is None: + return 0 + + return value + + +def _resolve_message(message, input_values): + template = START_VALIDATION_MESSAGES[message["key"]] + if message["kind"] == "client-flow-message": + return template + + if message["kind"] == "client-flow-message-with-input-suffix": + return "{}{}".format(template, input_values[message["input"]]) + + raise ValueError( + "Unsupported generated start validation message {}".format(message["kind"]) + ) diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index 45cb134..df13e63 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -12,6 +12,7 @@ from tusclient.fingerprint import fingerprint, interface from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS, prepare_request_headers from tusclient.request_lifecycle import TusRequestContext +from tusclient.start_validation import validate_upload_start_or_raise from tusclient.storage.interface import Storage if TYPE_CHECKING: @@ -130,6 +131,12 @@ def __init__( fingerprinter: Optional[interface.Fingerprint] = None, upload_checksum=False, upload_length_deferred=False, + upload_size: Optional[int] = None, + upload_data_during_creation=False, + parallel_uploads: Optional[int] = None, + parallel_upload_boundaries=None, + protocol: Optional[str] = None, + retry_delays=None, on_progress: Optional[Callable[[int, Optional[int]], None]] = None, on_chunk_complete: Optional[Callable[[int, int, Optional[int]], None]] = None, ): @@ -144,6 +151,20 @@ def __init__( "Please specify a storage instance to enable resumablility." ) + validate_upload_start_or_raise( + client=client, + file_path=file_path, + file_stream=file_stream, + parallel_upload_boundaries=parallel_upload_boundaries, + parallel_uploads=parallel_uploads, + protocol=protocol, + retry_delays=retry_delays, + upload_data_during_creation=upload_data_during_creation, + upload_length_deferred=upload_length_deferred, + upload_size=upload_size, + url=url, + ) + self.verify_tls_cert = verify_tls_cert self.file_path = file_path self.file_stream = file_stream @@ -165,7 +186,13 @@ def __init__( self._retried = 0 self.retry_delay = retry_delay self.upload_checksum = upload_checksum + self.upload_data_during_creation = upload_data_during_creation self.upload_length_deferred = upload_length_deferred + self.upload_size = upload_size + self.parallel_uploads = parallel_uploads + self.parallel_upload_boundaries = parallel_upload_boundaries + self.protocol = protocol + self.retry_delays = retry_delays self.on_progress = on_progress self.on_chunk_complete = on_chunk_complete ( From 4640cdd5266267979152a1951471e1cba20c92c5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 22:07:28 +0200 Subject: [PATCH 81/95] Add detailed error proof --- .../api2-devdock-tus-detailed-error/main.py | 178 ++++++++++++++++++ tests/test_detailed_error.py | 77 ++++++++ tusclient/detailed_error.py | 106 +++++++++++ tusclient/exceptions.py | 30 +++ tusclient/protocol_generated.py | 7 + tusclient/uploader/uploader.py | 69 +++---- 6 files changed, 427 insertions(+), 40 deletions(-) create mode 100644 examples/api2-devdock-tus-detailed-error/main.py create mode 100644 tests/test_detailed_error.py create mode 100644 tusclient/detailed_error.py diff --git a/examples/api2-devdock-tus-detailed-error/main.py b/examples/api2-devdock-tus-detailed-error/main.py new file mode 100644 index 0000000..a72a6f1 --- /dev/null +++ b/examples/api2-devdock-tus-detailed-error/main.py @@ -0,0 +1,178 @@ +"""Validate detailed TUS create-upload errors from contract scenarios.""" + +import sys +from io import BytesIO +from pathlib import Path + +import requests +from tusclient import client as tus +from tusclient.exceptions import TusDetailedError + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + fail, + load_scenario, + object_value, + scenario_id, + string_value, + write_result, +) + + +def conformance_input_options(conformance_scenario): + entries = conformance_scenario["inputOptionEntries"] + if not isinstance(entries, list): + fail("conformanceScenario.inputOptionEntries must be a list") + + result = {} + for index, entry in enumerate(entries): + option = object_value( + entry, + "conformanceScenario.inputOptionEntries[{}]".format(index), + ) + key = string_value( + option["key"], + "conformanceScenario.inputOptionEntries[{}].key".format(index), + ) + result[key] = option["value"] + + return result + + +def conformance_input_source_bytes(conformance_scenario): + input_source = object_value( + conformance_scenario["inputSource"], + "conformanceScenario.inputSource", + ) + kind = string_value(input_source["kind"], "conformanceScenario.inputSource.kind") + if kind != "blob": + fail("unsupported conformance input source kind {!r}".format(kind)) + + return string_value( + input_source["content"], + "conformanceScenario.inputSource.content", + ).encode("utf-8") + + +def conformance_request(conformance_scenario): + requests_list = conformance_scenario["requests"] + if not isinstance(requests_list, list) or len(requests_list) != 1: + fail("detailed error scenario must have exactly one request") + return object_value(requests_list[0], "conformanceScenario.requests[0]") + + +def assert_expected_headers(actual_headers, expected_headers): + for key, expected_value in expected_headers.items(): + actual_value = actual_headers.get(key) + if actual_value != expected_value: + fail( + "detailed error expected header {}={!r}, got {!r}".format( + key, + expected_value, + actual_value, + ) + ) + + +def response_for(request_plan): + response_plan = object_value( + request_plan["response"], + "conformanceScenario.requests[0].response", + ) + response = requests.Response() + response.status_code = response_plan["statusCode"] + response._content = string_value( + response_plan["body"], + "conformanceScenario.requests[0].response.body", + ).encode("utf-8") + response.headers.update(response_plan.get("headers") or {}) + return response + + +def upload_expect_detailed_error(conformance_scenario): + request_plan = conformance_request(conformance_scenario) + input_options = conformance_input_options(conformance_scenario) + endpoint_url = string_value(input_options["endpointUrl"], "endpointUrl") + metadata = object_value(input_options["metadata"], "metadata") + headers = object_value(input_options["headers"], "headers") + content = conformance_input_source_bytes(conformance_scenario) + request_methods = [] + request_urls = [] + original_post = requests.post + + def fake_post(url, **kwargs): + request_methods.append(request_plan["effectiveMethod"]) + request_urls.append(url) + if url != request_plan["expectedUrl"]: + fail( + "detailed error expected URL {!r}, got {!r}".format( + request_plan["expectedUrl"], + url, + ) + ) + assert_expected_headers(kwargs["headers"], request_plan["headers"]) + error_message = request_plan.get("errorMessage") + if error_message is not None: + raise requests.exceptions.ConnectionError(error_message) + return response_for(request_plan) + + requests.post = fake_post + try: + try: + tus.TusClient(endpoint_url, headers=headers).uploader( + file_stream=BytesIO(content), + metadata=metadata, + ).create_url() + except Exception as error: + return detailed_result(error, request_methods, request_urls) + finally: + requests.post = original_post + + fail("detailed error scenario unexpectedly created an upload") + + +def detailed_result(error, request_methods, request_urls): + result = { + "errorCaught": True, + "errorIsDetailed": isinstance(error, TusDetailedError), + "errorMessage": str(error), + "requestCount": len(request_methods), + "requestMethods": request_methods, + "requestUrls": request_urls, + } + + if not isinstance(error, TusDetailedError): + return result + + result["causingErrorPresent"] = error.causing_error is not None + if error.causing_error is not None: + result["causingErrorMessage"] = str(error.causing_error) + result["originalRequestMethod"] = error.original_request_method + result["originalRequestRequestId"] = error.original_request_id + result["originalRequestUrl"] = error.original_request_url + result["originalResponsePresent"] = error.original_response_present + if error.original_response_present: + result["originalResponseBody"] = error.original_response_body + result["originalResponseStatus"] = error.original_response_status + + return result + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + conformance_scenario = object_value( + scenario["conformanceScenario"], + "conformanceScenario", + ) + result = upload_expect_detailed_error(conformance_scenario) + write_result(result) + print( + "Python TUS SDK devdock scenario {} observed detailed error {}".format( + scenario_id(scenario), + result["errorMessage"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_detailed_error.py b/tests/test_detailed_error.py new file mode 100644 index 0000000..0239dcf --- /dev/null +++ b/tests/test_detailed_error.py @@ -0,0 +1,77 @@ +from io import BytesIO + +import pytest +import requests +import responses + +from tusclient import client +from tusclient.exceptions import TusDetailedError +from tusclient.protocol_generated import CREATE_UPLOAD_METHOD, REQUEST_ID_HEADER_NAME + + +def uploader_for(tus_client): + return tus_client.uploader( + file_stream=BytesIO(b'hello world'), + metadata={'filename': 'hello.txt'}, + ) + + +@responses.activate +def test_create_upload_response_error_preserves_context(): + endpoint = 'https://tus.io/uploads' + tus_client = client.TusClient( + endpoint, + headers={REQUEST_ID_HEADER_NAME: 'contract-request-id'}, + ) + responses.add(CREATE_UPLOAD_METHOD, endpoint, status=500, body='server_error') + + with pytest.raises(TusDetailedError) as error: + uploader_for(tus_client).create_url() + + assert str(error.value) == ( + 'tus: unexpected response while creating upload, originated from request ' + '(method: POST, url: https://tus.io/uploads, response code: 500, ' + 'response text: server_error, request id: contract-request-id)' + ) + assert error.value.causing_error is None + assert error.value.original_request_method == CREATE_UPLOAD_METHOD + assert error.value.original_request_url == endpoint + assert error.value.original_request_id == 'contract-request-id' + assert error.value.original_response_body == 'server_error' + assert error.value.original_response_present is True + assert error.value.original_response_status == 500 + assert error.value.status_code == 500 + assert error.value.response_content == b'server_error' + + +def test_create_upload_request_error_preserves_context(monkeypatch): + endpoint = 'https://tus.io/uploads' + tus_client = client.TusClient( + endpoint, + headers={REQUEST_ID_HEADER_NAME: 'contract-request-id'}, + ) + + def failing_post(url, **kwargs): + assert url == endpoint + assert kwargs['headers'][REQUEST_ID_HEADER_NAME] == 'contract-request-id' + raise requests.exceptions.ConnectionError('socket down') + + monkeypatch.setattr(requests, 'post', failing_post) + + with pytest.raises(TusDetailedError) as error: + uploader_for(tus_client).create_url() + + assert str(error.value) == ( + 'tus: failed to create upload, caused by Error: socket down, ' + 'originated from request (method: POST, url: https://tus.io/uploads, ' + 'response code: n/a, response text: n/a, request id: contract-request-id)' + ) + assert str(error.value.causing_error) == 'socket down' + assert error.value.original_request_method == CREATE_UPLOAD_METHOD + assert error.value.original_request_url == endpoint + assert error.value.original_request_id == 'contract-request-id' + assert error.value.original_response_body is None + assert error.value.original_response_present is False + assert error.value.original_response_status is None + assert error.value.status_code is None + assert error.value.response_content is None diff --git a/tusclient/detailed_error.py b/tusclient/detailed_error.py new file mode 100644 index 0000000..d40fcbd --- /dev/null +++ b/tusclient/detailed_error.py @@ -0,0 +1,106 @@ +from tusclient.exceptions import TusDetailedError +from tusclient.protocol_generated import ( + DETAILED_ERROR_CAUSE_STRING_TEMPLATE, + DETAILED_ERROR_CAUSED_BY_TEMPLATE, + DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + DETAILED_ERROR_EMPTY_RESPONSE_BODY, + DETAILED_ERROR_MISSING_VALUE, + DETAILED_ERROR_REQUEST_CONTEXT_TEMPLATE, + DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE, + REQUEST_ID_HEADER_NAME, +) + + +def create_upload_response_error(context, response): + body = response_body(response) + message = detailed_error_message( + DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE, + request_context={ + 'body': body, + 'method': context.method, + 'requestId': request_id(context), + 'status': response.status_code, + 'url': context.url, + }, + ) + return TusDetailedError( + message, + status_code=response.status_code, + response_content=response.content, + original_request_method=context.method, + original_request_url=context.url, + original_request_id=request_id(context), + original_response_body=body, + original_response_present=True, + original_response_status=response.status_code, + ) + + +def create_upload_request_error(context, error): + message = detailed_error_message( + DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + cause=error, + request_context={ + 'body': DETAILED_ERROR_MISSING_VALUE, + 'method': context.method, + 'requestId': request_id(context), + 'status': DETAILED_ERROR_MISSING_VALUE, + 'url': context.url, + }, + ) + return TusDetailedError( + message, + causing_error=error, + original_request_method=context.method, + original_request_url=context.url, + original_request_id=request_id(context), + ) + + +def detailed_error_message(base_message, cause=None, request_context=None): + message = base_message + if cause is not None: + cause_message = format_flow_message( + DETAILED_ERROR_CAUSE_STRING_TEMPLATE, + {'message': missing_if_empty(str(cause))}, + ) + message += format_flow_message( + DETAILED_ERROR_CAUSED_BY_TEMPLATE, + {'cause': cause_message}, + ) + + if request_context is not None: + message += format_flow_message( + DETAILED_ERROR_REQUEST_CONTEXT_TEMPLATE, + request_context, + ) + + return message + + +def format_flow_message(template, values): + message = template + for key, value in values.items(): + message = message.replace('{{{}}}'.format(key), str(value)) + return message + + +def missing_if_empty(value): + if value is None or value == '': + return DETAILED_ERROR_MISSING_VALUE + return value + + +def request_id(context): + return missing_if_empty(context.headers.get(REQUEST_ID_HEADER_NAME)) + + +def response_body(response): + body = response.content + if body is None: + return DETAILED_ERROR_MISSING_VALUE + if body == b'': + return DETAILED_ERROR_EMPTY_RESPONSE_BODY + if isinstance(body, bytes): + return body.decode('utf-8', 'replace') + return str(body) diff --git a/tusclient/exceptions.py b/tusclient/exceptions.py index 959ac31..2b3a20d 100644 --- a/tusclient/exceptions.py +++ b/tusclient/exceptions.py @@ -31,5 +31,35 @@ def __init__(self, message, status_code=None, response_content=None): self.response_content = response_content +class TusDetailedError(TusCommunicationError): + """Communication error that preserves original request and response context.""" + + def __init__( + self, + message, + status_code=None, + response_content=None, + causing_error=None, + original_request_method=None, + original_request_url=None, + original_request_id=None, + original_response_body=None, + original_response_present=False, + original_response_status=None, + ): + super(TusDetailedError, self).__init__( + message, + status_code=status_code, + response_content=response_content, + ) + self.causing_error = causing_error + self.original_request_method = original_request_method + self.original_request_url = original_request_url + self.original_request_id = original_request_id + self.original_response_body = original_response_body + self.original_response_present = original_response_present + self.original_response_status = original_response_status + + class TusUploadFailed(TusCommunicationError): """Should be raised when an attempted upload fails""" diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index 87f535d..a9f69c3 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -10,6 +10,13 @@ DEFAULT_RESPONSE_HEADERS = { 'Tus-Resumable': '1.0.0', } +DETAILED_ERROR_CAUSE_STRING_TEMPLATE = 'Error: {message}' +DETAILED_ERROR_CAUSED_BY_TEMPLATE = ', caused by {cause}' +DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED = 'tus: failed to create upload' +DETAILED_ERROR_EMPTY_RESPONSE_BODY = '' +DETAILED_ERROR_MISSING_VALUE = 'n/a' +DETAILED_ERROR_REQUEST_CONTEXT_TEMPLATE = ', originated from request (method: {method}, url: {url}, response code: {status}, response text: {body}, request id: {requestId})' +DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE = 'tus: unexpected response while creating upload' LOCATION_HEADER_NAME = 'Location' METADATA_HEADER_NAME = 'Upload-Metadata' OFFSET_DISCOVERY_METHOD = 'HEAD' diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index 3107c17..97c31c5 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -9,6 +9,10 @@ from tusclient.uploader.baseuploader import BaseUploader +from tusclient.detailed_error import ( + create_upload_request_error, + create_upload_response_error, +) from tusclient.exceptions import TusUploadFailed, TusCommunicationError from tusclient.protocol_generated import ( CREATE_UPLOAD_METHOD, @@ -61,44 +65,29 @@ def create_url_with_upload(self, bytes_to_upload: int): headers = self.get_url_creation_headers() headers[UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME] = UPLOAD_BODY_CONTENT_TYPE context = self.run_before_request(CREATE_UPLOAD_METHOD, self.client.url, headers) - resp = requests.request( - CREATE_UPLOAD_METHOD, - self.client.url, - data=chunk, - headers=context.headers, - verify=self.verify_tls_cert, - cert=self.client_cert, - ) + try: + resp = requests.request( + CREATE_UPLOAD_METHOD, + self.client.url, + data=chunk, + headers=context.headers, + verify=self.verify_tls_cert, + cert=self.client_cert, + ) + except requests.exceptions.RequestException as error: + raise create_upload_request_error(context, error) self.run_after_response(context, resp) if not is_successful_response_status(resp.status_code): - raise TusCommunicationError( - "Attempt to create upload with data fails with status {}".format( - resp.status_code - ), - resp.status_code, - resp.content, - ) + raise create_upload_response_error(context, resp) url = resp.headers.get(LOCATION_HEADER_NAME) if url is None: - raise TusCommunicationError( - "Attempt to retrieve create file url with status {}".format( - resp.status_code - ), - resp.status_code, - resp.content, - ) + raise create_upload_response_error(context, resp) offset = resp.headers.get(UPLOAD_OFFSET_HEADER_NAME) if offset is None: - raise TusCommunicationError( - "Attempt to retrieve accepted upload offset with status {}".format( - resp.status_code - ), - resp.status_code, - resp.content, - ) + raise create_upload_response_error(context, resp) try: accepted_offset = int(offset) @@ -183,19 +172,19 @@ def create_url(self): """ headers = self.get_url_creation_headers() context = self.run_before_request("POST", self.client.url, headers) - resp = requests.post( - self.client.url, - headers=context.headers, - verify=self.verify_tls_cert, - cert=self.client_cert, - ) + try: + resp = requests.post( + self.client.url, + headers=context.headers, + verify=self.verify_tls_cert, + cert=self.client_cert, + ) + except requests.exceptions.RequestException as error: + raise create_upload_request_error(context, error) self.run_after_response(context, resp) url = resp.headers.get("location") - if url is None: - msg = "Attempt to retrieve create file url with status {}".format( - resp.status_code - ) - raise TusCommunicationError(msg, resp.status_code, resp.content) + if not is_successful_response_status(resp.status_code) or url is None: + raise create_upload_response_error(context, resp) return urljoin(self.client.url, url) def _do_request(self): From 4708bfabe3a9e24b9abbbb4f9ae6f3e4c8b775ee Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 22:12:38 +0200 Subject: [PATCH 82/95] Fix detailed error header matching --- examples/api2-devdock-tus-detailed-error/main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/api2-devdock-tus-detailed-error/main.py b/examples/api2-devdock-tus-detailed-error/main.py index a72a6f1..be5aa0a 100644 --- a/examples/api2-devdock-tus-detailed-error/main.py +++ b/examples/api2-devdock-tus-detailed-error/main.py @@ -62,8 +62,12 @@ def conformance_request(conformance_scenario): def assert_expected_headers(actual_headers, expected_headers): + normalized_actual_headers = {} + for key, value in actual_headers.items(): + normalized_actual_headers[key.lower()] = value + for key, expected_value in expected_headers.items(): - actual_value = actual_headers.get(key) + actual_value = normalized_actual_headers.get(key.lower()) if actual_value != expected_value: fail( "detailed error expected header {}={!r}, got {!r}".format( From c4c1f370aebb471ca066b36a646bd6cda202cc7d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 22:27:58 +0200 Subject: [PATCH 83/95] Add relative Location proof --- .../api2-devdock-tus-detailed-error/main.py | 37 +- .../main.py | 68 ++++ .../main.py | 37 +- examples/api2devdock.py | 347 ++++++++++++++++++ 4 files changed, 419 insertions(+), 70 deletions(-) create mode 100644 examples/api2-devdock-tus-relative-location-resolution/main.py diff --git a/examples/api2-devdock-tus-detailed-error/main.py b/examples/api2-devdock-tus-detailed-error/main.py index be5aa0a..1cc0627 100644 --- a/examples/api2-devdock-tus-detailed-error/main.py +++ b/examples/api2-devdock-tus-detailed-error/main.py @@ -10,6 +10,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from api2devdock import ( + conformance_input_options, + conformance_input_source_bytes, fail, load_scenario, object_value, @@ -19,41 +21,6 @@ ) -def conformance_input_options(conformance_scenario): - entries = conformance_scenario["inputOptionEntries"] - if not isinstance(entries, list): - fail("conformanceScenario.inputOptionEntries must be a list") - - result = {} - for index, entry in enumerate(entries): - option = object_value( - entry, - "conformanceScenario.inputOptionEntries[{}]".format(index), - ) - key = string_value( - option["key"], - "conformanceScenario.inputOptionEntries[{}].key".format(index), - ) - result[key] = option["value"] - - return result - - -def conformance_input_source_bytes(conformance_scenario): - input_source = object_value( - conformance_scenario["inputSource"], - "conformanceScenario.inputSource", - ) - kind = string_value(input_source["kind"], "conformanceScenario.inputSource.kind") - if kind != "blob": - fail("unsupported conformance input source kind {!r}".format(kind)) - - return string_value( - input_source["content"], - "conformanceScenario.inputSource.content", - ).encode("utf-8") - - def conformance_request(conformance_scenario): requests_list = conformance_scenario["requests"] if not isinstance(requests_list, list) or len(requests_list) != 1: diff --git a/examples/api2-devdock-tus-relative-location-resolution/main.py b/examples/api2-devdock-tus-relative-location-resolution/main.py new file mode 100644 index 0000000..5e334c2 --- /dev/null +++ b/examples/api2-devdock-tus-relative-location-resolution/main.py @@ -0,0 +1,68 @@ +"""Resolve a relative TUS Location header against the creation endpoint.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + TusConformancePlanServer, + conformance_input_options, + conformance_input_source_bytes, + fail, + load_scenario, + object_value, + scenario_id, + write_result, +) + + +def upload_with_relative_location_resolution(conformance_scenario): + input_options = conformance_input_options(conformance_scenario) + content = conformance_input_source_bytes(conformance_scenario) + endpoint_url = input_options["endpointUrl"] + + with TusConformancePlanServer(conformance_scenario, endpoint_url) as conformance_server: + uploader = tus.TusClient(conformance_server.endpoint_url()).uploader( + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=object_value(input_options["metadata"], "metadata"), + ) + uploader.upload() + + if not uploader.url: + fail("relative Location scenario did not expose an upload URL") + if uploader.offset != len(content): + fail( + "relative Location upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + + conformance_server.assert_exhausted() + result = conformance_server.result() + result["uploadUrl"] = conformance_server.canonical_url(uploader.url) + return result + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + conformance_scenario = object_value( + scenario["conformanceScenario"], + "conformanceScenario", + ) + result = upload_with_relative_location_resolution(conformance_scenario) + write_result(result) + print( + "Python TUS SDK devdock scenario {} resolved {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2-devdock-tus-start-option-validation/main.py b/examples/api2-devdock-tus-start-option-validation/main.py index d12f4dd..b644391 100644 --- a/examples/api2-devdock-tus-start-option-validation/main.py +++ b/examples/api2-devdock-tus-start-option-validation/main.py @@ -9,6 +9,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from api2devdock import ( + conformance_input_options, + conformance_input_source_bytes, fail, int_value, load_scenario, @@ -25,41 +27,6 @@ def bool_value(value, label): return value -def conformance_input_options(conformance_scenario): - entries = conformance_scenario["inputOptionEntries"] - if not isinstance(entries, list): - fail("conformanceScenario.inputOptionEntries must be a list") - - result = {} - for index, entry in enumerate(entries): - option = object_value( - entry, - "conformanceScenario.inputOptionEntries[{}]".format(index), - ) - key = string_value( - option["key"], - "conformanceScenario.inputOptionEntries[{}].key".format(index), - ) - result[key] = option["value"] - - return result - - -def conformance_input_source_bytes(conformance_scenario): - input_source = object_value( - conformance_scenario["inputSource"], - "conformanceScenario.inputSource", - ) - kind = string_value(input_source["kind"], "conformanceScenario.inputSource.kind") - if kind != "blob": - fail("unsupported conformance input source kind {!r}".format(kind)) - - return string_value( - input_source["content"], - "conformanceScenario.inputSource.content", - ).encode("utf-8") - - def conformance_completion(conformance_scenario): return object_value( conformance_scenario["completion"], diff --git a/examples/api2devdock.py b/examples/api2devdock.py index 433ecc3..ff928c9 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -1,8 +1,11 @@ """Shared helpers for API2 devdock examples.""" +from http.server import BaseHTTPRequestHandler, HTTPServer import json import os from pathlib import Path +from threading import Thread +from urllib.parse import urlparse, urlunparse def fail(message): @@ -87,6 +90,41 @@ def string_map_value(value, label): return value +def conformance_input_options(conformance_scenario): + entries = conformance_scenario["inputOptionEntries"] + if not isinstance(entries, list): + fail("conformanceScenario.inputOptionEntries must be a list") + + result = {} + for index, entry in enumerate(entries): + option = object_value( + entry, + "conformanceScenario.inputOptionEntries[{}]".format(index), + ) + key = string_value( + option["key"], + "conformanceScenario.inputOptionEntries[{}].key".format(index), + ) + result[key] = option["value"] + + return result + + +def conformance_input_source_bytes(conformance_scenario): + input_source = object_value( + conformance_scenario["inputSource"], + "conformanceScenario.inputSource", + ) + kind = string_value(input_source["kind"], "conformanceScenario.inputSource.kind") + if kind != "blob": + fail("unsupported conformance input source kind {!r}".format(kind)) + + return string_value( + input_source["content"], + "conformanceScenario.inputSource.content", + ).encode("utf-8") + + def resolve_value(value_spec, context, label): if "value" in value_spec: return value_spec["value"] @@ -400,6 +438,315 @@ def scalar_string(value): return str(value) +class TusConformancePlanServer: + def __init__(self, conformance_scenario, endpoint_origin): + self.endpoint_origin = urlparse(string_value(endpoint_origin, "endpointOrigin")) + if not self.endpoint_origin.scheme or not self.endpoint_origin.netloc: + fail("endpointOrigin must be an absolute URL") + + self.input_source_content = conformance_input_source_bytes(conformance_scenario) + requests = conformance_scenario["requests"] + if not isinstance(requests, list): + fail("conformanceScenario.requests must be a list") + self.requests = requests + self.errors = [] + self.observed = [None] * len(requests) + self.observed_count = 0 + self.next_request_index = 0 + self.httpd = HTTPServer(("127.0.0.1", 0), self._handler_class()) + self.thread = Thread(target=self.httpd.serve_forever) + self.thread.daemon = True + self.thread.start() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def close(self): + self.httpd.shutdown() + self.httpd.server_close() + self.thread.join(timeout=5) + + def endpoint_url(self): + return self.local_url(urlunparse(self.endpoint_origin)) + + def local_url(self, canonical_url): + parsed = urlparse(canonical_url) + if ( + parsed.scheme != self.endpoint_origin.scheme + or parsed.netloc != self.endpoint_origin.netloc + ): + return canonical_url + + server_origin = self._server_origin() + return urlunparse( + ( + server_origin.scheme, + server_origin.netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + + def canonical_url(self, actual_url): + parsed = urlparse(actual_url) + server_origin = self._server_origin() + if parsed.scheme != server_origin.scheme or parsed.netloc != server_origin.netloc: + return actual_url + + return urlunparse( + ( + self.endpoint_origin.scheme, + self.endpoint_origin.netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + + def local_value(self, value): + return string_value(value, "value").replace( + self._origin_string(self.endpoint_origin), + self._origin_string(self._server_origin()), + ) + + def canonical_value(self, value): + return string_value(value, "value").replace( + self._origin_string(self._server_origin()), + self._origin_string(self.endpoint_origin), + ) + + def assert_exhausted(self): + self.assert_no_errors() + if self.observed_count == len(self.requests): + return + + fail( + "expected {} conformance request(s), got {}".format( + len(self.requests), + self.observed_count, + ) + ) + + def assert_no_errors(self): + if self.errors: + fail("; ".join(self.errors)) + + def result(self): + self.assert_no_errors() + observed = [request for request in self.observed if request is not None] + return { + "absentHeaderPresence": [ + request["absentHeaderPresence"] for request in observed + ], + "requestBodySizes": [request["bodySize"] for request in observed], + "requestBodyStarts": [request["bodyStart"] for request in observed], + "requestCount": self.observed_count, + "requestHeaders": [request["headers"] for request in observed], + "requestMethods": [request["method"] for request in observed], + "requestUrls": [request["url"] for request in observed], + } + + def _handler_class(self): + conformance_server = self + + class TusConformanceRequestHandler(BaseHTTPRequestHandler): + def do_HEAD(self): + self._handle_conformance_request() + + def do_PATCH(self): + self._handle_conformance_request() + + def do_POST(self): + self._handle_conformance_request() + + def do_DELETE(self): + self._handle_conformance_request() + + def log_message(self, format, *args): + return + + def _handle_conformance_request(self): + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) if content_length > 0 else b"" + try: + request_plan = conformance_server.observe_request(self, body) + conformance_server.write_response(self, request_plan) + except Exception as error: + conformance_server.errors.append(str(error)) + response_body = str(error).encode("utf-8") + self.send_response(500) + self.send_header("Content-Length", str(len(response_body))) + self.end_headers() + self.wfile.write(response_body) + + return TusConformanceRequestHandler + + def observe_request(self, handler, body): + if self.next_request_index >= len(self.requests): + fail("unexpected request {} {}".format(handler.command, handler.path)) + + request_plan = object_value( + self.requests[self.next_request_index], + "conformanceScenario.requests[{}]".format(self.next_request_index), + ) + actual_url = self.canonical_url(self._request_url(handler)) + self.assert_request_matches_plan( + self.next_request_index, + request_plan, + handler.command, + actual_url, + body, + ) + self.assert_request_body_content(self.next_request_index, request_plan, body) + self.assert_absent_headers(self.next_request_index, request_plan, handler.headers) + expected_headers = object_value( + request_plan["effectiveHeaders"], + "conformanceScenario.requests[{}].effectiveHeaders".format( + self.next_request_index, + ), + ) + self.assert_headers(self.next_request_index, expected_headers, handler.headers) + + self.observed[self.next_request_index] = { + "absentHeaderPresence": self.captured_absent_header_presence( + request_plan, + handler.headers, + ), + "bodySize": None if request_plan.get("bodySize") is None else len(body), + "bodyStart": request_plan.get("bodyStart"), + "headers": self.captured_headers(expected_headers, handler.headers), + "method": handler.command, + "url": actual_url, + } + self.observed_count += 1 + self.next_request_index += 1 + return request_plan + + def assert_request_matches_plan(self, request_index, request_plan, method, actual_url, body): + expected_method = string_value( + request_plan["effectiveMethod"], + "conformanceScenario.requests[{}].effectiveMethod".format(request_index), + ) + expected_url = string_value( + request_plan["expectedUrl"], + "conformanceScenario.requests[{}].expectedUrl".format(request_index), + ) + if method != expected_method: + fail( + "request {} expected method {}, got {}".format( + request_index, + expected_method, + method, + ) + ) + if actual_url != expected_url: + fail( + "request {} expected URL {}, got {}".format( + request_index, + expected_url, + actual_url, + ) + ) + body_size = request_plan.get("bodySize") + if body_size is not None and len(body) != body_size: + fail( + "request {} expected body size {}, got {}".format( + request_index, + body_size, + len(body), + ) + ) + + def assert_request_body_content(self, request_index, request_plan, body): + body_start = request_plan.get("bodyStart") + if body_start is None: + return + + expected = self.input_source_content[body_start : body_start + len(body)] + if body != expected: + fail("request {} body did not match input source slice".format(request_index)) + + def assert_absent_headers(self, request_index, request_plan, actual_headers): + normalized_headers = self._normalized_headers(actual_headers) + for name in request_plan["absentHeaders"]: + if name.lower() not in normalized_headers: + continue + + fail("request {} expected header {} to be absent".format(request_index, name)) + + def assert_headers(self, request_index, expected_headers, actual_headers): + normalized_headers = self._normalized_headers(actual_headers) + for name, expected_value in expected_headers.items(): + actual_value = normalized_headers.get(name.lower()) + local_expected_value = self.local_value(expected_value) + if actual_value == local_expected_value: + continue + + fail( + "request {} expected header {}={!r}, got {!r}".format( + request_index, + name, + local_expected_value, + actual_value, + ) + ) + + def captured_headers(self, expected_headers, actual_headers): + normalized_headers = self._normalized_headers(actual_headers) + result = {} + for name in expected_headers: + actual_value = normalized_headers.get(name.lower()) + if actual_value is not None: + result[name] = self.canonical_value(actual_value) + + return result + + def captured_absent_header_presence(self, request_plan, actual_headers): + normalized_headers = self._normalized_headers(actual_headers) + result = {} + for name in request_plan["absentHeaders"]: + result[name] = name.lower() in normalized_headers + return result + + def write_response(self, handler, request_plan): + response_plan = object_value( + request_plan["response"], + "conformanceScenario request response", + ) + response_body = (response_plan.get("body") or "").encode("utf-8") + handler.send_response(response_plan["statusCode"]) + for name, value in response_plan["effectiveHeaders"].items(): + handler.send_header(name, self.local_value(value)) + if response_body: + handler.send_header("Content-Length", str(len(response_body))) + handler.end_headers() + if response_body: + handler.wfile.write(response_body) + + def _server_origin(self): + host, port = self.httpd.server_address + return urlparse("http://{}:{}".format(host, port)) + + def _request_url(self, handler): + host = handler.headers.get("Host") + if host is None: + host = "{}:{}".format(*self.httpd.server_address) + return "http://{}{}".format(host, handler.path) + + def _normalized_headers(self, headers): + return {name.lower(): value for name, value in headers.items()} + + @staticmethod + def _origin_string(parsed_url): + return "{}://{}".format(parsed_url.scheme, parsed_url.netloc) + + def tus_url(upload_config, scenario, create_response): context = {"createResponse": create_response, "scenario": scenario} return scalar_string(resolve_value(upload_config["tusUrl"], context, "tusUrl")) From db69a520f15f964b47dcfa8ca4019683bfe2efcc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 22:42:49 +0200 Subject: [PATCH 84/95] Add Python override PATCH proof --- .../main.py | 76 +++++++++++++++++++ examples/api2devdock.py | 6 ++ tusclient/protocol_generated.py | 47 ++++++++++++ tusclient/request.py | 41 ++++++++-- tusclient/uploader/baseuploader.py | 7 ++ 5 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 examples/api2-devdock-tus-override-patch-method/main.py diff --git a/examples/api2-devdock-tus-override-patch-method/main.py b/examples/api2-devdock-tus-override-patch-method/main.py new file mode 100644 index 0000000..8a12a9a --- /dev/null +++ b/examples/api2-devdock-tus-override-patch-method/main.py @@ -0,0 +1,76 @@ +"""Upload TUS bytes by tunneling PATCH through an override POST.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + TusConformancePlanServer, + bool_value, + conformance_input_options, + conformance_input_source_bytes, + fail, + load_scenario, + object_value, + scenario_id, + string_value, + write_result, +) + + +def upload_with_override_patch_method(conformance_scenario): + input_options = conformance_input_options(conformance_scenario) + content = conformance_input_source_bytes(conformance_scenario) + endpoint_url = string_value(input_options["endpointUrl"], "endpointUrl") + upload_url = string_value(input_options["uploadUrl"], "uploadUrl") + override_patch_method = bool_value( + input_options["overridePatchMethod"], + "overridePatchMethod", + ) + + with TusConformancePlanServer(conformance_scenario, endpoint_url) as conformance_server: + uploader = tus.TusClient(conformance_server.endpoint_url()).uploader( + file_stream=BytesIO(content), + chunk_size=len(content), + url=conformance_server.local_url(upload_url), + override_patch_method=override_patch_method, + ) + uploader.upload() + + if not uploader.url: + fail("override PATCH method scenario did not expose an upload URL") + if uploader.offset != len(content): + fail( + "override PATCH method upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + + conformance_server.assert_exhausted() + result = conformance_server.result() + result["uploadUrl"] = conformance_server.canonical_url(uploader.url) + return result + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + conformance_scenario = object_value( + scenario["conformanceScenario"], + "conformanceScenario", + ) + result = upload_with_override_patch_method(conformance_scenario) + write_result(result) + print( + "Python TUS SDK devdock scenario {} overrode PATCH for {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index ff928c9..df4af0a 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -57,6 +57,12 @@ def int_value(value, label): return value +def bool_value(value, label): + if not isinstance(value, bool): + fail("{} must be a boolean".format(label)) + return value + + def string_array_value(value, label): if not isinstance(value, list): fail("{} must be a list".format(label)) diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index a9f69c3..e202691 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -19,6 +19,19 @@ DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE = 'tus: unexpected response while creating upload' LOCATION_HEADER_NAME = 'Location' METADATA_HEADER_NAME = 'Upload-Metadata' +METHOD_OVERRIDE_INPUT_OPTION_NAMES = { + 'overridePatchMethod': 'override_patch_method', +} +METHOD_OVERRIDES = [ + { + 'headerName': 'X-HTTP-Method-Override', + 'headerValue': 'PATCH', + 'inputFlag': 'overridePatchMethod', + 'method': 'POST', + 'operationId': 'patchTusUpload', + 'sourceMethod': 'PATCH', + }, +] OFFSET_DISCOVERY_METHOD = 'HEAD' REQUEST_ID_HEADER_NAME = 'X-Request-ID' START_VALIDATION_CLIENT_FLOW_VALUES = { @@ -297,6 +310,7 @@ UPLOAD_BODY_CONTENT_TYPE = 'application/offset+octet-stream' UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME = 'Content-Type' UPLOAD_CHUNK_METHOD = 'PATCH' +UPLOAD_CHUNK_OPERATION_ID = 'patchTusUpload' UPLOAD_DEFER_LENGTH_HEADER_NAME = 'Upload-Defer-Length' UPLOAD_LENGTH_HEADER_NAME = 'Upload-Length' UPLOAD_OFFSET_HEADER_NAME = 'Upload-Offset' @@ -317,6 +331,39 @@ def prepare_request_headers(operation_headers=None, custom_headers=None, add_req return headers +def request_method_plan(operation_id, source_method, input_options=None): + input_options = input_options or {} + for method_override in METHOD_OVERRIDES: + if method_override['operationId'] != operation_id: + continue + + input_flag = method_override['inputFlag'] + option_name = METHOD_OVERRIDE_INPUT_OPTION_NAMES[input_flag] + if not input_options.get(option_name, False): + continue + + if source_method != method_override['sourceMethod']: + raise ValueError( + 'tus: method override expected {} for {}, got {}'.format( + method_override['sourceMethod'], + operation_id, + source_method, + ) + ) + + return { + 'headers': { + method_override['headerName']: method_override['headerValue'], + }, + 'method': method_override['method'], + } + + return { + 'headers': {}, + 'method': source_method, + } + + def add_operation_request_headers(headers, operation_headers): headers.update(DEFAULT_REQUEST_HEADERS) if operation_headers: diff --git a/tusclient/request.py b/tusclient/request.py index df5a795..a840761 100644 --- a/tusclient/request.py +++ b/tusclient/request.py @@ -8,6 +8,11 @@ import ssl from tusclient.exceptions import TusUploadFailed, TusCommunicationError +from tusclient.protocol_generated import ( + UPLOAD_CHUNK_METHOD, + UPLOAD_CHUNK_OPERATION_ID, + request_method_plan, +) # Catches requests exceptions and throws custom tuspy errors. @@ -73,6 +78,13 @@ def add_checksum(self, headers, chunk: bytes): ) ) + def request_method_plan(self): + return request_method_plan( + UPLOAD_CHUNK_OPERATION_ID, + UPLOAD_CHUNK_METHOD, + self.uploader.request_method_input_options(), + ) + class TusRequest(BaseTusRequest): """Class to handle async Tus upload requests""" @@ -88,15 +100,18 @@ def perform(self): self.add_checksum(operation_headers, chunk) if stream_eof and self._upload_length_deferred: operation_headers["upload-length"] = str(self._offset + len(chunk)) + method_plan = self.request_method_plan() + operation_headers.update(method_plan["headers"]) headers = self.uploader.prepare_request_headers(operation_headers) - context = self.uploader.run_before_request("PATCH", self._url, headers) - resp = requests.patch( + context = self.uploader.run_before_request(method_plan["method"], self._url, headers) + resp = requests.request( + method_plan["method"], self._url, data=chunk, headers=context.headers, verify=self.verify_tls_cert, stream=True, - cert=self.client_cert + cert=self.client_cert, ) self.uploader.run_after_response(context, resp) self.status_code = resp.status_code @@ -125,18 +140,28 @@ async def perform(self): self.add_checksum(operation_headers, chunk) try: ssl_ctx = ssl.create_default_context() - if (self.client_cert is not None): + if self.client_cert is not None: if self.client_cert is str: ssl_ctx.load_cert_chain(certfile=self.client_cert) else: - ssl_ctx.load_cert_chain(certfile=self.client_cert[0], keyfile=self.client_cert[1]) + ssl_ctx.load_cert_chain( + certfile=self.client_cert[0], keyfile=self.client_cert[1] + ) conn = aiohttp.TCPConnector(ssl=ssl_ctx) async with aiohttp.ClientSession(loop=self.io_loop, connector=conn) as session: verify_tls_cert = None if self.verify_tls_cert else False + method_plan = self.request_method_plan() + operation_headers.update(method_plan["headers"]) headers = self.uploader.prepare_request_headers(operation_headers) - context = self.uploader.run_before_request("PATCH", self._url, headers) - async with session.patch( - self._url, data=chunk, headers=context.headers, ssl=verify_tls_cert + context = self.uploader.run_before_request( + method_plan["method"], self._url, headers + ) + async with session.request( + method_plan["method"], + self._url, + data=chunk, + headers=context.headers, + ssl=verify_tls_cert, ) as resp: self.uploader.run_after_response(context, resp) self.status_code = resp.status diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index df13e63..40e9bb7 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -133,6 +133,7 @@ def __init__( upload_length_deferred=False, upload_size: Optional[int] = None, upload_data_during_creation=False, + override_patch_method=False, parallel_uploads: Optional[int] = None, parallel_upload_boundaries=None, protocol: Optional[str] = None, @@ -191,6 +192,7 @@ def __init__( self.upload_size = upload_size self.parallel_uploads = parallel_uploads self.parallel_upload_boundaries = parallel_upload_boundaries + self.override_patch_method = override_patch_method self.protocol = protocol self.retry_delays = retry_delays self.on_progress = on_progress @@ -212,6 +214,11 @@ def prepare_request_headers(self, operation_headers=None): add_request_id = getattr(self.client, "add_request_id", False) return prepare_request_headers(operation_headers, client_headers, add_request_id) + def request_method_input_options(self): + return { + "override_patch_method": self.override_patch_method, + } + def run_before_request(self, method, url, headers): context = TusRequestContext(method, url, headers) hooks = getattr(self.client, "request_hooks", None) From 20f0a90c97de05ccc42e91b73060f2da4cfddff9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 22:58:50 +0200 Subject: [PATCH 85/95] Prove Python file URL storage --- .../api2-devdock-tus-resume-upload/main.py | 93 ++++++++++++++----- tests/test_filestorage.py | 7 ++ tusclient/protocol_generated.py | 26 ++++++ tusclient/storage/filestorage.py | 44 ++++++++- 4 files changed, 140 insertions(+), 30 deletions(-) diff --git a/examples/api2-devdock-tus-resume-upload/main.py b/examples/api2-devdock-tus-resume-upload/main.py index 7c14b1c..a48d659 100644 --- a/examples/api2-devdock-tus-resume-upload/main.py +++ b/examples/api2-devdock-tus-resume-upload/main.py @@ -1,11 +1,15 @@ """Resume a Transloadit devdock TUS upload using tus-py-client.""" import sys +from contextlib import contextmanager from io import BytesIO +from os import remove from pathlib import Path +from tempfile import NamedTemporaryFile from tusclient import client as tus from tusclient.fingerprint.interface import Fingerprint +from tusclient.storage import filestorage from tusclient.storage.interface import Storage sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -36,6 +40,9 @@ def remove_item(self, key): def count(self): return len(self.urls) + def keys(self): + return list(self.urls.keys()) + def uploader_for(scenario, create_response, content, storage): upload_config = scenario["upload"] @@ -90,41 +97,77 @@ def resume_stored_upload(scenario, create_response, content, storage): return uploader.url +@contextmanager +def url_storage_for(upload_config): + backend = upload_config.get("urlStorageBackend") + if backend is None: + yield MemoryStorage() + return + + if backend["kind"] != "file": + fail("unsupported URL storage backend {!r}".format(backend["kind"])) + + temp_fp = NamedTemporaryFile(delete=False) + temp_fp.close() + storage = filestorage.FileStorage(temp_fp.name) + try: + yield storage + finally: + storage.close() + remove(temp_fp.name) + + def upload_with_stored_resume(scenario, create_response): upload_config = scenario["upload"] resume = upload_config["resume"] content = scenario_bytes(upload_config) - storage = MemoryStorage() - first_upload_url = upload_first_chunk_and_pause(scenario, create_response, content, storage) - previous_upload_count = storage.count() - if previous_upload_count != resume["expectedPreviousUploadCount"]: - fail( - "stored upload count {}, expected {}".format( - previous_upload_count, - resume["expectedPreviousUploadCount"], + with url_storage_for(upload_config) as storage: + first_upload_url = upload_first_chunk_and_pause(scenario, create_response, content, storage) + previous_upload_count = storage.count() + storage_keys_after_first_upload = storage.keys() + if previous_upload_count != resume["expectedPreviousUploadCount"]: + fail( + "stored upload count {}, expected {}".format( + previous_upload_count, + resume["expectedPreviousUploadCount"], + ) ) - ) - upload_url = resume_stored_upload(scenario, create_response, content, storage) - if upload_url != first_upload_url: - fail("resumed upload URL {}, expected {}".format(upload_url, first_upload_url)) + upload_url = resume_stored_upload(scenario, create_response, content, storage) + if upload_url != first_upload_url: + fail("resumed upload URL {}, expected {}".format(upload_url, first_upload_url)) + + remaining_previous_upload_count = storage.count() + if remaining_previous_upload_count != resume["expectedRemainingPreviousUploadCount"]: + fail( + "remaining stored upload count {}, expected {}".format( + remaining_previous_upload_count, + resume["expectedRemainingPreviousUploadCount"], + ) + ) - remaining_previous_upload_count = storage.count() - if remaining_previous_upload_count != resume["expectedRemainingPreviousUploadCount"]: - fail( - "remaining stored upload count {}, expected {}".format( - remaining_previous_upload_count, - resume["expectedRemainingPreviousUploadCount"], + result = { + "firstUploadUrl": first_upload_url, + "previousUploadCount": previous_upload_count, + "remainingPreviousUploadCount": remaining_previous_upload_count, + "uploadUrl": upload_url, + } + + backend = upload_config.get("urlStorageBackend") + if backend is not None: + expected_key_prefix = backend["expectedStoredUploadKeyPrefix"] + result.update( + { + "storageFileEntryCount": storage.count(), + "storedUploadKeyPrefixMatched": any( + key.startswith(expected_key_prefix) for key in storage_keys_after_first_upload + ), + "urlStorageBackend": backend["kind"], + } ) - ) - return { - "firstUploadUrl": first_upload_url, - "previousUploadCount": previous_upload_count, - "remainingPreviousUploadCount": remaining_previous_upload_count, - "uploadUrl": upload_url, - } + return result def main(): diff --git a/tests/test_filestorage.py b/tests/test_filestorage.py index b77ffed..94c91a8 100644 --- a/tests/test_filestorage.py +++ b/tests/test_filestorage.py @@ -1,6 +1,7 @@ import unittest import os +from tusclient.protocol_generated import url_storage_fingerprint_prefix from tusclient.storage import filestorage @@ -24,6 +25,12 @@ def test_set_get_remove_item(self): self.assertEqual(self.storage.get_item(key), url) self.assertEqual(self.storage.get_item(key_2), url_2) + self.assertEqual(self.storage.count(), 2) + self.assertTrue(self.storage.keys()[0].startswith(url_storage_fingerprint_prefix(key))) + self.assertTrue(self.storage.keys()[1].startswith(url_storage_fingerprint_prefix(key_2))) + + self.storage.set_item(key, url) + self.assertEqual(self.storage.count(), 2) self.storage.remove_item(key) self.assertIsNone(self.storage.get_item(key)) diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index e202691..f2a8650 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -307,6 +307,10 @@ 'ietf-draft-03', 'ietf-draft-05', ] +URL_STORAGE_ID_MULTIPLIER = 1000000000000 +URL_STORAGE_ID_STRATEGY = 'rounded-random-number' +URL_STORAGE_NAMESPACE = 'tus' +URL_STORAGE_SEPARATOR = '::' UPLOAD_BODY_CONTENT_TYPE = 'application/offset+octet-stream' UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME = 'Content-Type' UPLOAD_CHUNK_METHOD = 'PATCH' @@ -364,6 +368,28 @@ def request_method_plan(operation_id, source_method, input_options=None): } +def url_storage_all_uploads_prefix(): + return '{}{}'.format(URL_STORAGE_NAMESPACE, URL_STORAGE_SEPARATOR) + + +def url_storage_fingerprint_prefix(fingerprint): + return '{}{}{}'.format( + url_storage_all_uploads_prefix(), + fingerprint, + URL_STORAGE_SEPARATOR, + ) + + +def url_storage_id(random_value): + if URL_STORAGE_ID_STRATEGY != 'rounded-random-number': + raise ValueError('tus: unsupported URL storage id strategy {}'.format(URL_STORAGE_ID_STRATEGY)) + return round(random_value * URL_STORAGE_ID_MULTIPLIER) + + +def url_storage_key(fingerprint, upload_id): + return '{}{}'.format(url_storage_fingerprint_prefix(fingerprint), upload_id) + + def add_operation_request_headers(headers, operation_headers): headers.update(DEFAULT_REQUEST_HEADERS) if operation_headers: diff --git a/tusclient/storage/filestorage.py b/tusclient/storage/filestorage.py index 05a6fb5..b7e50a7 100644 --- a/tusclient/storage/filestorage.py +++ b/tusclient/storage/filestorage.py @@ -1,8 +1,16 @@ """ An implementation of , using a file as storage. """ +from random import random + from tinydb import TinyDB, Query +from tusclient.protocol_generated import ( + url_storage_fingerprint_prefix, + url_storage_id, + url_storage_key, +) + from . import interface @@ -19,7 +27,7 @@ def get_item(self, key: str): - key[str]: The unique id for the stored item (in this case, url) :Returns: url[str] """ - result = self._db.search(self._urls.key == key) + result = self._records_for_item(key) return result[0].get("url") if result else None def set_item(self, key: str, url: str): @@ -30,19 +38,45 @@ def set_item(self, key: str, url: str): - key[str]: The unique id to which the item (in this case, url) would be stored. - value[str]: The actual url value to be stored. """ - if self._db.search(self._urls.key == key): - self._db.update({"url": url}, self._urls.key == key) + result = self._records_for_item(key) + if result: + self._db.update({"url": url}, self._urls.key == result[0].get("key")) else: - self._db.insert({"key": key, "url": url}) + self._db.insert({"key": self._new_storage_key(key), "url": url}) def remove_item(self, key: str): """ Remove/Delete the url value under the unique key from storage. """ - self._db.remove(self._urls.key == key) + for stored_key in self._keys_for_item(key): + self._db.remove(self._urls.key == stored_key) def close(self): """ Close the file storage and release all opened files. """ self._db.close() + + def count(self): + return len(self._db.all()) + + def keys(self): + return [record.get("key") for record in self._db.all()] + + def _records_for_item(self, key: str): + exact = self._db.search(self._urls.key == key) + if exact: + return exact + + prefix = url_storage_fingerprint_prefix(key) + return self._db.search( + self._urls.key.test( + lambda stored_key: isinstance(stored_key, str) and stored_key.startswith(prefix) + ) + ) + + def _keys_for_item(self, key: str): + return [record.get("key") for record in self._records_for_item(key)] + + def _new_storage_key(self, key: str): + return url_storage_key(key, url_storage_id(random())) From 03e1f06be6651e6c447577499aa997e83d3aedc1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 23:15:43 +0200 Subject: [PATCH 86/95] Prove Python protocol selection --- .../main.py | 84 +++++++++++++++++++ tests/test_client.py | 43 ++++++++++ tusclient/protocol_generated.py | 77 ++++++++++++++++- tusclient/request.py | 22 ++++- tusclient/uploader/baseuploader.py | 9 +- tusclient/uploader/uploader.py | 10 ++- 6 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 examples/api2-devdock-tus-protocol-version-selection/main.py diff --git a/examples/api2-devdock-tus-protocol-version-selection/main.py b/examples/api2-devdock-tus-protocol-version-selection/main.py new file mode 100644 index 0000000..d15c5c7 --- /dev/null +++ b/examples/api2-devdock-tus-protocol-version-selection/main.py @@ -0,0 +1,84 @@ +"""Select a generated TUS protocol mode for a conformance upload.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + TusConformancePlanServer, + bool_value, + conformance_input_options, + conformance_input_source_bytes, + fail, + load_scenario, + object_value, + scenario_id, + string_value, + write_result, +) + + +def upload_with_protocol_version_selection(conformance_scenario): + input_options = conformance_input_options(conformance_scenario) + content = conformance_input_source_bytes(conformance_scenario) + endpoint_url = string_value(input_options["endpointUrl"], "endpointUrl") + metadata = object_value(input_options["metadata"], "metadata") + protocol = string_value(input_options["protocol"], "protocol") + upload_data_during_creation = bool_value( + input_options["uploadDataDuringCreation"], + "uploadDataDuringCreation", + ) + if not upload_data_during_creation: + fail("Python protocol-version proof expects creation with upload") + + completion = object_value(conformance_scenario["completion"], "completion") + + with TusConformancePlanServer(conformance_scenario, endpoint_url) as conformance_server: + uploader = tus.TusClient(conformance_server.endpoint_url()).create_upload_with_data( + len(content), + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=metadata, + protocol=protocol, + ) + + if not uploader.url: + fail("protocol-version scenario did not expose an upload URL") + if uploader.offset != len(content): + fail( + "protocol-version upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + + conformance_server.assert_exhausted() + result = conformance_server.result() + result["completionKind"] = string_value(completion["kind"], "completion.kind") + result["errorCalled"] = False + result["successCalled"] = True + result["uploadUrl"] = conformance_server.canonical_url(uploader.url) + return result + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + conformance_scenario = object_value( + scenario["conformanceScenario"], + "conformanceScenario", + ) + result = upload_with_protocol_version_selection(conformance_scenario) + write_result(result) + print( + "Python TUS SDK devdock scenario {} selected protocol for {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_client.py b/tests/test_client.py index e785815..27d6a5e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,10 +10,12 @@ CREATE_UPLOAD_METHOD, LOCATION_HEADER_NAME, TERMINATE_UPLOAD_METHOD, + TUS_PROTOCOL_REQUEST_HEADERS, UPLOAD_BODY_CONTENT_TYPE, UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, UPLOAD_LENGTH_HEADER_NAME, UPLOAD_OFFSET_HEADER_NAME, + upload_body_headers, ) from tusclient.request_lifecycle import RequestLifecycleHooks from tusclient.uploader import Uploader, AsyncUploader @@ -153,6 +155,47 @@ def validate_create_request(request): ], ) + @responses.activate + def test_create_upload_with_data_uses_selected_protocol_headers(self): + upload_url = 'http://tusd.tusdemo.net/files/ietf-draft-05' + protocol = 'ietf-draft-05' + protocol_request_headers = TUS_PROTOCOL_REQUEST_HEADERS[protocol] + body_headers = upload_body_headers(protocol, done=True) + + def validate_create_request(request): + self.assertEqual(request.body, b'hello') + self.assertNotIn('Tus-Resumable', request.headers) + for header_name, header_value in protocol_request_headers.items(): + self.assertEqual(request.headers[header_name], header_value) + for header_name, header_value in body_headers.items(): + self.assertEqual(request.headers[header_name], header_value) + + return ( + 201, + { + LOCATION_HEADER_NAME: upload_url, + UPLOAD_OFFSET_HEADER_NAME: '5', + }, + '', + ) + + responses.add_callback( + CREATE_UPLOAD_METHOD, + self.client.url, + callback=validate_create_request, + ) + + uploader = self.client.create_upload_with_data( + 5, + file_stream=BytesIO(b'hello'), + chunk_size=5, + metadata={}, + protocol=protocol, + ) + + self.assertEqual(uploader.url, upload_url) + self.assertEqual(uploader.offset, 5) + @responses.activate def test_create_upload_with_data_non_success_status(self): responses.add(CREATE_UPLOAD_METHOD, self.client.url, status=400, body='bad') diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index f2a8650..89b8687 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -3,6 +3,7 @@ # the source fix belongs in the protocol contract generator so all TUS clients stay in sync. CREATE_UPLOAD_METHOD = 'POST' +DEFAULT_CLIENT_PROTOCOL = 'tus-v1' DEFAULT_PROTOCOL_VERSION = '1.0.0' DEFAULT_REQUEST_HEADERS = { 'Tus-Resumable': '1.0.0', @@ -307,6 +308,33 @@ 'ietf-draft-03', 'ietf-draft-05', ] +TUS_PROTOCOL_REQUEST_HEADERS = { + 'tus-v1': { + 'Tus-Resumable': '1.0.0', + }, + 'ietf-draft-03': { + 'Upload-Draft-Interop-Version': '5', + }, + 'ietf-draft-05': { + 'Upload-Draft-Interop-Version': '6', + }, +} +TUS_PROTOCOL_UPLOAD_BODY_CONTENT_TYPES = { + 'ietf-draft-05': 'application/partial-upload', + 'tus-v1': 'application/offset+octet-stream', +} +TUS_PROTOCOL_UPLOAD_COMPLETE_HEADERS = { + 'ietf-draft-03': { + 'completeValue': '?1', + 'incompleteValue': '?0', + 'name': 'Upload-Complete', + }, + 'ietf-draft-05': { + 'completeValue': '?1', + 'incompleteValue': '?0', + 'name': 'Upload-Complete', + }, +} URL_STORAGE_ID_MULTIPLIER = 1000000000000 URL_STORAGE_ID_STRATEGY = 'rounded-random-number' URL_STORAGE_NAMESPACE = 'tus' @@ -327,9 +355,50 @@ def is_successful_response_status(response_status_code): ) -def prepare_request_headers(operation_headers=None, custom_headers=None, add_request_id=False): +def normalize_client_protocol(protocol=None): + if protocol is None or protocol == DEFAULT_PROTOCOL_VERSION: + return DEFAULT_CLIENT_PROTOCOL + return protocol + + +def protocol_request_headers(protocol=None): + normalized_protocol = normalize_client_protocol(protocol) + headers = TUS_PROTOCOL_REQUEST_HEADERS.get(normalized_protocol) + if headers is None: + raise ValueError('tus: unsupported protocol {}'.format(protocol)) + return dict(headers) + + +def protocol_upload_body_content_type(protocol=None): + return TUS_PROTOCOL_UPLOAD_BODY_CONTENT_TYPES.get(normalize_client_protocol(protocol)) + + +def upload_body_headers(protocol=None, done=None): + headers = {} + content_type = protocol_upload_body_content_type(protocol) + if content_type: + headers[UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME] = content_type + if done is not None: + upload_complete_header = TUS_PROTOCOL_UPLOAD_COMPLETE_HEADERS.get( + normalize_client_protocol(protocol) + ) + if upload_complete_header: + headers[upload_complete_header['name']] = ( + upload_complete_header['completeValue'] + if done + else upload_complete_header['incompleteValue'] + ) + return headers + + +def prepare_request_headers( + operation_headers=None, + custom_headers=None, + add_request_id=False, + protocol=None, +): headers = {} - add_operation_request_headers(headers, operation_headers) + add_operation_request_headers(headers, operation_headers, protocol) add_custom_request_headers(headers, custom_headers) add_request_id_header(headers, add_request_id) return headers @@ -390,8 +459,8 @@ def url_storage_key(fingerprint, upload_id): return '{}{}'.format(url_storage_fingerprint_prefix(fingerprint), upload_id) -def add_operation_request_headers(headers, operation_headers): - headers.update(DEFAULT_REQUEST_HEADERS) +def add_operation_request_headers(headers, operation_headers, protocol=None): + headers.update(protocol_request_headers(protocol)) if operation_headers: headers.update(operation_headers) diff --git a/tusclient/request.py b/tusclient/request.py index a840761..76cdbac 100644 --- a/tusclient/request.py +++ b/tusclient/request.py @@ -12,6 +12,7 @@ UPLOAD_CHUNK_METHOD, UPLOAD_CHUNK_OPERATION_ID, request_method_plan, + upload_body_headers, ) @@ -58,7 +59,6 @@ def __init__(self, uploader): self._operation_headers = { "upload-offset": str(uploader.offset), - "Content-Type": "application/offset+octet-stream", } self._offset = uploader.offset self._upload_length_deferred = uploader.upload_length_deferred @@ -85,6 +85,11 @@ def request_method_plan(self): self.uploader.request_method_input_options(), ) + def _is_final_chunk(self, stream_eof, chunk_size): + if self._upload_length_deferred: + return stream_eof + return self._offset + chunk_size >= self.uploader.file_size + class TusRequest(BaseTusRequest): """Class to handle async Tus upload requests""" @@ -98,6 +103,12 @@ def perform(self): stream_eof = len(chunk) < self._content_length operation_headers = dict(self._operation_headers) self.add_checksum(operation_headers, chunk) + operation_headers.update( + upload_body_headers( + self.uploader.protocol, + done=self._is_final_chunk(stream_eof, len(chunk)), + ) + ) if stream_eof and self._upload_length_deferred: operation_headers["upload-length"] = str(self._offset + len(chunk)) method_plan = self.request_method_plan() @@ -121,7 +132,6 @@ def perform(self): except requests.exceptions.RequestException as error: raise TusUploadFailed(error) - class AsyncTusRequest(BaseTusRequest): """Class to handle async Tus upload requests""" @@ -136,8 +146,15 @@ async def perform(self): Perform actual request. """ chunk = self.file.read(self._content_length) + stream_eof = len(chunk) < self._content_length operation_headers = dict(self._operation_headers) self.add_checksum(operation_headers, chunk) + operation_headers.update( + upload_body_headers( + self.uploader.protocol, + done=self._is_final_chunk(stream_eof, len(chunk)), + ) + ) try: ssl_ctx = ssl.create_default_context() if self.client_cert is not None: @@ -169,5 +186,6 @@ async def perform(self): k.lower(): v for k, v in resp.headers.items() } self.response_content = await resp.content.read() + self.stream_eof = stream_eof except aiohttp.ClientError as error: raise TusUploadFailed(error) diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index 40e9bb7..c692ebf 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -178,6 +178,7 @@ def __init__( self.remove_fingerprint_on_success = remove_fingerprint_on_success self.url_storage = url_storage self.fingerprinter = fingerprinter or fingerprint.Fingerprint() + self.protocol = protocol self.offset = 0 self.url = None self.__init_url_and_offset(url) @@ -193,7 +194,6 @@ def __init__( self.parallel_uploads = parallel_uploads self.parallel_upload_boundaries = parallel_upload_boundaries self.override_patch_method = override_patch_method - self.protocol = protocol self.retry_delays = retry_delays self.on_progress = on_progress self.on_chunk_complete = on_chunk_complete @@ -212,7 +212,12 @@ def get_headers(self): def prepare_request_headers(self, operation_headers=None): client_headers = getattr(self.client, "headers", {}) add_request_id = getattr(self.client, "add_request_id", False) - return prepare_request_headers(operation_headers, client_headers, add_request_id) + return prepare_request_headers( + operation_headers, + client_headers, + add_request_id, + self.protocol, + ) def request_method_input_options(self): return { diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index 97c31c5..826c7a8 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -17,10 +17,9 @@ from tusclient.protocol_generated import ( CREATE_UPLOAD_METHOD, LOCATION_HEADER_NAME, - UPLOAD_BODY_CONTENT_TYPE, - UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, UPLOAD_OFFSET_HEADER_NAME, is_successful_response_status, + upload_body_headers, ) from tusclient.request import TusRequest, AsyncTusRequest, catch_requests_error @@ -63,7 +62,12 @@ def create_url_with_upload(self, bytes_to_upload: int): ) headers = self.get_url_creation_headers() - headers[UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME] = UPLOAD_BODY_CONTENT_TYPE + headers.update( + upload_body_headers( + self.protocol, + done=bytes_to_upload == self.get_file_size(), + ) + ) context = self.run_before_request(CREATE_UPLOAD_METHOD, self.client.url, headers) try: resp = requests.request( From b7af40fc2c91d0fd160a5449676f97ece1cfab9a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 23:42:57 +0200 Subject: [PATCH 87/95] Prove Python TUS abort upload --- .../api2-devdock-tus-abort-upload/main.py | 155 ++++++++++++++++++ examples/api2devdock.py | 21 ++- tests/test_client.py | 43 +++++ tests/test_request.py | 14 ++ tusclient/client.py | 36 ++++ tusclient/exceptions.py | 9 + tusclient/protocol_generated.py | 13 ++ tusclient/request.py | 58 ++++--- tusclient/uploader/baseuploader.py | 27 ++- tusclient/uploader/uploader.py | 45 +++-- 10 files changed, 377 insertions(+), 44 deletions(-) create mode 100644 examples/api2-devdock-tus-abort-upload/main.py diff --git a/examples/api2-devdock-tus-abort-upload/main.py b/examples/api2-devdock-tus-abort-upload/main.py new file mode 100644 index 0000000..ea40d24 --- /dev/null +++ b/examples/api2-devdock-tus-abort-upload/main.py @@ -0,0 +1,155 @@ +"""Abort a TUS upload against the API2 devdock conformance server.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus +from tusclient.exceptions import TusUploadAborted +from tusclient.fingerprint.interface import Fingerprint +from tusclient.storage.interface import Storage + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + TusConformancePlanServer, + bool_value, + conformance_input_options, + conformance_input_source_bytes, + fail, + load_scenario, + object_value, + scenario_id, + string_value, + write_result, +) + + +class MemoryStorage(Storage): + def __init__(self): + self.values = {} + + def get_item(self, key): + return self.values.get(key) + + def set_item(self, key, value): + self.values[key] = value + + def remove_item(self, key): + self.values.pop(key, None) + + +class FixedFingerprint(Fingerprint): + def __init__(self, fingerprint): + self.fingerprint = fingerprint + + def get_fingerprint(self, fs): + return self.fingerprint + + +def upload_and_abort(conformance_scenario): + input_options = conformance_input_options(conformance_scenario) + content = conformance_input_source_bytes(conformance_scenario) + endpoint_url = string_value(input_options["endpointUrl"], "endpointUrl") + headers = object_value(input_options.get("headers", {}), "headers") + metadata = object_value(input_options["metadata"], "metadata") + override_patch_method = bool_value( + input_options.get("overridePatchMethod", False), + "overridePatchMethod", + ) + runtime_setup = object_value( + conformance_scenario["runtimeSetup"], + "conformanceScenario.runtimeSetup", + ) + abort_setup = object_value( + runtime_setup["abort"], + "conformanceScenario.runtimeSetup.abort", + ) + terminate_upload_on_abort = bool_value( + abort_setup["terminateUpload"], + "conformanceScenario.runtimeSetup.abort.terminateUpload", + ) + + client_ref = {} + uploader_ref = {} + + def on_abort_request(event): + active_client = client_ref.get("client") + active_uploader = uploader_ref.get("uploader") + if active_client is None: + fail("abort request observed before client was initialized") + active_client.abort_upload(active_uploader, False) + + with TusConformancePlanServer( + conformance_scenario, + endpoint_url, + on_abort_request=on_abort_request, + ) as conformance_server: + client = tus.TusClient(conformance_server.endpoint_url(), headers=headers) + client_ref["client"] = client + + storage = None + fingerprint = input_options.get("fingerprint") + uploader_options = {} + if fingerprint is not None: + storage = MemoryStorage() + uploader_options.update( + { + "fingerprinter": FixedFingerprint( + string_value(fingerprint, "fingerprint"), + ), + "store_url": True, + "url_storage": storage, + } + ) + + uploader = client.uploader( + file_stream=BytesIO(content), + chunk_size=len(content), + metadata=metadata, + override_patch_method=override_patch_method, + **uploader_options, + ) + uploader_ref["uploader"] = uploader + + try: + uploader.upload() + except TusUploadAborted: + pass + else: + fail("abort scenario completed without TusUploadAborted") + + if terminate_upload_on_abort: + if not uploader.url: + fail("abort scenario requested termination before upload URL was known") + client.abort_upload(uploader, True) + + conformance_server.assert_exhausted() + result = conformance_server.result() + result["completionKind"] = "aborted" + result["errorCalled"] = False + result["successCalled"] = False + result["uploadUrl"] = ( + conformance_server.canonical_url(uploader.url) if uploader.url else None + ) + if storage is not None and fingerprint is not None: + result["storedUrlAfterAbort"] = storage.get_item(fingerprint) + return result + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + conformance_scenario = object_value( + scenario["conformanceScenario"], + "conformanceScenario", + ) + result = upload_and_abort(conformance_scenario) + write_result(result) + print( + "Python TUS SDK devdock scenario {} aborted the upload".format( + scenario_id(scenario), + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index df4af0a..1e4f773 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -445,17 +445,19 @@ def scalar_string(value): class TusConformancePlanServer: - def __init__(self, conformance_scenario, endpoint_origin): + def __init__(self, conformance_scenario, endpoint_origin, on_abort_request=None): self.endpoint_origin = urlparse(string_value(endpoint_origin, "endpointOrigin")) if not self.endpoint_origin.scheme or not self.endpoint_origin.netloc: fail("endpointOrigin must be an absolute URL") + self.on_abort_request = on_abort_request self.input_source_content = conformance_input_source_bytes(conformance_scenario) requests = conformance_scenario["requests"] if not isinstance(requests, list): fail("conformanceScenario.requests must be a list") self.requests = requests self.errors = [] + self.events = [] self.observed = [None] * len(requests) self.observed_count = 0 self.next_request_index = 0 @@ -550,6 +552,7 @@ def result(self): "absentHeaderPresence": [ request["absentHeaderPresence"] for request in observed ], + "events": self.events, "requestBodySizes": [request["bodySize"] for request in observed], "requestBodyStarts": [request["bodyStart"] for request in observed], "requestCount": self.observed_count, @@ -582,6 +585,10 @@ def _handle_conformance_request(self): body = self.rfile.read(content_length) if content_length > 0 else b"" try: request_plan = conformance_server.observe_request(self, body) + if request_plan.get("abort", False): + conformance_server.abort_request(conformance_server.observed_count - 1) + self.close_connection = True + return conformance_server.write_response(self, request_plan) except Exception as error: conformance_server.errors.append(str(error)) @@ -634,6 +641,18 @@ def observe_request(self, handler, body): self.next_request_index += 1 return request_plan + def abort_request(self, request_index): + observed = self.observed[request_index] + event = { + "kind": "request-abort", + "method": observed["method"], + "requestIndex": request_index, + "url": observed["url"], + } + self.events.append(event) + if self.on_abort_request is not None: + self.on_abort_request(event) + def assert_request_matches_plan(self, request_index, request_plan, method, actual_url, body): expected_method = string_value( request_plan["effectiveMethod"], diff --git a/tests/test_client.py b/tests/test_client.py index 27d6a5e..f0abf46 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,6 +5,7 @@ from tusclient import client from tusclient.exceptions import TusCommunicationError +from tusclient.fingerprint.interface import Fingerprint from tusclient.protocol_generated import ( DEFAULT_REQUEST_HEADERS, CREATE_UPLOAD_METHOD, @@ -18,9 +19,32 @@ upload_body_headers, ) from tusclient.request_lifecycle import RequestLifecycleHooks +from tusclient.storage.interface import Storage from tusclient.uploader import Uploader, AsyncUploader +class MemoryStorage(Storage): + def __init__(self): + self.values = {} + + def get_item(self, key): + return self.values.get(key) + + def set_item(self, key, value): + self.values[key] = value + + def remove_item(self, key): + self.values.pop(key, None) + + +class FixedFingerprint(Fingerprint): + def __init__(self, value): + self.value = value + + def get_fingerprint(self, fs): + return self.value + + class TusClientTest(unittest.TestCase): def setUp(self): self.client = client.TusClient('http://tusd.tusdemo.net/files/', @@ -93,6 +117,25 @@ def test_terminate_upload_non_success_status(self): self.assertEqual(context.exception.status_code, 404) self.assertEqual(context.exception.response_content, b'gone') + @responses.activate + def test_abort_upload_terminates_and_removes_stored_url(self): + upload_url = 'http://tusd.tusdemo.net/files/abort' + storage = MemoryStorage() + uploader = self.client.uploader( + file_stream=BytesIO(b'hello'), + fingerprinter=FixedFingerprint('abort-fingerprint'), + store_url=True, + url_storage=storage, + ) + uploader.set_url(upload_url) + responses.add(TERMINATE_UPLOAD_METHOD, upload_url, status=204) + + response = self.client.abort_upload(uploader, terminate_upload=True) + + self.assertEqual(response.status_code, 204) + self.assertTrue(uploader.is_aborted()) + self.assertIsNone(storage.get_item('abort-fingerprint')) + @responses.activate def test_create_upload_with_data(self): upload_url = 'http://tusd.tusdemo.net/files/creation-with-upload' diff --git a/tests/test_request.py b/tests/test_request.py index a40952b..3372b0b 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -3,7 +3,9 @@ from parametrize import parametrize import responses +import requests +from tusclient.exceptions import TusUploadAborted from tusclient import request from tusclient.request_lifecycle import RequestLifecycleHooks from tests import mixin @@ -102,3 +104,15 @@ def validate_verify(req): resps.add_callback(responses.PATCH, self.url, callback=validate_verify) tus_request.perform() self.assertEqual(verify, False) + + def test_perform_maps_aborted_transport_error(self): + tus_request = request.TusRequest(self.uploader) + self.uploader.abort() + + def disconnect(req): + raise requests.exceptions.ConnectionError("connection closed") + + with responses.RequestsMock() as resps: + resps.add_callback(responses.PATCH, self.url, callback=disconnect) + with self.assertRaises(TusUploadAborted): + tus_request.perform() diff --git a/tusclient/client.py b/tusclient/client.py index f537d47..6466040 100644 --- a/tusclient/client.py +++ b/tusclient/client.py @@ -4,6 +4,7 @@ from tusclient.exceptions import TusCommunicationError from tusclient.protocol_generated import ( + ABORT_REMOVE_STORED_URL_AFTER_TERMINATION, TERMINATE_UPLOAD_METHOD, is_successful_response_status, prepare_request_headers, @@ -50,6 +51,8 @@ def __init__( self.client_cert = client_cert self.request_hooks = request_hooks self.add_request_id = add_request_id + self._abort_requested = False + self._current_uploader = None def set_headers(self, headers: Dict[str, str]): """ @@ -80,6 +83,39 @@ def enable_request_id_header(self): def disable_request_id_header(self): self.add_request_id = False + def _set_current_uploader(self, uploader): + self._current_uploader = uploader + if self._abort_requested: + self._abort_requested = False + uploader.abort() + + def _clear_current_uploader(self, uploader): + if self._current_uploader is uploader: + self._current_uploader = None + + def abort_upload( + self, + uploader=None, + terminate_upload: bool = False, + verify_tls_cert: bool = True, + ): + active_uploader = uploader or self._current_uploader + if active_uploader is None: + self._abort_requested = True + return None + + active_uploader.abort() + if not terminate_upload or not active_uploader.url: + return None + + response = self.terminate_upload( + active_uploader.url, + verify_tls_cert=verify_tls_cert, + ) + if ABORT_REMOVE_STORED_URL_AFTER_TERMINATION == "after-successful-termination": + active_uploader.remove_stored_url() + return response + def terminate_upload(self, upload_url: str, verify_tls_cert: bool = True): headers = prepare_request_headers(None, self.headers, self.add_request_id) context = TusRequestContext(TERMINATE_UPLOAD_METHOD, upload_url, headers) diff --git a/tusclient/exceptions.py b/tusclient/exceptions.py index 2b3a20d..205fe7a 100644 --- a/tusclient/exceptions.py +++ b/tusclient/exceptions.py @@ -2,6 +2,8 @@ Global Tusclient exception and warning classes. """ +from tusclient.protocol_generated import ABORT_ERROR_MESSAGE + class TusCommunicationError(Exception): """ @@ -63,3 +65,10 @@ def __init__( class TusUploadFailed(TusCommunicationError): """Should be raised when an attempted upload fails""" + + +class TusUploadAborted(TusCommunicationError): + """Should be raised when an upload request is explicitly aborted""" + + def __init__(self, message=ABORT_ERROR_MESSAGE): + super(TusUploadAborted, self).__init__(message) diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index 89b8687..42813b6 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -2,6 +2,19 @@ # If it looks wrong, please report the issue instead of editing this file by hand; # the source fix belongs in the protocol contract generator so all TUS clients stay in sync. +ABORT_ERROR_MESSAGE = 'Request was aborted' +ABORT_ERROR_TYPE = 'DOMException' +ABORT_REMOVE_STORED_URL_AFTER_TERMINATION = 'after-successful-termination' +ABORT_SEQUENCE = [ + 'mark-aborted', + 'abort-parallel-uploads', + 'abort-current-request', + 'clear-retry-timer', + 'terminate-upload-if-requested', +] +ABORT_SUPPRESS_ERROR_AFTER_ABORT = True +ABORT_TERMINATE_UPLOAD = 'when-requested-and-upload-url-known' +ABORT_TERMINATE_UPLOAD_CONTEXT = 'detached-from-aborted-request' CREATE_UPLOAD_METHOD = 'POST' DEFAULT_CLIENT_PROTOCOL = 'tus-v1' DEFAULT_PROTOCOL_VERSION = '1.0.0' diff --git a/tusclient/request.py b/tusclient/request.py index 76cdbac..3e845f3 100644 --- a/tusclient/request.py +++ b/tusclient/request.py @@ -7,7 +7,7 @@ import aiohttp import ssl -from tusclient.exceptions import TusUploadFailed, TusCommunicationError +from tusclient.exceptions import TusUploadAborted, TusUploadFailed, TusCommunicationError from tusclient.protocol_generated import ( UPLOAD_CHUNK_METHOD, UPLOAD_CHUNK_OPERATION_ID, @@ -115,21 +115,26 @@ def perform(self): operation_headers.update(method_plan["headers"]) headers = self.uploader.prepare_request_headers(operation_headers) context = self.uploader.run_before_request(method_plan["method"], self._url, headers) - resp = requests.request( - method_plan["method"], - self._url, - data=chunk, - headers=context.headers, - verify=self.verify_tls_cert, - stream=True, - cert=self.client_cert, - ) + try: + resp = requests.request( + method_plan["method"], + self._url, + data=chunk, + headers=context.headers, + verify=self.verify_tls_cert, + stream=True, + cert=self.client_cert, + ) + finally: + self.uploader.clear_current_request() self.uploader.run_after_response(context, resp) self.status_code = resp.status_code self.response_content = resp.content self.response_headers = {k.lower(): v for k, v in resp.headers.items()} self.stream_eof = stream_eof except requests.exceptions.RequestException as error: + if self.uploader.is_aborted(): + raise TusUploadAborted() raise TusUploadFailed(error) class AsyncTusRequest(BaseTusRequest): @@ -173,19 +178,24 @@ async def perform(self): context = self.uploader.run_before_request( method_plan["method"], self._url, headers ) - async with session.request( - method_plan["method"], - self._url, - data=chunk, - headers=context.headers, - ssl=verify_tls_cert, - ) as resp: - self.uploader.run_after_response(context, resp) - self.status_code = resp.status - self.response_headers = { - k.lower(): v for k, v in resp.headers.items() - } - self.response_content = await resp.content.read() - self.stream_eof = stream_eof + try: + async with session.request( + method_plan["method"], + self._url, + data=chunk, + headers=context.headers, + ssl=verify_tls_cert, + ) as resp: + self.uploader.run_after_response(context, resp) + self.status_code = resp.status + self.response_headers = { + k.lower(): v for k, v in resp.headers.items() + } + self.response_content = await resp.content.read() + self.stream_eof = stream_eof + finally: + self.uploader.clear_current_request() except aiohttp.ClientError as error: + if self.uploader.is_aborted(): + raise TusUploadAborted() raise TusUploadFailed(error) diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index c692ebf..b29ecfb 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -4,6 +4,7 @@ from base64 import b64encode from sys import maxsize as MAXSIZE import hashlib +from threading import Event import requests @@ -197,11 +198,18 @@ def __init__( self.retry_delays = retry_delays self.on_progress = on_progress self.on_chunk_complete = on_chunk_complete + self._abort_requested = Event() ( self.__checksum_algorithm_name, self.__checksum_algorithm, ) = self.CHECKSUM_ALGORITHM_PAIR + def abort(self): + self._abort_requested.set() + + def is_aborted(self): + return self._abort_requested.is_set() + def get_headers(self): """ Return headers of the uploader instance. This would include the headers of the @@ -225,12 +233,18 @@ def request_method_input_options(self): } def run_before_request(self, method, url, headers): + if self.client is not None: + self.client._set_current_uploader(self) context = TusRequestContext(method, url, headers) hooks = getattr(self.client, "request_hooks", None) if hooks is not None and hooks.before_request is not None: hooks.before_request(context) return context + def clear_current_request(self): + if self.client is not None: + self.client._clear_current_uploader(self) + def run_after_response(self, context, response): hooks = getattr(self.client, "request_hooks", None) if hooks is not None and hooks.after_response is not None: @@ -273,9 +287,12 @@ def get_offset(self): """ headers = self.get_headers() context = self.run_before_request("HEAD", self.url, headers) - resp = requests.head( - self.url, headers=context.headers, verify=self.verify_tls_cert, cert=self.client_cert - ) + try: + resp = requests.head( + self.url, headers=context.headers, verify=self.verify_tls_cert, cert=self.client_cert + ) + finally: + self.clear_current_request() self.run_after_response(context, resp) offset = resp.headers.get("upload-offset") if offset is None: @@ -380,6 +397,10 @@ def remove_url_on_success(self): self.url_storage.remove_item(self._get_fingerprint()) + def remove_stored_url(self): + if self.store_url and self.url_storage: + self.url_storage.remove_item(self._get_fingerprint()) + def get_file_stream(self): """ Return a file stream instance of the upload. diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index 826c7a8..c114c50 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -13,7 +13,7 @@ create_upload_request_error, create_upload_response_error, ) -from tusclient.exceptions import TusUploadFailed, TusCommunicationError +from tusclient.exceptions import TusUploadAborted, TusUploadFailed, TusCommunicationError from tusclient.protocol_generated import ( CREATE_UPLOAD_METHOD, LOCATION_HEADER_NAME, @@ -79,7 +79,11 @@ def create_url_with_upload(self, bytes_to_upload: int): cert=self.client_cert, ) except requests.exceptions.RequestException as error: + if self.is_aborted(): + raise TusUploadAborted() raise create_upload_request_error(context, error) + finally: + self.clear_current_request() self.run_after_response(context, resp) if not is_successful_response_status(resp.status_code): @@ -184,7 +188,11 @@ def create_url(self): cert=self.client_cert, ) except requests.exceptions.RequestException as error: + if self.is_aborted(): + raise TusUploadAborted() raise create_upload_request_error(context, error) + finally: + self.clear_current_request() self.run_after_response(context, resp) url = resp.headers.get("location") if not is_successful_response_status(resp.status_code) or url is None: @@ -282,23 +290,28 @@ async def create_url(self): async with aiohttp.ClientSession(connector=conn) as session: headers = self.get_url_creation_headers() context = self.run_before_request("POST", self.client.url, headers) - verify_tls_cert = None if self.verify_tls_cert else False - async with session.post( - self.client.url, headers=context.headers, ssl=verify_tls_cert - ) as resp: - self.run_after_response(context, resp) - url = resp.headers.get("location") - if url is None: - msg = ( - "Attempt to retrieve create file url with status {}".format( - resp.status + try: + verify_tls_cert = None if self.verify_tls_cert else False + async with session.post( + self.client.url, headers=context.headers, ssl=verify_tls_cert + ) as resp: + self.run_after_response(context, resp) + url = resp.headers.get("location") + if url is None: + msg = ( + "Attempt to retrieve create file url with status {}".format( + resp.status + ) + ) + raise TusCommunicationError( + msg, resp.status, await resp.content.read() ) - ) - raise TusCommunicationError( - msg, resp.status, await resp.content.read() - ) - return urljoin(self.client.url, url) + return urljoin(self.client.url, url) + finally: + self.clear_current_request() except aiohttp.ClientError as error: + if self.is_aborted(): + raise TusUploadAborted() raise TusCommunicationError(error) async def _do_request(self): From 7337b6819db2bf9f772f74e50b019b6064288d7d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 00:04:38 +0200 Subject: [PATCH 88/95] Prove Python TUS path input source --- .../main.py | 114 ++++++++++++++++++ examples/api2devdock.py | 27 ++++- 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 examples/api2-devdock-tus-node-path-input-source/main.py diff --git a/examples/api2-devdock-tus-node-path-input-source/main.py b/examples/api2-devdock-tus-node-path-input-source/main.py new file mode 100644 index 0000000..0d7681a --- /dev/null +++ b/examples/api2-devdock-tus-node-path-input-source/main.py @@ -0,0 +1,114 @@ +"""Read a path-backed source as a TUS upload input.""" + +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + TusConformancePlanServer, + conformance_input_options, + conformance_input_source_bytes, + conformance_input_source_kind, + conformance_scenario_wants_event, + fail, + load_scenario, + object_value, + scenario_id, + write_result, +) + + +def append_source_open_event(events, conformance_scenario, input_kind, size): + if not conformance_scenario_wants_event(conformance_scenario, "source-open"): + return events + + return events + [ + { + "inputKind": input_kind, + "kind": "source-open", + "size": size, + } + ] + + +def append_source_close_event(events, conformance_scenario): + if not conformance_scenario_wants_event(conformance_scenario, "source-close"): + return events + + return events + [{"kind": "source-close"}] + + +def append_success_event(events, conformance_scenario): + if not conformance_scenario_wants_event(conformance_scenario, "success"): + return events + + return events + [{"kind": "success"}] + + +def upload_with_node_path_input_source(conformance_scenario): + input_options = conformance_input_options(conformance_scenario) + content = conformance_input_source_bytes(conformance_scenario) + input_kind = conformance_input_source_kind(conformance_scenario) + endpoint_url = input_options["endpointUrl"] + events = [] + + with TemporaryDirectory(prefix="api2-python-tus-node-path-input-source-") as tmp_dir: + input_path = Path(tmp_dir) / "input.txt" + input_path.write_bytes(content) + events = append_source_open_event( + events, + conformance_scenario, + input_kind, + len(content), + ) + + with TusConformancePlanServer(conformance_scenario, endpoint_url) as conformance_server: + uploader = tus.TusClient(conformance_server.endpoint_url()).uploader( + chunk_size=len(content), + file_path=str(input_path), + metadata=object_value(input_options["metadata"], "metadata"), + ) + uploader.upload() + events = append_success_event(events, conformance_scenario) + + if not uploader.url: + fail("node-path TUS upload did not expose an upload URL") + if uploader.offset != len(content): + fail( + "node-path TUS upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + + events = append_source_close_event(events, conformance_scenario) + conformance_server.assert_exhausted() + result = conformance_server.result() + result["events"] = events + result["inputKind"] = input_kind + result["uploadUrl"] = conformance_server.canonical_url(uploader.url) + return result + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + conformance_scenario = object_value( + scenario["conformanceScenario"], + "conformanceScenario", + ) + result = upload_with_node_path_input_source(conformance_scenario) + write_result(result) + print( + "Python TUS SDK devdock scenario {} read {} for {}".format( + scenario_id(scenario), + result["inputKind"], + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index 1e4f773..556a10a 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -122,7 +122,7 @@ def conformance_input_source_bytes(conformance_scenario): "conformanceScenario.inputSource", ) kind = string_value(input_source["kind"], "conformanceScenario.inputSource.kind") - if kind != "blob": + if kind not in ("blob", "node-path-reference"): fail("unsupported conformance input source kind {!r}".format(kind)) return string_value( @@ -131,6 +131,31 @@ def conformance_input_source_bytes(conformance_scenario): ).encode("utf-8") +def conformance_input_source_kind(conformance_scenario): + input_source = object_value( + conformance_scenario["inputSource"], + "conformanceScenario.inputSource", + ) + return string_value(input_source["kind"], "conformanceScenario.inputSource.kind") + + +def conformance_scenario_wants_event(conformance_scenario, event_kind): + events = conformance_scenario.get("events", []) + if not isinstance(events, list): + fail("conformanceScenario.events must be a list") + + for index, event in enumerate(events): + event = object_value(event, "conformanceScenario.events[{}]".format(index)) + kind = string_value( + event["kind"], + "conformanceScenario.events[{}].kind".format(index), + ) + if kind == event_kind: + return True + + return False + + def resolve_value(value_spec, context, label): if "value" in value_spec: return value_spec["value"] From 6d8c8f179b0e6ff881b34aa353a393a203020cb1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 01:46:07 +0200 Subject: [PATCH 89/95] Prove Python TUS retry state transitions --- .../main.py | 152 ++++++++++++++++++ examples/api2devdock.py | 27 ++++ tests/test_uploader.py | 68 ++++++++ tusclient/uploader/baseuploader.py | 22 +++ tusclient/uploader/uploader.py | 56 ++++--- 5 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 examples/api2-devdock-tus-retry-state-transitions/main.py diff --git a/examples/api2-devdock-tus-retry-state-transitions/main.py b/examples/api2-devdock-tus-retry-state-transitions/main.py new file mode 100644 index 0000000..d3d8942 --- /dev/null +++ b/examples/api2-devdock-tus-retry-state-transitions/main.py @@ -0,0 +1,152 @@ +"""Prove TUS retry attempt state resets after recovered progress.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + TusConformancePlanServer, + conformance_input_options, + conformance_input_source_bytes, + conformance_retry_decisions, + fail, + int_array_value, + load_scenario, + object_value, + scenario_id, + string_value, + write_result, +) + + +class RetryStateObserver: + def __init__(self, retry_decisions, retry_delays): + self.retry_decisions = retry_decisions + self.retry_delays = retry_delays + self.events = [] + self.index = 0 + + def on_should_retry(self, error, retry_attempt): + if self.index >= len(self.retry_decisions): + fail( + "retry state scenario observed unexpected retry attempt {}".format( + retry_attempt, + ) + ) + + expected = self.retry_decisions[self.index] + if retry_attempt != expected["retryAttempt"]: + fail( + "retry state scenario expected retry attempt {}, got {}".format( + expected["retryAttempt"], + retry_attempt, + ) + ) + + decision = expected["decision"] + self.events.append( + { + "decision": decision, + "kind": "should-retry", + "retryAttempt": retry_attempt, + } + ) + + if decision: + if retry_attempt >= len(self.retry_delays): + fail( + "retry state scenario has no delay for retry attempt {}".format( + retry_attempt, + ) + ) + self.events.append( + { + "delay": self.retry_delays[retry_attempt], + "kind": "retry-schedule", + } + ) + + self.index += 1 + return decision + + def assert_complete(self): + if self.index == len(self.retry_decisions): + return + + fail( + "retry state scenario observed {} retry decision(s), expected {}".format( + self.index, + len(self.retry_decisions), + ) + ) + + +def upload_with_retry_state_transitions(conformance_scenario): + input_options = conformance_input_options(conformance_scenario) + content = conformance_input_source_bytes(conformance_scenario) + endpoint_url = string_value(input_options["endpointUrl"], "endpointUrl") + metadata = object_value(input_options["metadata"], "metadata") + retry_delays = int_array_value(input_options["retryDelays"], "retryDelays") + retry_decisions = conformance_retry_decisions(conformance_scenario) + completion = object_value( + conformance_scenario["completion"], + "conformanceScenario.completion", + ) + completion_kind = string_value( + completion["kind"], + "conformanceScenario.completion.kind", + ) + observer = RetryStateObserver(retry_decisions, retry_delays) + + with TusConformancePlanServer(conformance_scenario, endpoint_url) as conformance_server: + client = tus.TusClient(conformance_server.endpoint_url()) + uploader = client.uploader( + file_stream=BytesIO(content), + metadata=metadata, + on_should_retry=observer.on_should_retry, + retry_delays=retry_delays, + ) + uploader.upload() + observer.assert_complete() + + if not uploader.url: + fail("retry state scenario did not expose an upload URL") + if uploader.offset != len(content): + fail( + "retry state scenario upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + + conformance_server.assert_exhausted() + result = conformance_server.result() + result["completionKind"] = completion_kind + result["errorCalled"] = False + result["eventCount"] = len(observer.events) + result["events"] = observer.events + result["successCalled"] = True + result["uploadUrl"] = conformance_server.canonical_url(uploader.url) + return result + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + conformance_scenario = object_value( + scenario["conformanceScenario"], + "conformanceScenario", + ) + result = upload_with_retry_state_transitions(conformance_scenario) + write_result(result) + print( + "Python TUS SDK devdock scenario {} proved retry state transitions".format( + scenario_id(scenario), + ) + ) + + +if __name__ == "__main__": + main() diff --git a/examples/api2devdock.py b/examples/api2devdock.py index 556a10a..1eea1ca 100644 --- a/examples/api2devdock.py +++ b/examples/api2devdock.py @@ -139,6 +139,33 @@ def conformance_input_source_kind(conformance_scenario): return string_value(input_source["kind"], "conformanceScenario.inputSource.kind") +def conformance_retry_decisions(conformance_scenario): + decisions = conformance_scenario["retryDecisions"] + if not isinstance(decisions, list): + fail("conformanceScenario.retryDecisions must be a list") + + result = [] + for index, decision in enumerate(decisions): + decision = object_value( + decision, + "conformanceScenario.retryDecisions[{}]".format(index), + ) + result.append( + { + "decision": bool_value( + decision["decision"], + "conformanceScenario.retryDecisions[{}].decision".format(index), + ), + "retryAttempt": int_value( + decision["retryAttempt"], + "conformanceScenario.retryDecisions[{}].retryAttempt".format(index), + ), + } + ) + + return result + + def conformance_scenario_wants_event(conformance_scenario, event_kind): events = conformance_scenario.get("events", []) if not isinstance(events, list): diff --git a/tests/test_uploader.py b/tests/test_uploader.py index aba0c25..dec1ffd 100644 --- a/tests/test_uploader.py +++ b/tests/test_uploader.py @@ -297,6 +297,74 @@ def test_upload_retry(self, request_mock): self.uploader.upload_chunk() self.assertEqual(self.uploader._retried, num_of_retries) + @mock.patch('tusclient.uploader.uploader.TusRequest') + def test_upload_retry_delays_and_should_retry_reset_after_progress(self, request_mock): + first_failure = mock.Mock() + first_failure.status_code = 500 + first_failure.response_content = b'' + first_failure.response_headers = {} + first_failure.perform.return_value = None + + second_failure = mock.Mock() + second_failure.status_code = 500 + second_failure.response_content = b'' + second_failure.response_headers = {} + second_failure.perform.return_value = None + + success = mock.Mock() + success.status_code = 204 + success.response_content = b'' + success.response_headers = {'upload-offset': '11'} + success.perform.return_value = None + + retry_attempts = [] + + def should_retry(error, retry_attempt): + retry_attempts.append(retry_attempt) + return True + + self.uploader.retries = 0 + self.uploader.retry_delays = [250] + self.uploader.on_should_retry = should_retry + self.uploader.offset = 0 + request_mock.side_effect = [first_failure, second_failure, success] + + with mock.patch.object(self.uploader, 'get_offset', side_effect=[5, 5]) as get_offset: + with mock.patch('tusclient.uploader.uploader.time.sleep') as sleep: + self.uploader.upload_chunk() + + self.assertEqual(retry_attempts, [0, 0]) + self.assertEqual(self.uploader.offset, 11) + self.assertEqual(get_offset.call_count, 2) + sleep.assert_has_calls([mock.call(0.25), mock.call(0.25)]) + + @mock.patch('tusclient.uploader.uploader.TusRequest') + def test_upload_retry_stops_when_should_retry_returns_false(self, request_mock): + failure = mock.Mock() + failure.status_code = 500 + failure.response_content = b'' + failure.response_headers = {} + failure.perform.return_value = None + + retry_attempts = [] + + def should_retry(error, retry_attempt): + retry_attempts.append(retry_attempt) + return False + + self.uploader.retries = 0 + self.uploader.retry_delays = [0] + self.uploader.on_should_retry = should_retry + request_mock.side_effect = [failure] + + with mock.patch.object(self.uploader, 'get_offset') as get_offset: + with pytest.raises(exceptions.TusCommunicationError): + self.uploader.upload_chunk() + + self.assertEqual(retry_attempts, [0]) + self.assertEqual(self.uploader._retried, 0) + get_offset.assert_not_called() + @responses.activate def test_upload_empty(self): responses.add( diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index b29ecfb..ebc4081 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -87,6 +87,8 @@ class BaseUploader: Callback invoked with bytes sent and total bytes after upload progress changes. - on_chunk_complete (Optional[Callable]): Callback invoked with chunk size, accepted offset, and total bytes after a chunk is accepted. + - on_should_retry (Optional[Callable]): + Callback invoked with an error and retry attempt before scheduling a retry. :Constructor Args: - file_path (str) @@ -139,6 +141,7 @@ def __init__( parallel_upload_boundaries=None, protocol: Optional[str] = None, retry_delays=None, + on_should_retry: Optional[Callable[[Exception, int], bool]] = None, on_progress: Optional[Callable[[int, Optional[int]], None]] = None, on_chunk_complete: Optional[Callable[[int, int, Optional[int]], None]] = None, ): @@ -196,6 +199,7 @@ def __init__( self.parallel_upload_boundaries = parallel_upload_boundaries self.override_patch_method = override_patch_method self.retry_delays = retry_delays + self.on_should_retry = on_should_retry self.on_progress = on_progress self.on_chunk_complete = on_chunk_complete self._abort_requested = Event() @@ -232,6 +236,24 @@ def request_method_input_options(self): "override_patch_method": self.override_patch_method, } + def _retry_limit(self): + if self.retry_delays is not None: + return len(self.retry_delays) + + return self.retries + + def _retry_delay_seconds(self, retry_attempt): + if self.retry_delays is not None: + return self.retry_delays[retry_attempt] / 1000 + + return self.retry_delay + + def _should_retry(self, error, retry_attempt): + if self.on_should_retry is None: + return True + + return bool(self.on_should_retry(error, retry_attempt)) + def run_before_request(self, method, url, headers): if self.client is not None: self.client._set_current_uploader(self) diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index c114c50..5be4a68 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -208,19 +208,25 @@ def _do_request(self): self._retry_or_cry(error) def _retry_or_cry(self, error): - if self.retries > self._retried: - time.sleep(self.retry_delay) - - self._retried += 1 - try: - self.offset = self.get_offset() - except TusCommunicationError as err: - self._retry_or_cry(err) - else: - self._do_request() - else: + retry_attempt = self._retried + if self._retry_limit() <= retry_attempt: + raise error + if not self._should_retry(error, retry_attempt): raise error + time.sleep(self._retry_delay_seconds(retry_attempt)) + self._retried += 1 + previous_offset = self.offset + try: + recovered_offset = self.get_offset() + except TusCommunicationError as err: + self._retry_or_cry(err) + else: + if recovered_offset > previous_offset: + self._retried = 0 + self.offset = recovered_offset + self._do_request() + class AsyncUploader(BaseUploader): def __init__(self, *args, **kwargs): @@ -323,15 +329,21 @@ async def _do_request(self): await self._retry_or_cry(error) async def _retry_or_cry(self, error): - if self.retries > self._retried: - await asyncio.sleep(self.retry_delay) - - self._retried += 1 - try: - self.offset = self.get_offset() - except TusCommunicationError as err: - await self._retry_or_cry(err) - else: - await self._do_request() - else: + retry_attempt = self._retried + if self._retry_limit() <= retry_attempt: + raise error + if not self._should_retry(error, retry_attempt): raise error + + await asyncio.sleep(self._retry_delay_seconds(retry_attempt)) + self._retried += 1 + previous_offset = self.offset + try: + recovered_offset = self.get_offset() + except TusCommunicationError as err: + await self._retry_or_cry(err) + else: + if recovered_offset > previous_offset: + self._retried = 0 + self.offset = recovered_offset + await self._do_request() From 0969ad663903fcb07579909011531c61964efbca Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 02:25:47 +0200 Subject: [PATCH 90/95] Support Python TUS parallel upload concat --- .../main.py | 107 ++++++++++++ tests/test_uploader.py | 101 +++++++++++ tusclient/protocol_generated.py | 11 ++ tusclient/uploader/baseuploader.py | 156 ++++++++++++++++- tusclient/uploader/uploader.py | 161 ++++++++++++++++++ 5 files changed, 528 insertions(+), 8 deletions(-) create mode 100644 examples/api2-devdock-tus-parallel-upload-concat/main.py diff --git a/examples/api2-devdock-tus-parallel-upload-concat/main.py b/examples/api2-devdock-tus-parallel-upload-concat/main.py new file mode 100644 index 0000000..f7dea4f --- /dev/null +++ b/examples/api2-devdock-tus-parallel-upload-concat/main.py @@ -0,0 +1,107 @@ +"""Prove TUS parallel partial uploads are concatenated into a final upload.""" + +import sys +from io import BytesIO +from pathlib import Path + +from tusclient import client as tus + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from api2devdock import ( + TusConformancePlanServer, + conformance_input_options, + conformance_input_source_bytes, + fail, + int_value, + load_scenario, + object_value, + scenario_id, + string_value, + write_result, +) + + +def upload_with_parallel_concat(conformance_scenario): + input_options = conformance_input_options(conformance_scenario) + content = conformance_input_source_bytes(conformance_scenario) + endpoint_url = string_value(input_options["endpointUrl"], "endpointUrl") + metadata = object_value(input_options["metadata"], "metadata") + metadata_for_partial_uploads = object_value( + input_options["metadataForPartialUploads"], + "metadataForPartialUploads", + ) + parallel_uploads = int_value(input_options["parallelUploads"], "parallelUploads") + completion = object_value( + conformance_scenario["completion"], + "conformanceScenario.completion", + ) + completion_kind = string_value( + completion["kind"], + "conformanceScenario.completion.kind", + ) + events = [] + + with TusConformancePlanServer(conformance_scenario, endpoint_url) as conformance_server: + client = tus.TusClient(conformance_server.endpoint_url()) + uploader = client.uploader( + file_stream=BytesIO(content), + metadata=metadata, + metadata_for_partial_uploads=metadata_for_partial_uploads, + parallel_uploads=parallel_uploads, + on_progress=lambda bytes_sent, bytes_total: events.append( + { + "bytesSent": bytes_sent, + "bytesTotal": bytes_total, + "kind": "progress", + }, + ), + on_chunk_complete=lambda chunk_size, bytes_accepted, bytes_total: events.append( + { + "bytesAccepted": bytes_accepted, + "bytesTotal": bytes_total, + "chunkSize": chunk_size, + "kind": "chunk-complete", + }, + ), + ) + uploader.upload() + + if not uploader.url: + fail("parallel upload concat scenario did not expose an upload URL") + if uploader.offset != len(content): + fail( + "parallel upload concat scenario upload offset {}, expected {}".format( + uploader.offset, + len(content), + ) + ) + + conformance_server.assert_exhausted() + result = conformance_server.result() + result["completionKind"] = completion_kind + result["errorCalled"] = False + result["eventCount"] = len(events) + result["events"] = events + result["successCalled"] = True + result["uploadUrl"] = conformance_server.canonical_url(uploader.url) + return result + + +def main(): + scenario = load_scenario(Path(__file__).with_name("api2-scenario.json")) + conformance_scenario = object_value( + scenario["conformanceScenario"], + "conformanceScenario", + ) + result = upload_with_parallel_concat(conformance_scenario) + write_result(result) + print( + "Python TUS SDK devdock scenario {} concatenated parallel uploads into {}".format( + scenario_id(scenario), + result["uploadUrl"], + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_uploader.py b/tests/test_uploader.py index dec1ffd..b2ad4ac 100644 --- a/tests/test_uploader.py +++ b/tests/test_uploader.py @@ -283,6 +283,107 @@ def test_upload(self, request_mock): self.uploader.upload() self.assertEqual(self.uploader.offset, self.uploader.get_file_size()) + @responses.activate + def test_parallel_upload_concat(self): + content = b"hello world" + part_1_url = f"{self.client.url}parallel-part-1" + part_2_url = f"{self.client.url}parallel-part-2" + final_url = f"{self.client.url}parallel-final" + events = [] + + def add_expected_request(method, url, expected_headers, response_headers, body=b""): + def callback(request): + for name, value in expected_headers.items(): + self.assertEqual(request.headers.get(name), value) + self.assertEqual(request.body or b"", body) + return (201 if method == responses.POST else 204, response_headers, "") + + responses.add_callback(method, url, callback=callback) + + add_expected_request( + responses.POST, + self.client.url, + { + "Tus-Resumable": "1.0.0", + "Upload-Concat": "partial", + "Upload-Length": "5", + "Upload-Metadata": "test d29ybGQ=", + }, + {"Location": part_1_url}, + ) + add_expected_request( + responses.POST, + self.client.url, + { + "Tus-Resumable": "1.0.0", + "Upload-Concat": "partial", + "Upload-Length": "6", + "Upload-Metadata": "test d29ybGQ=", + }, + {"Location": part_2_url}, + ) + add_expected_request( + responses.PATCH, + part_1_url, + { + "Content-Type": "application/offset+octet-stream", + "Tus-Resumable": "1.0.0", + "Upload-Offset": "0", + }, + {"Upload-Offset": "5"}, + body=b"hello", + ) + add_expected_request( + responses.PATCH, + part_2_url, + { + "Content-Type": "application/offset+octet-stream", + "Tus-Resumable": "1.0.0", + "Upload-Offset": "0", + }, + {"Upload-Offset": "6"}, + body=b" world", + ) + + def final_callback(request): + self.assertEqual(request.headers.get("Tus-Resumable"), "1.0.0") + self.assertEqual( + request.headers.get("Upload-Concat"), + "final;{} {}".format(part_1_url, part_2_url), + ) + self.assertEqual(request.headers.get("Upload-Metadata"), "foo aGVsbG8=") + self.assertNotIn("Upload-Length", request.headers) + self.assertEqual(request.body or b"", b"") + return (201, {"Location": final_url}, "") + + responses.add_callback(responses.POST, self.client.url, callback=final_callback) + + uploader = self.client.uploader( + file_stream=io.BytesIO(content), + metadata={"foo": "hello"}, + metadata_for_partial_uploads={"test": "world"}, + parallel_uploads=2, + on_progress=lambda bytes_sent, bytes_total: events.append( + ("progress", bytes_sent, bytes_total), + ), + on_chunk_complete=lambda chunk_size, bytes_accepted, bytes_total: events.append( + ("chunk-complete", chunk_size, bytes_accepted, bytes_total), + ), + ) + uploader.upload() + + self.assertEqual(uploader.url, final_url) + self.assertEqual(uploader.offset, len(content)) + self.assertEqual( + events, + [ + ("progress", 5, 11), + ("chunk-complete", 5, 5, 11), + ("progress", 11, 11), + ("chunk-complete", 6, 11, 11), + ], + ) + @mock.patch('tusclient.uploader.uploader.TusRequest') def test_upload_retry(self, request_mock): num_of_retries = 3 diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index 42813b6..bed19a5 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -47,6 +47,15 @@ }, ] OFFSET_DISCOVERY_METHOD = 'HEAD' +PARALLEL_FINAL_CONCAT_PREFIX = 'final;' +PARALLEL_PARTIAL_HEADER_KIND = 'partial-upload' +PARALLEL_PARTIAL_METADATA_SOURCE = 'metadataForPartialUploads' +PARALLEL_PARTIAL_NESTED_UPLOADS = 'disabled' +PARALLEL_PARTIAL_URL_STORAGE = 'parent-managed' +PARALLEL_UPLOAD_DEFAULT = 1 +PARALLEL_UPLOAD_MINIMUM = 2 +PARALLEL_UPLOAD_SPLIT_STRATEGY = 'contiguous-floor-size-last-remainder' +PARALLEL_UPLOAD_URL_SEPARATOR = ' ' REQUEST_ID_HEADER_NAME = 'X-Request-ID' START_VALIDATION_CLIENT_FLOW_VALUES = { 'minimumParallelUploads': 2, @@ -356,6 +365,8 @@ UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME = 'Content-Type' UPLOAD_CHUNK_METHOD = 'PATCH' UPLOAD_CHUNK_OPERATION_ID = 'patchTusUpload' +UPLOAD_CONCAT_HEADER_NAME = 'Upload-Concat' +UPLOAD_CONCAT_PARTIAL_VALUE = 'partial' UPLOAD_DEFER_LENGTH_HEADER_NAME = 'Upload-Defer-Length' UPLOAD_LENGTH_HEADER_NAME = 'Upload-Length' UPLOAD_OFFSET_HEADER_NAME = 'Upload-Offset' diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index ebc4081..5fe2189 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -11,7 +11,25 @@ from tusclient.exceptions import TusCommunicationError from tusclient.request import TusRequest, catch_requests_error from tusclient.fingerprint import fingerprint, interface -from tusclient.protocol_generated import DEFAULT_REQUEST_HEADERS, prepare_request_headers +from tusclient.protocol_generated import ( + DEFAULT_REQUEST_HEADERS, + PARALLEL_FINAL_CONCAT_PREFIX, + PARALLEL_PARTIAL_HEADER_KIND, + PARALLEL_PARTIAL_METADATA_SOURCE, + PARALLEL_PARTIAL_NESTED_UPLOADS, + PARALLEL_PARTIAL_URL_STORAGE, + PARALLEL_UPLOAD_DEFAULT, + PARALLEL_UPLOAD_MINIMUM, + PARALLEL_UPLOAD_SPLIT_STRATEGY, + PARALLEL_UPLOAD_URL_SEPARATOR, + START_VALIDATION_MESSAGES, + METADATA_HEADER_NAME, + UPLOAD_CONCAT_HEADER_NAME, + UPLOAD_CONCAT_PARTIAL_VALUE, + UPLOAD_DEFER_LENGTH_HEADER_NAME, + UPLOAD_LENGTH_HEADER_NAME, + prepare_request_headers, +) from tusclient.request_lifecycle import TusRequestContext from tusclient.start_validation import validate_upload_start_or_raise from tusclient.storage.interface import Storage @@ -139,6 +157,7 @@ def __init__( override_patch_method=False, parallel_uploads: Optional[int] = None, parallel_upload_boundaries=None, + metadata_for_partial_uploads: Optional[Dict] = None, protocol: Optional[str] = None, retry_delays=None, on_should_retry: Optional[Callable[[Exception, int], bool]] = None, @@ -197,6 +216,7 @@ def __init__( self.upload_size = upload_size self.parallel_uploads = parallel_uploads self.parallel_upload_boundaries = parallel_upload_boundaries + self.metadata_for_partial_uploads = metadata_for_partial_uploads or {} self.override_patch_method = override_patch_method self.retry_delays = retry_delays self.on_should_retry = on_should_retry @@ -272,14 +292,33 @@ def run_after_response(self, context, response): if hooks is not None and hooks.after_response is not None: hooks.after_response(context, response) - def get_url_creation_headers(self): + def get_url_creation_headers( + self, + metadata=None, + partial=False, + upload_length=None, + final_upload_urls=None, + ): """Return headers required to create upload url""" operation_headers = {} - if self.upload_length_deferred: - operation_headers['upload-defer-length'] = '1' + if final_upload_urls is not None: + operation_headers[UPLOAD_CONCAT_HEADER_NAME] = "{}{}".format( + PARALLEL_FINAL_CONCAT_PREFIX, + PARALLEL_UPLOAD_URL_SEPARATOR.join(final_upload_urls), + ) else: - operation_headers["upload-length"] = str(self.file_size) - operation_headers["upload-metadata"] = ",".join(self.encode_metadata()) + if partial: + operation_headers[UPLOAD_CONCAT_HEADER_NAME] = UPLOAD_CONCAT_PARTIAL_VALUE + if self.upload_length_deferred: + operation_headers[UPLOAD_DEFER_LENGTH_HEADER_NAME] = "1" + else: + operation_headers[UPLOAD_LENGTH_HEADER_NAME] = str( + self.file_size if upload_length is None else upload_length + ) + + encoded_metadata = self.encode_metadata(metadata) + if encoded_metadata: + operation_headers[METADATA_HEADER_NAME] = ",".join(encoded_metadata) return self.prepare_request_headers(operation_headers) @property @@ -324,12 +363,13 @@ def get_offset(self): raise TusCommunicationError(msg, resp.status_code, resp.content) return int(offset) - def encode_metadata(self): + def encode_metadata(self, metadata=None): """ Return list of encoded metadata as defined by the Tus protocol. """ encoded_list = [] - for key, value in self.metadata.items(): + metadata = self.metadata if metadata is None else metadata + for key, value in metadata.items(): key_str = str(key) # dict keys may be of any object type. # confirm that the key does not contain unwanted characters. @@ -343,6 +383,106 @@ def encode_metadata(self): ) return encoded_list + def parallel_upload_count(self): + parallel_uploads = ( + PARALLEL_UPLOAD_DEFAULT + if self.parallel_uploads is None + else self.parallel_uploads + ) + if parallel_uploads == 1: + return parallel_uploads + if parallel_uploads < PARALLEL_UPLOAD_MINIMUM: + raise ValueError( + "tus: parallel uploads must be at least {}".format( + PARALLEL_UPLOAD_MINIMUM, + ) + ) + + return parallel_uploads + + def parallel_upload_part_ranges(self, parallel_uploads): + if self.parallel_upload_boundaries is not None: + return [ + self._parallel_upload_boundary_range(index, boundary) + for index, boundary in enumerate(self.parallel_upload_boundaries) + ] + + if PARALLEL_UPLOAD_SPLIT_STRATEGY != "contiguous-floor-size-last-remainder": + raise ValueError( + "tus: unsupported parallel upload split strategy {}".format( + PARALLEL_UPLOAD_SPLIT_STRATEGY, + ) + ) + if self.file_size is None: + raise ValueError(START_VALIDATION_MESSAGES["parallelUploadMissingSize"]) + if parallel_uploads <= 0: + raise ValueError("tus: parallel upload count must be positive") + + part_size = self.file_size // parallel_uploads + if part_size <= 0: + raise ValueError("tus: parallel upload parts must not be empty") + + ranges = [] + start = 0 + for index in range(parallel_uploads): + end = start + part_size + if index == parallel_uploads - 1: + end = self.file_size + ranges.append((start, end)) + start = end + + return ranges + + def assert_parallel_upload_policy_supported(self): + if PARALLEL_PARTIAL_HEADER_KIND != "partial-upload": + raise ValueError( + "tus: unsupported partial upload header kind {}".format( + PARALLEL_PARTIAL_HEADER_KIND, + ) + ) + if PARALLEL_PARTIAL_METADATA_SOURCE != "metadataForPartialUploads": + raise ValueError( + "tus: unsupported parallel partial metadata policy {}".format( + PARALLEL_PARTIAL_METADATA_SOURCE, + ) + ) + if PARALLEL_PARTIAL_NESTED_UPLOADS != "disabled": + raise ValueError( + "tus: unsupported nested parallel upload policy {}".format( + PARALLEL_PARTIAL_NESTED_UPLOADS, + ) + ) + if PARALLEL_PARTIAL_URL_STORAGE != "parent-managed": + raise ValueError( + "tus: unsupported parallel partial URL storage policy {}".format( + PARALLEL_PARTIAL_URL_STORAGE, + ) + ) + + def read_file_range(self, start, end): + stream = self.get_file_stream() + try: + stream.seek(start) + chunk = stream.read(end - start) + finally: + if self.file_stream is None: + stream.close() + + if len(chunk) != end - start: + raise ValueError(START_VALIDATION_MESSAGES["parallelUploadSliceMissingValue"]) + + return chunk + + def _parallel_upload_boundary_range(self, index, boundary): + if isinstance(boundary, dict): + return boundary["start"], boundary["end"] + if isinstance(boundary, (list, tuple)) and len(boundary) == 2: + return boundary[0], boundary[1] + + raise ValueError( + "tus: invalid parallel upload boundary at index {}".format(index) + ) + def __init_url_and_offset(self, url: Optional[str] = None): """ Return the tus upload url. diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index 5be4a68..764fe6a 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -17,8 +17,11 @@ from tusclient.protocol_generated import ( CREATE_UPLOAD_METHOD, LOCATION_HEADER_NAME, + UPLOAD_CHUNK_METHOD, + UPLOAD_CHUNK_OPERATION_ID, UPLOAD_OFFSET_HEADER_NAME, is_successful_response_status, + request_method_plan, upload_body_headers, ) from tusclient.request import TusRequest, AsyncTusRequest, catch_requests_error @@ -134,6 +137,13 @@ def upload(self, stop_at: Optional[int] = None): defaults to the file size. """ self.stop_at = stop_at or self.file_size + parallel_uploads = self.parallel_upload_count() + + if parallel_uploads > 1: + if stop_at is not None and stop_at != self.file_size: + raise ValueError("tus: stop_at is not supported with parallel uploads") + self.upload_parallel(parallel_uploads) + return if not self.url: # Ensure the POST request is performed even for empty files. @@ -145,6 +155,135 @@ def upload(self, stop_at: Optional[int] = None): while self.stop_at is None or (self.offset < self.stop_at): self.upload_chunk() + def upload_parallel(self, parallel_uploads: int): + self.assert_parallel_upload_policy_supported() + part_ranges = self.parallel_upload_part_ranges(parallel_uploads) + partial_urls = [ + self.create_partial_url(end - start) + for start, end in part_ranges + ] + accepted_bytes = 0 + + for part_url, (start, end) in zip(partial_urls, part_ranges): + part_size = end - start + accepted_bytes += self.upload_partial_chunk(part_url, start, end) + self.offset = accepted_bytes + self.notify_progress(self.offset) + self.notify_chunk_complete(part_size, self.offset) + + self.set_url(self.create_final_url(partial_urls)) + self.offset = self.file_size + self.remove_url_on_success() + + @catch_requests_error + def create_partial_url(self, part_size: int): + headers = self.get_url_creation_headers( + metadata=self.metadata_for_partial_uploads, + partial=True, + upload_length=part_size, + ) + context = self.run_before_request(CREATE_UPLOAD_METHOD, self.client.url, headers) + try: + resp = requests.request( + CREATE_UPLOAD_METHOD, + self.client.url, + headers=context.headers, + verify=self.verify_tls_cert, + cert=self.client_cert, + ) + except requests.exceptions.RequestException as error: + if self.is_aborted(): + raise TusUploadAborted() + raise create_upload_request_error(context, error) + finally: + self.clear_current_request() + self.run_after_response(context, resp) + url = resp.headers.get(LOCATION_HEADER_NAME) + if not is_successful_response_status(resp.status_code) or url is None: + raise create_upload_response_error(context, resp) + return urljoin(self.client.url, url) + + @catch_requests_error + def upload_partial_chunk(self, partial_url: str, start: int, end: int): + chunk = self.read_file_range(start, end) + operation_headers = { + UPLOAD_OFFSET_HEADER_NAME: "0", + } + operation_headers.update(upload_body_headers(self.protocol, done=True)) + method_plan = request_method_plan( + UPLOAD_CHUNK_OPERATION_ID, + UPLOAD_CHUNK_METHOD, + self.request_method_input_options(), + ) + operation_headers.update(method_plan["headers"]) + headers = self.prepare_request_headers(operation_headers) + context = self.run_before_request(method_plan["method"], partial_url, headers) + try: + resp = requests.request( + method_plan["method"], + partial_url, + data=chunk, + headers=context.headers, + verify=self.verify_tls_cert, + stream=True, + cert=self.client_cert, + ) + except requests.exceptions.RequestException as error: + if self.is_aborted(): + raise TusUploadAborted() + raise create_upload_request_error(context, error) + finally: + self.clear_current_request() + self.run_after_response(context, resp) + + if not is_successful_response_status(resp.status_code): + raise create_upload_response_error(context, resp) + + accepted_offset = resp.headers.get(UPLOAD_OFFSET_HEADER_NAME) + if accepted_offset is None: + raise create_upload_response_error(context, resp) + + try: + accepted_offset = int(accepted_offset) + except ValueError: + raise TusCommunicationError( + "Unexpected accepted upload offset {}".format(accepted_offset), + resp.status_code, + resp.content, + ) + if accepted_offset != len(chunk): + raise TusCommunicationError( + "Unexpected accepted upload offset {}".format(accepted_offset), + resp.status_code, + resp.content, + ) + + return accepted_offset + + @catch_requests_error + def create_final_url(self, partial_urls): + headers = self.get_url_creation_headers(final_upload_urls=partial_urls) + context = self.run_before_request(CREATE_UPLOAD_METHOD, self.client.url, headers) + try: + resp = requests.request( + CREATE_UPLOAD_METHOD, + self.client.url, + headers=context.headers, + verify=self.verify_tls_cert, + cert=self.client_cert, + ) + except requests.exceptions.RequestException as error: + if self.is_aborted(): + raise TusUploadAborted() + raise create_upload_request_error(context, error) + finally: + self.clear_current_request() + self.run_after_response(context, resp) + url = resp.headers.get(LOCATION_HEADER_NAME) + if not is_successful_response_status(resp.status_code) or url is None: + raise create_upload_response_error(context, resp) + return urljoin(self.client.url, url) + def upload_chunk(self): """ Upload chunk of file. @@ -245,6 +384,19 @@ async def upload(self, stop_at: Optional[int] = None): defaults to the file size. """ self.stop_at = stop_at or self.file_size + parallel_uploads = self.parallel_upload_count() + + if parallel_uploads > 1: + if stop_at is not None and stop_at != self.file_size: + raise ValueError("tus: stop_at is not supported with parallel uploads") + loop = asyncio.get_running_loop() + await loop.run_in_executor( + None, + Uploader.upload_parallel, + self, + parallel_uploads, + ) + return if not self.url: self.set_url(await self.create_url()) @@ -253,6 +405,15 @@ async def upload(self, stop_at: Optional[int] = None): while self.stop_at is None or (self.offset < self.stop_at): await self.upload_chunk() + def create_partial_url(self, part_size: int): + return Uploader.create_partial_url(self, part_size) + + def upload_partial_chunk(self, partial_url: str, start: int, end: int): + return Uploader.upload_partial_chunk(self, partial_url, start, end) + + def create_final_url(self, partial_urls): + return Uploader.create_final_url(self, partial_urls) + async def upload_chunk(self): """ Upload chunk of file. From 04bc4fd732ee09b49015666fbd8a98d29461b6fc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 12 Jun 2026 17:22:55 +0200 Subject: [PATCH 91/95] Generate the TUS termination retry runtime The abort/termination retry loop now comes from the shared statement-IR procedure (terminate-upload-with-retry): tusclient/abort_generated.py owns the algorithm, and TusClient.terminate_upload delegates to it with an empty retry budget to keep its historical single-attempt behavior. The HTTP terminate request stays a handwritten transport shim. Co-Authored-By: Claude Fable 5 --- tusclient/abort_generated.py | 82 ++++++++++++++++++++++++++++++++++++ tusclient/client.py | 10 +++++ 2 files changed, 92 insertions(+) create mode 100644 tusclient/abort_generated.py diff --git a/tusclient/abort_generated.py b/tusclient/abort_generated.py new file mode 100644 index 0000000..854cff8 --- /dev/null +++ b/tusclient/abort_generated.py @@ -0,0 +1,82 @@ +# Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +# If it looks wrong, please report the issue instead of editing this file by hand; +# the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +import time + +from tusclient.exceptions import TusCommunicationError + + +DEFAULT_RETRY_DELAYS = [ + 0, + 1000, + 3000, + 5000, +] +RETRY_ATTEMPT_INCREMENT_POLICY = 'after-retry-scheduled' +RETRY_CLIENT_ERROR_STATUS_CATEGORY = 400 +RETRYABLE_CLIENT_STATUS_CODES = [ + 409, + 423, +] + + +def normalized_retry_delays(retry_delays): + if retry_delays is None: + return list(DEFAULT_RETRY_DELAYS) + return list(retry_delays) + + +def should_retry_status(status_code): + if not status_code: + return False + is_client_error = ( + status_code >= RETRY_CLIENT_ERROR_STATUS_CATEGORY + and status_code < RETRY_CLIENT_ERROR_STATUS_CATEGORY + 100 + ) + return not is_client_error or status_code in RETRYABLE_CLIENT_STATUS_CODES + + +def should_schedule_retry(on_should_retry, error, retry_attempt, retry_delays): + if retry_attempt >= len(retry_delays) or not should_retry_status(error.status_code): + return False + if on_should_retry is not None: + return bool(on_should_retry(error, retry_attempt)) + return True + + +def next_retry_attempt(retry_attempt): + if RETRY_ATTEMPT_INCREMENT_POLICY == 'after-retry-scheduled': + return retry_attempt + 1 + raise ValueError( + 'tus: unsupported retry increment policy {}'.format( + RETRY_ATTEMPT_INCREMENT_POLICY, + ) + ) + + +def terminate_upload_with_retry( + upload_url, + send_terminate_request, + retry_delays=None, + on_should_retry=None, +): + retry_delays = normalized_retry_delays(retry_delays) + retry_attempt = 0 + + while True: + error = None + try: + response = send_terminate_request(upload_url) + except TusCommunicationError as terminate_error: + error = terminate_error + if error is None: + return response + + if not should_schedule_retry(on_should_retry, error, retry_attempt, retry_delays): + raise error + + delay_ms = retry_delays[retry_attempt] + if delay_ms > 0: + time.sleep(delay_ms / 1000) + retry_attempt = next_retry_attempt(retry_attempt) diff --git a/tusclient/client.py b/tusclient/client.py index 6466040..5525f4c 100644 --- a/tusclient/client.py +++ b/tusclient/client.py @@ -2,6 +2,7 @@ import requests +from tusclient.abort_generated import terminate_upload_with_retry from tusclient.exceptions import TusCommunicationError from tusclient.protocol_generated import ( ABORT_REMOVE_STORED_URL_AFTER_TERMINATION, @@ -117,6 +118,15 @@ def abort_upload( return response def terminate_upload(self, upload_url: str, verify_tls_cert: bool = True): + # The retry/abort sequencing lives in the generated termination runtime; this client + # keeps its historical single-attempt behavior by passing an empty retry budget. + return terminate_upload_with_retry( + upload_url, + lambda url: self._send_terminate_request(url, verify_tls_cert), + retry_delays=[], + ) + + def _send_terminate_request(self, upload_url: str, verify_tls_cert: bool): headers = prepare_request_headers(None, self.headers, self.add_request_id) context = TusRequestContext(TERMINATE_UPLOAD_METHOD, upload_url, headers) if self.request_hooks is not None and self.request_hooks.before_request is not None: From 685de6c3941a453c3f556ff236982d4df49cba95 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 12 Jun 2026 18:54:06 +0200 Subject: [PATCH 92/95] Generate the chunk upload retry runtime The uploader's per-chunk patch-with-retry now runs on the generated upload_chunk_with_retry (tusclient/upload_chunks_generated.py), emitted from the shared TUS protocol contract retry core. The handwritten _do_request/_retry_or_cry recursion is delegated: the patch transport stays in _perform_patch_request (TusRequest machinery, accepted-state absorption), offset recovery failures re-enter the retry decision iteratively exactly as the recursion did, and the no-status-filter retry semantics plus the _retried counter surface are preserved. The legacy retries/retry_delay options map onto the delays-indexed budget via _upload_retry_delays_ms. The async uploader keeps its own loop for now. Co-Authored-By: Claude Fable 5 --- tests/test_uploader.py | 2 +- tusclient/upload_chunks_generated.py | 84 ++++++++++++++++++++++++++++ tusclient/uploader/baseuploader.py | 12 ++++ tusclient/uploader/uploader.py | 60 +++++++------------- 4 files changed, 116 insertions(+), 42 deletions(-) create mode 100644 tusclient/upload_chunks_generated.py diff --git a/tests/test_uploader.py b/tests/test_uploader.py index b2ad4ac..acdb7d4 100644 --- a/tests/test_uploader.py +++ b/tests/test_uploader.py @@ -431,7 +431,7 @@ def should_retry(error, retry_attempt): request_mock.side_effect = [first_failure, second_failure, success] with mock.patch.object(self.uploader, 'get_offset', side_effect=[5, 5]) as get_offset: - with mock.patch('tusclient.uploader.uploader.time.sleep') as sleep: + with mock.patch('tusclient.upload_chunks_generated.time.sleep') as sleep: self.uploader.upload_chunk() self.assertEqual(retry_attempts, [0, 0]) diff --git a/tusclient/upload_chunks_generated.py b/tusclient/upload_chunks_generated.py new file mode 100644 index 0000000..49605b5 --- /dev/null +++ b/tusclient/upload_chunks_generated.py @@ -0,0 +1,84 @@ +# Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +# If it looks wrong, please report the issue instead of editing this file by hand; +# the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +import time + +from tusclient.abort_generated import next_retry_attempt +from tusclient.exceptions import TusCommunicationError, TusUploadFailed + + +RETRY_ATTEMPT_RESET_POLICY = 'when-offset-advanced-since-last-retry' + + +def effective_retry_attempt(retry_attempt, offset, offset_before_retry): + if RETRY_ATTEMPT_RESET_POLICY == 'when-offset-advanced-since-last-retry': + if offset > offset_before_retry: + return 0 + return retry_attempt + raise ValueError( + 'tus: unsupported retry reset policy {}'.format( + RETRY_ATTEMPT_RESET_POLICY, + ) + ) + + +def should_schedule_upload_retry(on_should_retry, error, retry_attempt, retry_delays): + if retry_attempt >= len(retry_delays): + return False + if on_should_retry is not None: + return bool(on_should_retry(error, retry_attempt)) + return True + + +def upload_chunk_with_retry( + uploader, + perform_patch_request, + retry_delays, + on_should_retry=None, +): + uploader._retried = 0 + offset_before_retry = uploader.offset + start_offset = uploader.offset + + if not uploader.upload_length_deferred: + uploader.notify_progress(start_offset) + + while True: + error = None + try: + perform_patch_request() + except TusUploadFailed as patch_error: + error = patch_error + if error is None: + if uploader.upload_length_deferred: + uploader.notify_progress(start_offset) + uploader.notify_progress(uploader.offset) + uploader.notify_chunk_complete(uploader.offset - start_offset, uploader.offset) + return + + while error is not None: + effective_attempt = effective_retry_attempt( + uploader._retried, + uploader.offset, + offset_before_retry, + ) + if not should_schedule_upload_retry( + on_should_retry, + error, + effective_attempt, + retry_delays, + ): + raise error + + delay_ms = retry_delays[effective_attempt] + if delay_ms > 0: + time.sleep(delay_ms / 1000) + uploader._retried = next_retry_attempt(effective_attempt) + offset_before_retry = uploader.offset + error = None + try: + uploader.offset = uploader.get_offset() + except TusCommunicationError as sync_error: + error = sync_error + continue diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index 5fe2189..ca5180c 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -262,6 +262,18 @@ def _retry_limit(self): return self.retries + def _upload_retry_delays_ms(self): + """The retry budget as the delays-indexed-by-attempt list (in milliseconds). + + The legacy ``retries``/``retry_delay`` options map onto it as ``retries`` + repetitions of the same delay, which preserves their historical budget and + sleep behavior through the generated retry runtime. + """ + if self.retry_delays is not None: + return list(self.retry_delays) + + return [self.retry_delay * 1000] * self.retries + def _retry_delay_seconds(self, retry_attempt): if self.retry_delays is not None: return self.retry_delays[retry_attempt] / 1000 diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index 764fe6a..75d9e2a 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -1,5 +1,4 @@ from typing import Optional -import time import asyncio from urllib.parse import urljoin @@ -25,6 +24,7 @@ upload_body_headers, ) from tusclient.request import TusRequest, AsyncTusRequest, catch_requests_error +from tusclient.upload_chunks_generated import upload_chunk_with_retry def _verify_upload(request: TusRequest): @@ -288,26 +288,18 @@ def upload_chunk(self): """ Upload chunk of file. """ - self._retried = 0 - # Ensure that we have a URL, as this is behavior we allowed previously. # See https://github.com/tus/tus-py-client/issues/82. if not self.url: self.set_url(self.create_url()) self.offset = 0 - previous_offset = self.offset - if not self.upload_length_deferred: - self.notify_progress(previous_offset) - self._do_request() - self.offset = int(self.request.response_headers.get("upload-offset")) - if self.upload_length_deferred and self.request.stream_eof: - self.file_size = self.offset - self.stop_at = self.offset - if self.upload_length_deferred: - self.notify_progress(previous_offset) - self.notify_progress(self.offset) - self.notify_chunk_complete(self.offset - previous_offset, self.offset) + upload_chunk_with_retry( + self, + self._perform_patch_request, + self._upload_retry_delays_ms(), + on_should_retry=self.on_should_retry, + ) self.remove_url_on_success() @catch_requests_error @@ -338,33 +330,19 @@ def create_url(self): raise create_upload_response_error(context, resp) return urljoin(self.client.url, url) - def _do_request(self): - self.request = TusRequest(self) - try: - self.request.perform() - _verify_upload(self.request) - except TusUploadFailed as error: - self._retry_or_cry(error) + def _perform_patch_request(self): + """Send one chunk PATCH and absorb its accepted state on success. - def _retry_or_cry(self, error): - retry_attempt = self._retried - if self._retry_limit() <= retry_attempt: - raise error - if not self._should_retry(error, retry_attempt): - raise error - - time.sleep(self._retry_delay_seconds(retry_attempt)) - self._retried += 1 - previous_offset = self.offset - try: - recovered_offset = self.get_offset() - except TusCommunicationError as err: - self._retry_or_cry(err) - else: - if recovered_offset > previous_offset: - self._retried = 0 - self.offset = recovered_offset - self._do_request() + The retry algorithm around this transport step lives in the generated + ``upload_chunk_with_retry`` (see tusclient/upload_chunks_generated.py). + """ + self.request = TusRequest(self) + self.request.perform() + _verify_upload(self.request) + self.offset = int(self.request.response_headers.get("upload-offset")) + if self.upload_length_deferred and self.request.stream_eof: + self.file_size = self.offset + self.stop_at = self.offset class AsyncUploader(BaseUploader): From bc5da8157ea5d9dee50fe7c2b20418664eeb37e5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 12 Jun 2026 19:57:32 +0200 Subject: [PATCH 93/95] Generate the async chunk upload retry runtime AsyncUploader's handwritten _do_request/_retry_or_cry recursion delegates to the generated async upload_chunk_with_retry, the same walked per-chunk retry procedure as the sync runtime with await at the patch and sleep effects. The retry decision helpers stay imported from the sync modules, and the legacy _retry_limit/_retry_delay_seconds/_should_retry helpers leave baseuploader with their last consumer. Co-Authored-By: Claude Fable 5 --- tusclient/async_upload_chunks_generated.py | 65 ++++++++++++++++++++++ tusclient/uploader/baseuploader.py | 18 ------ tusclient/uploader/uploader.py | 61 +++++++------------- 3 files changed, 86 insertions(+), 58 deletions(-) create mode 100644 tusclient/async_upload_chunks_generated.py diff --git a/tusclient/async_upload_chunks_generated.py b/tusclient/async_upload_chunks_generated.py new file mode 100644 index 0000000..ebd8967 --- /dev/null +++ b/tusclient/async_upload_chunks_generated.py @@ -0,0 +1,65 @@ +# Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +# If it looks wrong, please report the issue instead of editing this file by hand; +# the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +import asyncio + +from tusclient.abort_generated import next_retry_attempt +from tusclient.exceptions import TusCommunicationError, TusUploadFailed +from tusclient.upload_chunks_generated import ( + effective_retry_attempt, + should_schedule_upload_retry, +) + + +async def upload_chunk_with_retry( + uploader, + perform_patch_request, + retry_delays, + on_should_retry=None, +): + uploader._retried = 0 + offset_before_retry = uploader.offset + start_offset = uploader.offset + + if not uploader.upload_length_deferred: + uploader.notify_progress(start_offset) + + while True: + error = None + try: + await perform_patch_request() + except TusUploadFailed as patch_error: + error = patch_error + if error is None: + if uploader.upload_length_deferred: + uploader.notify_progress(start_offset) + uploader.notify_progress(uploader.offset) + uploader.notify_chunk_complete(uploader.offset - start_offset, uploader.offset) + return + + while error is not None: + effective_attempt = effective_retry_attempt( + uploader._retried, + uploader.offset, + offset_before_retry, + ) + if not should_schedule_upload_retry( + on_should_retry, + error, + effective_attempt, + retry_delays, + ): + raise error + + delay_ms = retry_delays[effective_attempt] + if delay_ms > 0: + await asyncio.sleep(delay_ms / 1000) + uploader._retried = next_retry_attempt(effective_attempt) + offset_before_retry = uploader.offset + error = None + try: + uploader.offset = uploader.get_offset() + except TusCommunicationError as sync_error: + error = sync_error + continue diff --git a/tusclient/uploader/baseuploader.py b/tusclient/uploader/baseuploader.py index ca5180c..4d9a90b 100644 --- a/tusclient/uploader/baseuploader.py +++ b/tusclient/uploader/baseuploader.py @@ -256,12 +256,6 @@ def request_method_input_options(self): "override_patch_method": self.override_patch_method, } - def _retry_limit(self): - if self.retry_delays is not None: - return len(self.retry_delays) - - return self.retries - def _upload_retry_delays_ms(self): """The retry budget as the delays-indexed-by-attempt list (in milliseconds). @@ -274,18 +268,6 @@ def _upload_retry_delays_ms(self): return [self.retry_delay * 1000] * self.retries - def _retry_delay_seconds(self, retry_attempt): - if self.retry_delays is not None: - return self.retry_delays[retry_attempt] / 1000 - - return self.retry_delay - - def _should_retry(self, error, retry_attempt): - if self.on_should_retry is None: - return True - - return bool(self.on_should_retry(error, retry_attempt)) - def run_before_request(self, method, url, headers): if self.client is not None: self.client._set_current_uploader(self) diff --git a/tusclient/uploader/uploader.py b/tusclient/uploader/uploader.py index 75d9e2a..9e269c0 100644 --- a/tusclient/uploader/uploader.py +++ b/tusclient/uploader/uploader.py @@ -8,6 +8,9 @@ from tusclient.uploader.baseuploader import BaseUploader +from tusclient.async_upload_chunks_generated import ( + upload_chunk_with_retry as async_upload_chunk_with_retry, +) from tusclient.detailed_error import ( create_upload_request_error, create_upload_response_error, @@ -396,26 +399,18 @@ async def upload_chunk(self): """ Upload chunk of file. """ - self._retried = 0 - # Ensure that we have a URL, as this is behavior we allowed previously. # See https://github.com/tus/tus-py-client/issues/82. if not self.url: self.set_url(await self.create_url()) self.offset = 0 - previous_offset = self.offset - if not self.upload_length_deferred: - self.notify_progress(previous_offset) - await self._do_request() - self.offset = int(self.request.response_headers.get("upload-offset")) - if self.upload_length_deferred and self.request.stream_eof: - self.file_size = self.offset - self.stop_at = self.offset - if self.upload_length_deferred: - self.notify_progress(previous_offset) - self.notify_progress(self.offset) - self.notify_chunk_complete(self.offset - previous_offset, self.offset) + await async_upload_chunk_with_retry( + self, + self._perform_patch_request, + self._upload_retry_delays_ms(), + on_should_retry=self.on_should_retry, + ) self.remove_url_on_success() async def create_url(self): @@ -459,30 +454,16 @@ async def create_url(self): raise TusUploadAborted() raise TusCommunicationError(error) - async def _do_request(self): + async def _perform_patch_request(self): + """Send one chunk PATCH and absorb its accepted state on success. + + The retry algorithm around this transport step lives in the generated + ``upload_chunk_with_retry`` (see tusclient/async_upload_chunks_generated.py). + """ self.request = AsyncTusRequest(self) - try: - await self.request.perform() - _verify_upload(self.request) - except TusUploadFailed as error: - await self._retry_or_cry(error) - - async def _retry_or_cry(self, error): - retry_attempt = self._retried - if self._retry_limit() <= retry_attempt: - raise error - if not self._should_retry(error, retry_attempt): - raise error - - await asyncio.sleep(self._retry_delay_seconds(retry_attempt)) - self._retried += 1 - previous_offset = self.offset - try: - recovered_offset = self.get_offset() - except TusCommunicationError as err: - await self._retry_or_cry(err) - else: - if recovered_offset > previous_offset: - self._retried = 0 - self.offset = recovered_offset - await self._do_request() + await self.request.perform() + _verify_upload(self.request) + self.offset = int(self.request.response_headers.get("upload-offset")) + if self.upload_length_deferred and self.request.stream_eof: + self.file_size = self.offset + self.stop_at = self.offset From ef48a69099f91f062e794497837aff577559b426 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 13 Jun 2026 02:22:13 +0200 Subject: [PATCH 94/95] Drop the dead source-method assert from request_method_plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Method-override interpretation is keyed by operationId, and the contract derives each override's sourceMethod from that same operation (tusClientMethodOverrideModels), so after an operationId match the source_method comparison could never fire for contract-consistent callers — both generated call sites pass the UPLOAD_CHUNK constants derived from the very operation the override names. This converges the Python interpretation with the TypeScript runtime (tusMethodOverrideForOperation over the sourceMethod-less tusClientRuntimeMethodOverrideModels). source_method stays the no-override fallback method, mirroring the TS plan-method fallback. Co-Authored-By: Claude Fable 5 --- tusclient/protocol_generated.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index bed19a5..128fad2 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -439,15 +439,6 @@ def request_method_plan(operation_id, source_method, input_options=None): if not input_options.get(option_name, False): continue - if source_method != method_override['sourceMethod']: - raise ValueError( - 'tus: method override expected {} for {}, got {}'.format( - method_override['sourceMethod'], - operation_id, - source_method, - ) - ) - return { 'headers': { method_override['headerName']: method_override['headerValue'], From 6e4e8413016724bf465a29840edeb7579b9e28ec Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 13 Jun 2026 10:47:40 +0200 Subject: [PATCH 95/95] Trim generated Python TUS method overrides --- tusclient/protocol_generated.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tusclient/protocol_generated.py b/tusclient/protocol_generated.py index 128fad2..96b8c4f 100644 --- a/tusclient/protocol_generated.py +++ b/tusclient/protocol_generated.py @@ -43,7 +43,6 @@ 'inputFlag': 'overridePatchMethod', 'method': 'POST', 'operationId': 'patchTusUpload', - 'sourceMethod': 'PATCH', }, ] OFFSET_DISCOVERY_METHOD = 'HEAD'