From a717ed806ee0c3b346d1f945509c1100a781738d Mon Sep 17 00:00:00 2001 From: Adepu Sri Charan Date: Wed, 21 Oct 2026 16:48:01 +0530 Subject: [PATCH 1/3] chore(proto): generate Binary Store alpha RPCs Generate protobuf bindings from Dapr runtime v1.19.0-rc.4 (commit 1cdc46c820eede065522f80652c40304d668ea66) using grpcio-tools 1.81.1 and mypy-protobuf 5.0.0. Support immutable runtime refs and reliable generation failures without changing dependency floors. Signed-off-by: Adepu Sri Charan --- dapr/proto/runtime/v1/binarystore_pb2.py | 50 +++++ dapr/proto/runtime/v1/binarystore_pb2.pyi | 196 ++++++++++++++++++ dapr/proto/runtime/v1/binarystore_pb2_grpc.py | 24 +++ dapr/proto/runtime/v1/dapr_pb2.py | 11 +- dapr/proto/runtime/v1/dapr_pb2_grpc.py | 134 ++++++++++++ tools/regen_grpcclient.sh | 13 +- 6 files changed, 418 insertions(+), 10 deletions(-) create mode 100644 dapr/proto/runtime/v1/binarystore_pb2.py create mode 100644 dapr/proto/runtime/v1/binarystore_pb2.pyi create mode 100644 dapr/proto/runtime/v1/binarystore_pb2_grpc.py diff --git a/dapr/proto/runtime/v1/binarystore_pb2.py b/dapr/proto/runtime/v1/binarystore_pb2.py new file mode 100644 index 000000000..4502dc1ac --- /dev/null +++ b/dapr/proto/runtime/v1/binarystore_pb2.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: dapr/proto/runtime/v1/binarystore.proto +# Protobuf Python Version: 6.33.5 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 5, + '', + 'dapr/proto/runtime/v1/binarystore.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from dapr.proto.common.v1 import common_pb2 as dapr_dot_proto_dot_common_dot_v1_dot_common__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\'dapr/proto/runtime/v1/binarystore.proto\x12\x15\x64\x61pr.proto.runtime.v1\x1a!dapr/proto/common/v1/common.proto\"\xc9\x01\n\x1aSetBinaryFileRequestAlpha1\x12S\n\x0finitial_request\x18\x01 \x01(\x0b\x32\x38.dapr.proto.runtime.v1.SetBinaryFileRequestInitialAlpha1H\x00\x12\x36\n\x07payload\x18\x02 \x01(\x0b\x32#.dapr.proto.common.v1.StreamPayloadH\x00\x42\x1e\n\x1cset_binary_file_request_type\"\x98\x01\n!SetBinaryFileRequestInitialAlpha1\x12%\n\x0e\x63omponent_name\x18\x01 \x01(\tR\rcomponentName\x12\x1b\n\tfile_name\x18\x02 \x01(\tR\x08\x66ileName\x12!\n\toverwrite\x18\x03 \x01(\x08H\x00R\toverwrite\x88\x01\x01\x42\x0c\n\n_overwrite\"\x1d\n\x1bSetBinaryFileResponseAlpha1\"`\n\x1aGetBinaryFileRequestAlpha1\x12%\n\x0e\x63omponent_name\x18\x01 \x01(\tR\rcomponentName\x12\x1b\n\tfile_name\x18\x02 \x01(\tR\x08\x66ileName\"S\n\x1bGetBinaryFileResponseAlpha1\x12\x34\n\x07payload\x18\x01 \x01(\x0b\x32#.dapr.proto.common.v1.StreamPayload\"c\n\x1d\x44\x65leteBinaryFileRequestAlpha1\x12%\n\x0e\x63omponent_name\x18\x01 \x01(\tR\rcomponentName\x12\x1b\n\tfile_name\x18\x02 \x01(\tR\x08\x66ileName\" \n\x1e\x44\x65leteBinaryFileResponseAlpha1Bt\n\nio.dapr.v1B\x15\x44\x61prBinaryStoreProtosZ1github.com/dapr/dapr/pkg/proto/runtime/v1;runtime\xaa\x02\x1b\x44\x61pr.Client.Autogen.Grpc.v1b\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'dapr.proto.runtime.v1.binarystore_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n\nio.dapr.v1B\025DaprBinaryStoreProtosZ1github.com/dapr/dapr/pkg/proto/runtime/v1;runtime\252\002\033Dapr.Client.Autogen.Grpc.v1' + _globals['_SETBINARYFILEREQUESTALPHA1']._serialized_start=102 + _globals['_SETBINARYFILEREQUESTALPHA1']._serialized_end=303 + _globals['_SETBINARYFILEREQUESTINITIALALPHA1']._serialized_start=306 + _globals['_SETBINARYFILEREQUESTINITIALALPHA1']._serialized_end=458 + _globals['_SETBINARYFILERESPONSEALPHA1']._serialized_start=460 + _globals['_SETBINARYFILERESPONSEALPHA1']._serialized_end=489 + _globals['_GETBINARYFILEREQUESTALPHA1']._serialized_start=491 + _globals['_GETBINARYFILEREQUESTALPHA1']._serialized_end=587 + _globals['_GETBINARYFILERESPONSEALPHA1']._serialized_start=589 + _globals['_GETBINARYFILERESPONSEALPHA1']._serialized_end=672 + _globals['_DELETEBINARYFILEREQUESTALPHA1']._serialized_start=674 + _globals['_DELETEBINARYFILEREQUESTALPHA1']._serialized_end=773 + _globals['_DELETEBINARYFILERESPONSEALPHA1']._serialized_start=775 + _globals['_DELETEBINARYFILERESPONSEALPHA1']._serialized_end=807 +# @@protoc_insertion_point(module_scope) diff --git a/dapr/proto/runtime/v1/binarystore_pb2.pyi b/dapr/proto/runtime/v1/binarystore_pb2.pyi new file mode 100644 index 000000000..1aeff3608 --- /dev/null +++ b/dapr/proto/runtime/v1/binarystore_pb2.pyi @@ -0,0 +1,196 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file + +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from dapr.proto.common.v1 import common_pb2 as _common_pb2 +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +import builtins as _builtins +import sys +import typing as _typing + +if sys.version_info >= (3, 10): + from typing import TypeAlias as _TypeAlias +else: + from typing_extensions import TypeAlias as _TypeAlias + +DESCRIPTOR: _descriptor.FileDescriptor + +@_typing.final +class SetBinaryFileRequestAlpha1(_message.Message): + """SetBinaryFileRequestAlpha1 is the request for SetBinaryFileAlpha1. + The first message in the stream must contain the options; subsequent + messages carry chunks of the binary file content via payload. + """ + + DESCRIPTOR: _descriptor.Descriptor + + INITIAL_REQUEST_FIELD_NUMBER: _builtins.int + PAYLOAD_FIELD_NUMBER: _builtins.int + @_builtins.property + def initial_request(self) -> Global___SetBinaryFileRequestInitialAlpha1: + """Request details. Must be present in the first message only.""" + + @_builtins.property + def payload(self) -> _common_pb2.StreamPayload: + """Chunk of data of arbitrary size.""" + + def __init__( + self, + *, + initial_request: Global___SetBinaryFileRequestInitialAlpha1 | None = ..., + payload: _common_pb2.StreamPayload | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["initial_request", b"initial_request", "payload", b"payload", "set_binary_file_request_type", b"set_binary_file_request_type"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["initial_request", b"initial_request", "payload", b"payload", "set_binary_file_request_type", b"set_binary_file_request_type"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType_set_binary_file_request_type: _TypeAlias = _typing.Literal["initial_request", "payload"] # noqa: Y015 + _WhichOneofArgType_set_binary_file_request_type: _TypeAlias = _typing.Literal["set_binary_file_request_type", b"set_binary_file_request_type"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType_set_binary_file_request_type) -> _WhichOneofReturnType_set_binary_file_request_type | None: ... + +Global___SetBinaryFileRequestAlpha1: _TypeAlias = SetBinaryFileRequestAlpha1 # noqa: Y015 + +@_typing.final +class SetBinaryFileRequestInitialAlpha1(_message.Message): + """SetBinaryFileRequestInitialAlpha1 contains options for the first message in the + SetBinaryFileAlpha1 request. + """ + + DESCRIPTOR: _descriptor.Descriptor + + COMPONENT_NAME_FIELD_NUMBER: _builtins.int + FILE_NAME_FIELD_NUMBER: _builtins.int + OVERWRITE_FIELD_NUMBER: _builtins.int + component_name: _builtins.str + """Name of the component. Required.""" + file_name: _builtins.str + """Name of the file to store. Required.""" + overwrite: _builtins.bool + """When false (POST semantics), the operation fails if a file with the same + name already exists. When true (PUT semantics), any existing file is + replaced. Defaults to false. + """ + def __init__( + self, + *, + component_name: _builtins.str = ..., + file_name: _builtins.str = ..., + overwrite: _builtins.bool | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_overwrite", b"_overwrite", "overwrite", b"overwrite"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_overwrite", b"_overwrite", "component_name", b"component_name", "file_name", b"file_name", "overwrite", b"overwrite"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__overwrite: _TypeAlias = _typing.Literal["overwrite"] # noqa: Y015 + _WhichOneofArgType__overwrite: _TypeAlias = _typing.Literal["_overwrite", b"_overwrite"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__overwrite) -> _WhichOneofReturnType__overwrite | None: ... + +Global___SetBinaryFileRequestInitialAlpha1: _TypeAlias = SetBinaryFileRequestInitialAlpha1 # noqa: Y015 + +@_typing.final +class SetBinaryFileResponseAlpha1(_message.Message): + """SetBinaryFileResponseAlpha1 is the response for SetBinaryFileAlpha1.""" + + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +Global___SetBinaryFileResponseAlpha1: _TypeAlias = SetBinaryFileResponseAlpha1 # noqa: Y015 + +@_typing.final +class GetBinaryFileRequestAlpha1(_message.Message): + """GetBinaryFileRequestAlpha1 is the request for GetBinaryFileAlpha1.""" + + DESCRIPTOR: _descriptor.Descriptor + + COMPONENT_NAME_FIELD_NUMBER: _builtins.int + FILE_NAME_FIELD_NUMBER: _builtins.int + component_name: _builtins.str + """Name of the component. Required.""" + file_name: _builtins.str + """Name of the file to retrieve. Required.""" + def __init__( + self, + *, + component_name: _builtins.str = ..., + file_name: _builtins.str = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["component_name", b"component_name", "file_name", b"file_name"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___GetBinaryFileRequestAlpha1: _TypeAlias = GetBinaryFileRequestAlpha1 # noqa: Y015 + +@_typing.final +class GetBinaryFileResponseAlpha1(_message.Message): + """GetBinaryFileResponseAlpha1 is the streaming response for GetBinaryFileAlpha1.""" + + DESCRIPTOR: _descriptor.Descriptor + + PAYLOAD_FIELD_NUMBER: _builtins.int + @_builtins.property + def payload(self) -> _common_pb2.StreamPayload: + """Chunk of data.""" + + def __init__( + self, + *, + payload: _common_pb2.StreamPayload | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["payload", b"payload"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["payload", b"payload"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___GetBinaryFileResponseAlpha1: _TypeAlias = GetBinaryFileResponseAlpha1 # noqa: Y015 + +@_typing.final +class DeleteBinaryFileRequestAlpha1(_message.Message): + """DeleteBinaryFileRequestAlpha1 is the request for DeleteBinaryFileAlpha1.""" + + DESCRIPTOR: _descriptor.Descriptor + + COMPONENT_NAME_FIELD_NUMBER: _builtins.int + FILE_NAME_FIELD_NUMBER: _builtins.int + component_name: _builtins.str + """Name of the component. Required.""" + file_name: _builtins.str + """Name of the file to delete. Required.""" + def __init__( + self, + *, + component_name: _builtins.str = ..., + file_name: _builtins.str = ..., + ) -> None: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["component_name", b"component_name", "file_name", b"file_name"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + +Global___DeleteBinaryFileRequestAlpha1: _TypeAlias = DeleteBinaryFileRequestAlpha1 # noqa: Y015 + +@_typing.final +class DeleteBinaryFileResponseAlpha1(_message.Message): + """DeleteBinaryFileResponseAlpha1 is the response for DeleteBinaryFileAlpha1.""" + + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +Global___DeleteBinaryFileResponseAlpha1: _TypeAlias = DeleteBinaryFileResponseAlpha1 # noqa: Y015 diff --git a/dapr/proto/runtime/v1/binarystore_pb2_grpc.py b/dapr/proto/runtime/v1/binarystore_pb2_grpc.py new file mode 100644 index 000000000..a37acbc2a --- /dev/null +++ b/dapr/proto/runtime/v1/binarystore_pb2_grpc.py @@ -0,0 +1,24 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + + +GRPC_GENERATED_VERSION = '1.81.1' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in dapr/proto/runtime/v1/binarystore_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) diff --git a/dapr/proto/runtime/v1/dapr_pb2.py b/dapr/proto/runtime/v1/dapr_pb2.py index e9e941ca2..c1a98f500 100644 --- a/dapr/proto/runtime/v1/dapr_pb2.py +++ b/dapr/proto/runtime/v1/dapr_pb2.py @@ -37,9 +37,10 @@ from dapr.proto.runtime.v1 import workflow_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_workflow__pb2 from dapr.proto.runtime.v1 import jobs_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_jobs__pb2 from dapr.proto.runtime.v1 import ai_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_ai__pb2 +from dapr.proto.runtime.v1 import binarystore_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n dapr/proto/runtime/v1/dapr.proto\x12\x15\x64\x61pr.proto.runtime.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a!dapr/proto/common/v1/common.proto\x1a\"dapr/proto/runtime/v1/actors.proto\x1a\"dapr/proto/runtime/v1/pubsub.proto\x1a\"dapr/proto/runtime/v1/invoke.proto\x1a!dapr/proto/runtime/v1/state.proto\x1a#dapr/proto/runtime/v1/binding.proto\x1a\"dapr/proto/runtime/v1/secret.proto\x1a$dapr/proto/runtime/v1/metadata.proto\x1a)dapr/proto/runtime/v1/configuration.proto\x1a dapr/proto/runtime/v1/lock.proto\x1a\"dapr/proto/runtime/v1/crypto.proto\x1a$dapr/proto/runtime/v1/workflow.proto\x1a dapr/proto/runtime/v1/jobs.proto\x1a\x1e\x64\x61pr/proto/runtime/v1/ai.proto\"\x11\n\x0fShutdownRequest2\xc8?\n\x04\x44\x61pr\x12\x64\n\rInvokeService\x12+.dapr.proto.runtime.v1.InvokeServiceRequest\x1a$.dapr.proto.common.v1.InvokeResponse\"\x00\x12]\n\x08GetState\x12&.dapr.proto.runtime.v1.GetStateRequest\x1a\'.dapr.proto.runtime.v1.GetStateResponse\"\x00\x12i\n\x0cGetBulkState\x12*.dapr.proto.runtime.v1.GetBulkStateRequest\x1a+.dapr.proto.runtime.v1.GetBulkStateResponse\"\x00\x12N\n\tSaveState\x12\'.dapr.proto.runtime.v1.SaveStateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12i\n\x10QueryStateAlpha1\x12(.dapr.proto.runtime.v1.QueryStateRequest\x1a).dapr.proto.runtime.v1.QueryStateResponse\"\x00\x12R\n\x0b\x44\x65leteState\x12).dapr.proto.runtime.v1.DeleteStateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12Z\n\x0f\x44\x65leteBulkState\x12-.dapr.proto.runtime.v1.DeleteBulkStateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12j\n\x17\x45xecuteStateTransaction\x12\x35.dapr.proto.runtime.v1.ExecuteStateTransactionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12T\n\x0cPublishEvent\x12*.dapr.proto.runtime.v1.PublishEventRequest\x1a\x16.google.protobuf.Empty\"\x00\x12t\n\x16\x42ulkPublishEventAlpha1\x12).dapr.proto.runtime.v1.BulkPublishRequest\x1a*.dapr.proto.runtime.v1.BulkPublishResponse\"\x03\x88\x02\x01\x12k\n\x10\x42ulkPublishEvent\x12).dapr.proto.runtime.v1.BulkPublishRequest\x1a*.dapr.proto.runtime.v1.BulkPublishResponse\"\x00\x12\x97\x01\n\x1aSubscribeTopicEventsAlpha1\x12\x38.dapr.proto.runtime.v1.SubscribeTopicEventsRequestAlpha1\x1a\x39.dapr.proto.runtime.v1.SubscribeTopicEventsResponseAlpha1\"\x00(\x01\x30\x01\x12l\n\rInvokeBinding\x12+.dapr.proto.runtime.v1.InvokeBindingRequest\x1a,.dapr.proto.runtime.v1.InvokeBindingResponse\"\x00\x12`\n\tGetSecret\x12\'.dapr.proto.runtime.v1.GetSecretRequest\x1a(.dapr.proto.runtime.v1.GetSecretResponse\"\x00\x12l\n\rGetBulkSecret\x12+.dapr.proto.runtime.v1.GetBulkSecretRequest\x1a,.dapr.proto.runtime.v1.GetBulkSecretResponse\"\x00\x12`\n\x12RegisterActorTimer\x12\x30.dapr.proto.runtime.v1.RegisterActorTimerRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x64\n\x14UnregisterActorTimer\x12\x32.dapr.proto.runtime.v1.UnregisterActorTimerRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x66\n\x15RegisterActorReminder\x12\x33.dapr.proto.runtime.v1.RegisterActorReminderRequest\x1a\x16.google.protobuf.Empty\"\x00\x12j\n\x17UnregisterActorReminder\x12\x35.dapr.proto.runtime.v1.UnregisterActorReminderRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x9f\x01\n\x1eUnregisterActorRemindersByType\x12<.dapr.proto.runtime.v1.UnregisterActorRemindersByTypeRequest\x1a=.dapr.proto.runtime.v1.UnregisterActorRemindersByTypeResponse\"\x00\x12{\n\x12ListActorReminders\x12\x30.dapr.proto.runtime.v1.ListActorRemindersRequest\x1a\x31.dapr.proto.runtime.v1.ListActorRemindersResponse\"\x00\x12r\n\x0fListActorTimers\x12-.dapr.proto.runtime.v1.ListActorTimersRequest\x1a..dapr.proto.runtime.v1.ListActorTimersResponse\"\x00\x12l\n\rGetActorState\x12+.dapr.proto.runtime.v1.GetActorStateRequest\x1a,.dapr.proto.runtime.v1.GetActorStateResponse\"\x00\x12u\n\x10GetActorReminder\x12..dapr.proto.runtime.v1.GetActorReminderRequest\x1a/.dapr.proto.runtime.v1.GetActorReminderResponse\"\x00\x12l\n\rGetActorTimer\x12+.dapr.proto.runtime.v1.GetActorTimerRequest\x1a,.dapr.proto.runtime.v1.GetActorTimerResponse\"\x00\x12t\n\x1c\x45xecuteActorStateTransaction\x12:.dapr.proto.runtime.v1.ExecuteActorStateTransactionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x66\n\x0bInvokeActor\x12).dapr.proto.runtime.v1.InvokeActorRequest\x1a*.dapr.proto.runtime.v1.InvokeActorResponse\"\x00\x12\x97\x01\n\x1aSubscribeActorEventsAlpha1\x12\x38.dapr.proto.runtime.v1.SubscribeActorEventsRequestAlpha1\x1a\x39.dapr.proto.runtime.v1.SubscribeActorEventsResponseAlpha1\"\x00(\x01\x30\x01\x12{\n\x16GetConfigurationAlpha1\x12..dapr.proto.runtime.v1.GetConfigurationRequest\x1a/.dapr.proto.runtime.v1.GetConfigurationResponse\"\x00\x12u\n\x10GetConfiguration\x12..dapr.proto.runtime.v1.GetConfigurationRequest\x1a/.dapr.proto.runtime.v1.GetConfigurationResponse\"\x00\x12\x8f\x01\n\x1cSubscribeConfigurationAlpha1\x12\x34.dapr.proto.runtime.v1.SubscribeConfigurationRequest\x1a\x35.dapr.proto.runtime.v1.SubscribeConfigurationResponse\"\x00\x30\x01\x12\x89\x01\n\x16SubscribeConfiguration\x12\x34.dapr.proto.runtime.v1.SubscribeConfigurationRequest\x1a\x35.dapr.proto.runtime.v1.SubscribeConfigurationResponse\"\x00\x30\x01\x12\x93\x01\n\x1eUnsubscribeConfigurationAlpha1\x12\x36.dapr.proto.runtime.v1.UnsubscribeConfigurationRequest\x1a\x37.dapr.proto.runtime.v1.UnsubscribeConfigurationResponse\"\x00\x12\x8d\x01\n\x18UnsubscribeConfiguration\x12\x36.dapr.proto.runtime.v1.UnsubscribeConfigurationRequest\x1a\x37.dapr.proto.runtime.v1.UnsubscribeConfigurationResponse\"\x00\x12`\n\rTryLockAlpha1\x12%.dapr.proto.runtime.v1.TryLockRequest\x1a&.dapr.proto.runtime.v1.TryLockResponse\"\x00\x12]\n\x0cUnlockAlpha1\x12$.dapr.proto.runtime.v1.UnlockRequest\x1a%.dapr.proto.runtime.v1.UnlockResponse\"\x00\x12\x62\n\rEncryptAlpha1\x12%.dapr.proto.runtime.v1.EncryptRequest\x1a&.dapr.proto.runtime.v1.EncryptResponse(\x01\x30\x01\x12\x62\n\rDecryptAlpha1\x12%.dapr.proto.runtime.v1.DecryptRequest\x1a&.dapr.proto.runtime.v1.DecryptResponse(\x01\x30\x01\x12\x66\n\x0bGetMetadata\x12).dapr.proto.runtime.v1.GetMetadataRequest\x1a*.dapr.proto.runtime.v1.GetMetadataResponse\"\x00\x12R\n\x0bSetMetadata\x12).dapr.proto.runtime.v1.SetMetadataRequest\x1a\x16.google.protobuf.Empty\"\x00\x12m\n\x12SubtleGetKeyAlpha1\x12*.dapr.proto.runtime.v1.SubtleGetKeyRequest\x1a+.dapr.proto.runtime.v1.SubtleGetKeyResponse\x12p\n\x13SubtleEncryptAlpha1\x12+.dapr.proto.runtime.v1.SubtleEncryptRequest\x1a,.dapr.proto.runtime.v1.SubtleEncryptResponse\x12p\n\x13SubtleDecryptAlpha1\x12+.dapr.proto.runtime.v1.SubtleDecryptRequest\x1a,.dapr.proto.runtime.v1.SubtleDecryptResponse\x12p\n\x13SubtleWrapKeyAlpha1\x12+.dapr.proto.runtime.v1.SubtleWrapKeyRequest\x1a,.dapr.proto.runtime.v1.SubtleWrapKeyResponse\x12v\n\x15SubtleUnwrapKeyAlpha1\x12-.dapr.proto.runtime.v1.SubtleUnwrapKeyRequest\x1a..dapr.proto.runtime.v1.SubtleUnwrapKeyResponse\x12g\n\x10SubtleSignAlpha1\x12(.dapr.proto.runtime.v1.SubtleSignRequest\x1a).dapr.proto.runtime.v1.SubtleSignResponse\x12m\n\x12SubtleVerifyAlpha1\x12*.dapr.proto.runtime.v1.SubtleVerifyRequest\x1a+.dapr.proto.runtime.v1.SubtleVerifyResponse\x12u\n\x13StartWorkflowAlpha1\x12+.dapr.proto.runtime.v1.StartWorkflowRequest\x1a,.dapr.proto.runtime.v1.StartWorkflowResponse\"\x03\x88\x02\x01\x12o\n\x11GetWorkflowAlpha1\x12).dapr.proto.runtime.v1.GetWorkflowRequest\x1a*.dapr.proto.runtime.v1.GetWorkflowResponse\"\x03\x88\x02\x01\x12_\n\x13PurgeWorkflowAlpha1\x12+.dapr.proto.runtime.v1.PurgeWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12g\n\x17TerminateWorkflowAlpha1\x12/.dapr.proto.runtime.v1.TerminateWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12_\n\x13PauseWorkflowAlpha1\x12+.dapr.proto.runtime.v1.PauseWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12\x61\n\x14ResumeWorkflowAlpha1\x12,.dapr.proto.runtime.v1.ResumeWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12i\n\x18RaiseEventWorkflowAlpha1\x12\x30.dapr.proto.runtime.v1.RaiseEventWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12q\n\x12StartWorkflowBeta1\x12+.dapr.proto.runtime.v1.StartWorkflowRequest\x1a,.dapr.proto.runtime.v1.StartWorkflowResponse\"\x00\x12k\n\x10GetWorkflowBeta1\x12).dapr.proto.runtime.v1.GetWorkflowRequest\x1a*.dapr.proto.runtime.v1.GetWorkflowResponse\"\x00\x12[\n\x12PurgeWorkflowBeta1\x12+.dapr.proto.runtime.v1.PurgeWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x63\n\x16TerminateWorkflowBeta1\x12/.dapr.proto.runtime.v1.TerminateWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12[\n\x12PauseWorkflowBeta1\x12+.dapr.proto.runtime.v1.PauseWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12]\n\x13ResumeWorkflowBeta1\x12,.dapr.proto.runtime.v1.ResumeWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x65\n\x17RaiseEventWorkflowBeta1\x12\x30.dapr.proto.runtime.v1.RaiseEventWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12L\n\x08Shutdown\x12&.dapr.proto.runtime.v1.ShutdownRequest\x1a\x16.google.protobuf.Empty\"\x00\x12o\n\x11ScheduleJobAlpha1\x12).dapr.proto.runtime.v1.ScheduleJobRequest\x1a*.dapr.proto.runtime.v1.ScheduleJobResponse\"\x03\x88\x02\x01\x12\x66\n\x0bScheduleJob\x12).dapr.proto.runtime.v1.ScheduleJobRequest\x1a*.dapr.proto.runtime.v1.ScheduleJobResponse\"\x00\x12`\n\x0cGetJobAlpha1\x12$.dapr.proto.runtime.v1.GetJobRequest\x1a%.dapr.proto.runtime.v1.GetJobResponse\"\x03\x88\x02\x01\x12W\n\x06GetJob\x12$.dapr.proto.runtime.v1.GetJobRequest\x1a%.dapr.proto.runtime.v1.GetJobResponse\"\x00\x12i\n\x0f\x44\x65leteJobAlpha1\x12\'.dapr.proto.runtime.v1.DeleteJobRequest\x1a(.dapr.proto.runtime.v1.DeleteJobResponse\"\x03\x88\x02\x01\x12`\n\tDeleteJob\x12\'.dapr.proto.runtime.v1.DeleteJobRequest\x1a(.dapr.proto.runtime.v1.DeleteJobResponse\"\x00\x12\x90\x01\n\x18\x44\x65leteJobsByPrefixAlpha1\x12\x36.dapr.proto.runtime.v1.DeleteJobsByPrefixRequestAlpha1\x1a\x37.dapr.proto.runtime.v1.DeleteJobsByPrefixResponseAlpha1\"\x03\x88\x02\x01\x12{\n\x12\x44\x65leteJobsByPrefix\x12\x30.dapr.proto.runtime.v1.DeleteJobsByPrefixRequest\x1a\x31.dapr.proto.runtime.v1.DeleteJobsByPrefixResponse\"\x00\x12r\n\x0eListJobsAlpha1\x12,.dapr.proto.runtime.v1.ListJobsRequestAlpha1\x1a-.dapr.proto.runtime.v1.ListJobsResponseAlpha1\"\x03\x88\x02\x01\x12]\n\x08ListJobs\x12&.dapr.proto.runtime.v1.ListJobsRequest\x1a\'.dapr.proto.runtime.v1.ListJobsResponse\"\x00\x12k\n\x0e\x43onverseAlpha1\x12*.dapr.proto.runtime.v1.ConversationRequest\x1a+.dapr.proto.runtime.v1.ConversationResponse\"\x00\x12w\n\x0e\x43onverseAlpha2\x12\x30.dapr.proto.runtime.v1.ConversationRequestAlpha2\x1a\x31.dapr.proto.runtime.v1.ConversationResponseAlpha2\"\x00\x42i\n\nio.dapr.v1B\nDaprProtosZ1github.com/dapr/dapr/pkg/proto/runtime/v1;runtime\xaa\x02\x1b\x44\x61pr.Client.Autogen.Grpc.v1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n dapr/proto/runtime/v1/dapr.proto\x12\x15\x64\x61pr.proto.runtime.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a!dapr/proto/common/v1/common.proto\x1a\"dapr/proto/runtime/v1/actors.proto\x1a\"dapr/proto/runtime/v1/pubsub.proto\x1a\"dapr/proto/runtime/v1/invoke.proto\x1a!dapr/proto/runtime/v1/state.proto\x1a#dapr/proto/runtime/v1/binding.proto\x1a\"dapr/proto/runtime/v1/secret.proto\x1a$dapr/proto/runtime/v1/metadata.proto\x1a)dapr/proto/runtime/v1/configuration.proto\x1a dapr/proto/runtime/v1/lock.proto\x1a\"dapr/proto/runtime/v1/crypto.proto\x1a$dapr/proto/runtime/v1/workflow.proto\x1a dapr/proto/runtime/v1/jobs.proto\x1a\x1e\x64\x61pr/proto/runtime/v1/ai.proto\x1a\'dapr/proto/runtime/v1/binarystore.proto\"\x11\n\x0fShutdownRequest2\xd8\x42\n\x04\x44\x61pr\x12\x64\n\rInvokeService\x12+.dapr.proto.runtime.v1.InvokeServiceRequest\x1a$.dapr.proto.common.v1.InvokeResponse\"\x00\x12]\n\x08GetState\x12&.dapr.proto.runtime.v1.GetStateRequest\x1a\'.dapr.proto.runtime.v1.GetStateResponse\"\x00\x12i\n\x0cGetBulkState\x12*.dapr.proto.runtime.v1.GetBulkStateRequest\x1a+.dapr.proto.runtime.v1.GetBulkStateResponse\"\x00\x12N\n\tSaveState\x12\'.dapr.proto.runtime.v1.SaveStateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12i\n\x10QueryStateAlpha1\x12(.dapr.proto.runtime.v1.QueryStateRequest\x1a).dapr.proto.runtime.v1.QueryStateResponse\"\x00\x12R\n\x0b\x44\x65leteState\x12).dapr.proto.runtime.v1.DeleteStateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12Z\n\x0f\x44\x65leteBulkState\x12-.dapr.proto.runtime.v1.DeleteBulkStateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12j\n\x17\x45xecuteStateTransaction\x12\x35.dapr.proto.runtime.v1.ExecuteStateTransactionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12T\n\x0cPublishEvent\x12*.dapr.proto.runtime.v1.PublishEventRequest\x1a\x16.google.protobuf.Empty\"\x00\x12t\n\x16\x42ulkPublishEventAlpha1\x12).dapr.proto.runtime.v1.BulkPublishRequest\x1a*.dapr.proto.runtime.v1.BulkPublishResponse\"\x03\x88\x02\x01\x12k\n\x10\x42ulkPublishEvent\x12).dapr.proto.runtime.v1.BulkPublishRequest\x1a*.dapr.proto.runtime.v1.BulkPublishResponse\"\x00\x12\x97\x01\n\x1aSubscribeTopicEventsAlpha1\x12\x38.dapr.proto.runtime.v1.SubscribeTopicEventsRequestAlpha1\x1a\x39.dapr.proto.runtime.v1.SubscribeTopicEventsResponseAlpha1\"\x00(\x01\x30\x01\x12l\n\rInvokeBinding\x12+.dapr.proto.runtime.v1.InvokeBindingRequest\x1a,.dapr.proto.runtime.v1.InvokeBindingResponse\"\x00\x12`\n\tGetSecret\x12\'.dapr.proto.runtime.v1.GetSecretRequest\x1a(.dapr.proto.runtime.v1.GetSecretResponse\"\x00\x12l\n\rGetBulkSecret\x12+.dapr.proto.runtime.v1.GetBulkSecretRequest\x1a,.dapr.proto.runtime.v1.GetBulkSecretResponse\"\x00\x12`\n\x12RegisterActorTimer\x12\x30.dapr.proto.runtime.v1.RegisterActorTimerRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x64\n\x14UnregisterActorTimer\x12\x32.dapr.proto.runtime.v1.UnregisterActorTimerRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x66\n\x15RegisterActorReminder\x12\x33.dapr.proto.runtime.v1.RegisterActorReminderRequest\x1a\x16.google.protobuf.Empty\"\x00\x12j\n\x17UnregisterActorReminder\x12\x35.dapr.proto.runtime.v1.UnregisterActorReminderRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x9f\x01\n\x1eUnregisterActorRemindersByType\x12<.dapr.proto.runtime.v1.UnregisterActorRemindersByTypeRequest\x1a=.dapr.proto.runtime.v1.UnregisterActorRemindersByTypeResponse\"\x00\x12{\n\x12ListActorReminders\x12\x30.dapr.proto.runtime.v1.ListActorRemindersRequest\x1a\x31.dapr.proto.runtime.v1.ListActorRemindersResponse\"\x00\x12r\n\x0fListActorTimers\x12-.dapr.proto.runtime.v1.ListActorTimersRequest\x1a..dapr.proto.runtime.v1.ListActorTimersResponse\"\x00\x12l\n\rGetActorState\x12+.dapr.proto.runtime.v1.GetActorStateRequest\x1a,.dapr.proto.runtime.v1.GetActorStateResponse\"\x00\x12u\n\x10GetActorReminder\x12..dapr.proto.runtime.v1.GetActorReminderRequest\x1a/.dapr.proto.runtime.v1.GetActorReminderResponse\"\x00\x12l\n\rGetActorTimer\x12+.dapr.proto.runtime.v1.GetActorTimerRequest\x1a,.dapr.proto.runtime.v1.GetActorTimerResponse\"\x00\x12t\n\x1c\x45xecuteActorStateTransaction\x12:.dapr.proto.runtime.v1.ExecuteActorStateTransactionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x66\n\x0bInvokeActor\x12).dapr.proto.runtime.v1.InvokeActorRequest\x1a*.dapr.proto.runtime.v1.InvokeActorResponse\"\x00\x12\x97\x01\n\x1aSubscribeActorEventsAlpha1\x12\x38.dapr.proto.runtime.v1.SubscribeActorEventsRequestAlpha1\x1a\x39.dapr.proto.runtime.v1.SubscribeActorEventsResponseAlpha1\"\x00(\x01\x30\x01\x12{\n\x16GetConfigurationAlpha1\x12..dapr.proto.runtime.v1.GetConfigurationRequest\x1a/.dapr.proto.runtime.v1.GetConfigurationResponse\"\x00\x12u\n\x10GetConfiguration\x12..dapr.proto.runtime.v1.GetConfigurationRequest\x1a/.dapr.proto.runtime.v1.GetConfigurationResponse\"\x00\x12\x8f\x01\n\x1cSubscribeConfigurationAlpha1\x12\x34.dapr.proto.runtime.v1.SubscribeConfigurationRequest\x1a\x35.dapr.proto.runtime.v1.SubscribeConfigurationResponse\"\x00\x30\x01\x12\x89\x01\n\x16SubscribeConfiguration\x12\x34.dapr.proto.runtime.v1.SubscribeConfigurationRequest\x1a\x35.dapr.proto.runtime.v1.SubscribeConfigurationResponse\"\x00\x30\x01\x12\x93\x01\n\x1eUnsubscribeConfigurationAlpha1\x12\x36.dapr.proto.runtime.v1.UnsubscribeConfigurationRequest\x1a\x37.dapr.proto.runtime.v1.UnsubscribeConfigurationResponse\"\x00\x12\x8d\x01\n\x18UnsubscribeConfiguration\x12\x36.dapr.proto.runtime.v1.UnsubscribeConfigurationRequest\x1a\x37.dapr.proto.runtime.v1.UnsubscribeConfigurationResponse\"\x00\x12`\n\rTryLockAlpha1\x12%.dapr.proto.runtime.v1.TryLockRequest\x1a&.dapr.proto.runtime.v1.TryLockResponse\"\x00\x12]\n\x0cUnlockAlpha1\x12$.dapr.proto.runtime.v1.UnlockRequest\x1a%.dapr.proto.runtime.v1.UnlockResponse\"\x00\x12\x62\n\rEncryptAlpha1\x12%.dapr.proto.runtime.v1.EncryptRequest\x1a&.dapr.proto.runtime.v1.EncryptResponse(\x01\x30\x01\x12\x62\n\rDecryptAlpha1\x12%.dapr.proto.runtime.v1.DecryptRequest\x1a&.dapr.proto.runtime.v1.DecryptResponse(\x01\x30\x01\x12\x66\n\x0bGetMetadata\x12).dapr.proto.runtime.v1.GetMetadataRequest\x1a*.dapr.proto.runtime.v1.GetMetadataResponse\"\x00\x12R\n\x0bSetMetadata\x12).dapr.proto.runtime.v1.SetMetadataRequest\x1a\x16.google.protobuf.Empty\"\x00\x12m\n\x12SubtleGetKeyAlpha1\x12*.dapr.proto.runtime.v1.SubtleGetKeyRequest\x1a+.dapr.proto.runtime.v1.SubtleGetKeyResponse\x12p\n\x13SubtleEncryptAlpha1\x12+.dapr.proto.runtime.v1.SubtleEncryptRequest\x1a,.dapr.proto.runtime.v1.SubtleEncryptResponse\x12p\n\x13SubtleDecryptAlpha1\x12+.dapr.proto.runtime.v1.SubtleDecryptRequest\x1a,.dapr.proto.runtime.v1.SubtleDecryptResponse\x12p\n\x13SubtleWrapKeyAlpha1\x12+.dapr.proto.runtime.v1.SubtleWrapKeyRequest\x1a,.dapr.proto.runtime.v1.SubtleWrapKeyResponse\x12v\n\x15SubtleUnwrapKeyAlpha1\x12-.dapr.proto.runtime.v1.SubtleUnwrapKeyRequest\x1a..dapr.proto.runtime.v1.SubtleUnwrapKeyResponse\x12g\n\x10SubtleSignAlpha1\x12(.dapr.proto.runtime.v1.SubtleSignRequest\x1a).dapr.proto.runtime.v1.SubtleSignResponse\x12m\n\x12SubtleVerifyAlpha1\x12*.dapr.proto.runtime.v1.SubtleVerifyRequest\x1a+.dapr.proto.runtime.v1.SubtleVerifyResponse\x12u\n\x13StartWorkflowAlpha1\x12+.dapr.proto.runtime.v1.StartWorkflowRequest\x1a,.dapr.proto.runtime.v1.StartWorkflowResponse\"\x03\x88\x02\x01\x12o\n\x11GetWorkflowAlpha1\x12).dapr.proto.runtime.v1.GetWorkflowRequest\x1a*.dapr.proto.runtime.v1.GetWorkflowResponse\"\x03\x88\x02\x01\x12_\n\x13PurgeWorkflowAlpha1\x12+.dapr.proto.runtime.v1.PurgeWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12g\n\x17TerminateWorkflowAlpha1\x12/.dapr.proto.runtime.v1.TerminateWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12_\n\x13PauseWorkflowAlpha1\x12+.dapr.proto.runtime.v1.PauseWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12\x61\n\x14ResumeWorkflowAlpha1\x12,.dapr.proto.runtime.v1.ResumeWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12i\n\x18RaiseEventWorkflowAlpha1\x12\x30.dapr.proto.runtime.v1.RaiseEventWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x03\x88\x02\x01\x12q\n\x12StartWorkflowBeta1\x12+.dapr.proto.runtime.v1.StartWorkflowRequest\x1a,.dapr.proto.runtime.v1.StartWorkflowResponse\"\x00\x12k\n\x10GetWorkflowBeta1\x12).dapr.proto.runtime.v1.GetWorkflowRequest\x1a*.dapr.proto.runtime.v1.GetWorkflowResponse\"\x00\x12[\n\x12PurgeWorkflowBeta1\x12+.dapr.proto.runtime.v1.PurgeWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x63\n\x16TerminateWorkflowBeta1\x12/.dapr.proto.runtime.v1.TerminateWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12[\n\x12PauseWorkflowBeta1\x12+.dapr.proto.runtime.v1.PauseWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12]\n\x13ResumeWorkflowBeta1\x12,.dapr.proto.runtime.v1.ResumeWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x65\n\x17RaiseEventWorkflowBeta1\x12\x30.dapr.proto.runtime.v1.RaiseEventWorkflowRequest\x1a\x16.google.protobuf.Empty\"\x00\x12L\n\x08Shutdown\x12&.dapr.proto.runtime.v1.ShutdownRequest\x1a\x16.google.protobuf.Empty\"\x00\x12o\n\x11ScheduleJobAlpha1\x12).dapr.proto.runtime.v1.ScheduleJobRequest\x1a*.dapr.proto.runtime.v1.ScheduleJobResponse\"\x03\x88\x02\x01\x12\x66\n\x0bScheduleJob\x12).dapr.proto.runtime.v1.ScheduleJobRequest\x1a*.dapr.proto.runtime.v1.ScheduleJobResponse\"\x00\x12`\n\x0cGetJobAlpha1\x12$.dapr.proto.runtime.v1.GetJobRequest\x1a%.dapr.proto.runtime.v1.GetJobResponse\"\x03\x88\x02\x01\x12W\n\x06GetJob\x12$.dapr.proto.runtime.v1.GetJobRequest\x1a%.dapr.proto.runtime.v1.GetJobResponse\"\x00\x12i\n\x0f\x44\x65leteJobAlpha1\x12\'.dapr.proto.runtime.v1.DeleteJobRequest\x1a(.dapr.proto.runtime.v1.DeleteJobResponse\"\x03\x88\x02\x01\x12`\n\tDeleteJob\x12\'.dapr.proto.runtime.v1.DeleteJobRequest\x1a(.dapr.proto.runtime.v1.DeleteJobResponse\"\x00\x12\x90\x01\n\x18\x44\x65leteJobsByPrefixAlpha1\x12\x36.dapr.proto.runtime.v1.DeleteJobsByPrefixRequestAlpha1\x1a\x37.dapr.proto.runtime.v1.DeleteJobsByPrefixResponseAlpha1\"\x03\x88\x02\x01\x12{\n\x12\x44\x65leteJobsByPrefix\x12\x30.dapr.proto.runtime.v1.DeleteJobsByPrefixRequest\x1a\x31.dapr.proto.runtime.v1.DeleteJobsByPrefixResponse\"\x00\x12r\n\x0eListJobsAlpha1\x12,.dapr.proto.runtime.v1.ListJobsRequestAlpha1\x1a-.dapr.proto.runtime.v1.ListJobsResponseAlpha1\"\x03\x88\x02\x01\x12]\n\x08ListJobs\x12&.dapr.proto.runtime.v1.ListJobsRequest\x1a\'.dapr.proto.runtime.v1.ListJobsResponse\"\x00\x12k\n\x0e\x43onverseAlpha1\x12*.dapr.proto.runtime.v1.ConversationRequest\x1a+.dapr.proto.runtime.v1.ConversationResponse\"\x00\x12w\n\x0e\x43onverseAlpha2\x12\x30.dapr.proto.runtime.v1.ConversationRequestAlpha2\x1a\x31.dapr.proto.runtime.v1.ConversationResponseAlpha2\"\x00\x12\x80\x01\n\x13SetBinaryFileAlpha1\x12\x31.dapr.proto.runtime.v1.SetBinaryFileRequestAlpha1\x1a\x32.dapr.proto.runtime.v1.SetBinaryFileResponseAlpha1\"\x00(\x01\x12\x80\x01\n\x13GetBinaryFileAlpha1\x12\x31.dapr.proto.runtime.v1.GetBinaryFileRequestAlpha1\x1a\x32.dapr.proto.runtime.v1.GetBinaryFileResponseAlpha1\"\x00\x30\x01\x12\x87\x01\n\x16\x44\x65leteBinaryFileAlpha1\x12\x34.dapr.proto.runtime.v1.DeleteBinaryFileRequestAlpha1\x1a\x35.dapr.proto.runtime.v1.DeleteBinaryFileResponseAlpha1\"\x00\x42i\n\nio.dapr.v1B\nDaprProtosZ1github.com/dapr/dapr/pkg/proto/runtime/v1;runtime\xaa\x02\x1b\x44\x61pr.Client.Autogen.Grpc.v1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -73,8 +74,8 @@ _globals['_DAPR'].methods_by_name['DeleteJobsByPrefixAlpha1']._serialized_options = b'\210\002\001' _globals['_DAPR'].methods_by_name['ListJobsAlpha1']._loaded_options = None _globals['_DAPR'].methods_by_name['ListJobsAlpha1']._serialized_options = b'\210\002\001' - _globals['_SHUTDOWNREQUEST']._serialized_start=594 - _globals['_SHUTDOWNREQUEST']._serialized_end=611 - _globals['_DAPR']._serialized_start=614 - _globals['_DAPR']._serialized_end=8750 + _globals['_SHUTDOWNREQUEST']._serialized_start=635 + _globals['_SHUTDOWNREQUEST']._serialized_end=652 + _globals['_DAPR']._serialized_start=655 + _globals['_DAPR']._serialized_end=9191 # @@protoc_insertion_point(module_scope) diff --git a/dapr/proto/runtime/v1/dapr_pb2_grpc.py b/dapr/proto/runtime/v1/dapr_pb2_grpc.py index aa5618a69..c2db40c1a 100644 --- a/dapr/proto/runtime/v1/dapr_pb2_grpc.py +++ b/dapr/proto/runtime/v1/dapr_pb2_grpc.py @@ -6,6 +6,7 @@ from dapr.proto.common.v1 import common_pb2 as dapr_dot_proto_dot_common_dot_v1_dot_common__pb2 from dapr.proto.runtime.v1 import actors_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_actors__pb2 from dapr.proto.runtime.v1 import ai_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_ai__pb2 +from dapr.proto.runtime.v1 import binarystore_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2 from dapr.proto.runtime.v1 import binding_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_binding__pb2 from dapr.proto.runtime.v1 import configuration_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_configuration__pb2 from dapr.proto.runtime.v1 import crypto_pb2 as dapr_dot_proto_dot_runtime_dot_v1_dot_crypto__pb2 @@ -420,6 +421,21 @@ def __init__(self, channel): request_serializer=dapr_dot_proto_dot_runtime_dot_v1_dot_ai__pb2.ConversationRequestAlpha2.SerializeToString, response_deserializer=dapr_dot_proto_dot_runtime_dot_v1_dot_ai__pb2.ConversationResponseAlpha2.FromString, _registered_method=True) + self.SetBinaryFileAlpha1 = channel.stream_unary( + '/dapr.proto.runtime.v1.Dapr/SetBinaryFileAlpha1', + request_serializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.SetBinaryFileRequestAlpha1.SerializeToString, + response_deserializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.SetBinaryFileResponseAlpha1.FromString, + _registered_method=True) + self.GetBinaryFileAlpha1 = channel.unary_stream( + '/dapr.proto.runtime.v1.Dapr/GetBinaryFileAlpha1', + request_serializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.GetBinaryFileRequestAlpha1.SerializeToString, + response_deserializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.GetBinaryFileResponseAlpha1.FromString, + _registered_method=True) + self.DeleteBinaryFileAlpha1 = channel.unary_unary( + '/dapr.proto.runtime.v1.Dapr/DeleteBinaryFileAlpha1', + request_serializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.DeleteBinaryFileRequestAlpha1.SerializeToString, + response_deserializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.DeleteBinaryFileResponseAlpha1.FromString, + _registered_method=True) class DaprServicer: @@ -949,6 +965,28 @@ def ConverseAlpha2(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def SetBinaryFileAlpha1(self, request_iterator, context): + """Stores a binary file using a streaming request. The first message must + contain the options; subsequent messages carry the file content as chunks. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def GetBinaryFileAlpha1(self, request, context): + """Retrieves a binary file as a stream of chunks. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def DeleteBinaryFileAlpha1(self, request, context): + """Deletes a binary file. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_DaprServicer_to_server(servicer, server): rpc_method_handlers = { @@ -1322,6 +1360,21 @@ def add_DaprServicer_to_server(servicer, server): request_deserializer=dapr_dot_proto_dot_runtime_dot_v1_dot_ai__pb2.ConversationRequestAlpha2.FromString, response_serializer=dapr_dot_proto_dot_runtime_dot_v1_dot_ai__pb2.ConversationResponseAlpha2.SerializeToString, ), + 'SetBinaryFileAlpha1': grpc.stream_unary_rpc_method_handler( + servicer.SetBinaryFileAlpha1, + request_deserializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.SetBinaryFileRequestAlpha1.FromString, + response_serializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.SetBinaryFileResponseAlpha1.SerializeToString, + ), + 'GetBinaryFileAlpha1': grpc.unary_stream_rpc_method_handler( + servicer.GetBinaryFileAlpha1, + request_deserializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.GetBinaryFileRequestAlpha1.FromString, + response_serializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.GetBinaryFileResponseAlpha1.SerializeToString, + ), + 'DeleteBinaryFileAlpha1': grpc.unary_unary_rpc_method_handler( + servicer.DeleteBinaryFileAlpha1, + request_deserializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.DeleteBinaryFileRequestAlpha1.FromString, + response_serializer=dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.DeleteBinaryFileResponseAlpha1.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'dapr.proto.runtime.v1.Dapr', rpc_method_handlers) @@ -3331,3 +3384,84 @@ def ConverseAlpha2(request, timeout, metadata, _registered_method=True) + + @staticmethod + def SetBinaryFileAlpha1(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_unary( + request_iterator, + target, + '/dapr.proto.runtime.v1.Dapr/SetBinaryFileAlpha1', + dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.SetBinaryFileRequestAlpha1.SerializeToString, + dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.SetBinaryFileResponseAlpha1.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def GetBinaryFileAlpha1(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/dapr.proto.runtime.v1.Dapr/GetBinaryFileAlpha1', + dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.GetBinaryFileRequestAlpha1.SerializeToString, + dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.GetBinaryFileResponseAlpha1.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def DeleteBinaryFileAlpha1(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/dapr.proto.runtime.v1.Dapr/DeleteBinaryFileAlpha1', + dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.DeleteBinaryFileRequestAlpha1.SerializeToString, + dapr_dot_proto_dot_runtime_dot_v1_dot_binarystore__pb2.DeleteBinaryFileResponseAlpha1.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/tools/regen_grpcclient.sh b/tools/regen_grpcclient.sh index 13d669400..6280c6ebd 100755 --- a/tools/regen_grpcclient.sh +++ b/tools/regen_grpcclient.sh @@ -13,8 +13,11 @@ # limitations under the License. # ------------------------------------------------------------ -# Path to store output +set -euo pipefail + +# DAPR_REF accepts a branch, tag or commit; retain DAPR_BRANCH for existing callers. DAPR_BRANCH=${DAPR_BRANCH:-master} +DAPR_REF=${DAPR_REF:-$DAPR_BRANCH} if type "curl" > /dev/null; then HTTP_REQUEST_CLI=curl @@ -27,15 +30,15 @@ fi target="$(pwd)" -tmp="$(mktemp -d dapr-protos)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/dapr-protos.XXXXXX")" trap 'rm -rf "$tmp"' EXIT -url="https://github.com/dapr/dapr/archive/refs/heads/${DAPR_BRANCH}.tar.gz" +url="https://github.com/dapr/dapr/archive/${DAPR_REF}.tar.gz" pushd "$tmp" echo "Downloading Dapr from $url..." if [ "$HTTP_REQUEST_CLI" == "curl" ]; then - curl -SsL "$url" -o - | tar --strip-components=1 -xzf - + curl -fsSL "$url" | tar --strip-components=1 -xzf - else wget -q -O - "$url" | tar --strip-components=1 -xzf - fi @@ -48,5 +51,5 @@ done < <(find "dapr/proto/runtime/v1" -name '*.proto' -print0) popd -python3 -m grpc_tools.protoc -I ${target} --proto_path="${tmp}" --python_out=${target} --grpc_python_out=${target} --mypy_out=${target} ${files[@]} +python3 -m grpc_tools.protoc -I "$target" --proto_path="$tmp" --python_out="$target" --grpc_python_out="$target" --mypy_out="$target" "${files[@]}" echo -e "\ngRPC interface and proto buf generated successfully!" From f0b8e47cacbe35766230c975dacdfd302a52f68b Mon Sep 17 00:00:00 2001 From: Adepu Sri Charan Date: Wed, 21 Oct 2026 16:49:53 +0530 Subject: [PATCH 2/3] feat(clients): add Binary Store alpha APIs Add synchronous and asynchronous streaming file operations with overwrite, deadlines, authentication, examples and regression coverage. Signed-off-by: Adepu Sri Charan --- .github/workflows/run-tests.yaml | 51 +++ README.md | 3 + dapr/aio/clients/grpc/_binarystore.py | 161 ++++++++ dapr/aio/clients/grpc/client.py | 131 ++++++- dapr/aio/clients/grpc/interceptors.py | 27 ++ dapr/clients/grpc/_binarystore.py | 202 ++++++++++ dapr/clients/grpc/_channel.py | 13 +- dapr/clients/grpc/client.py | 114 +++++- dapr/clients/grpc/interceptors.py | 15 +- examples/binary-store/README.md | 63 +++ examples/binary-store/binary_store.py | 44 +++ examples/binary-store/binary_store_async.py | 48 +++ .../binary-store/components/binarystore.yaml | 12 + examples/binary-store/requirements.txt | 1 + pyproject.toml | 1 + tests/actor/test_grpc_host.py | 33 +- tests/binarystore_utils.py | 65 ++++ tests/clients/binarystore_server.py | 114 ++++++ tests/clients/test_binarystore.py | 293 ++++++++++++++ tests/clients/test_binarystore_async.py | 362 ++++++++++++++++++ tests/examples/test_binary_store.py | 35 ++ tests/integration/conftest.py | 20 + .../resources/binarystore/binarystore.yaml | 12 + .../resources/binarystore/limited.yaml | 12 + tests/integration/test_binarystore.py | 168 ++++++++ tests/integration/test_binarystore_async.py | 186 +++++++++ tests/test_binarystore_runtime.py | 60 +++ 27 files changed, 2240 insertions(+), 6 deletions(-) create mode 100644 dapr/aio/clients/grpc/_binarystore.py create mode 100644 dapr/clients/grpc/_binarystore.py create mode 100644 examples/binary-store/README.md create mode 100644 examples/binary-store/binary_store.py create mode 100644 examples/binary-store/binary_store_async.py create mode 100644 examples/binary-store/components/binarystore.yaml create mode 100644 examples/binary-store/requirements.txt create mode 100644 tests/binarystore_utils.py create mode 100644 tests/clients/binarystore_server.py create mode 100644 tests/clients/test_binarystore.py create mode 100644 tests/clients/test_binarystore_async.py create mode 100644 tests/examples/test_binary_store.py create mode 100644 tests/integration/resources/binarystore/binarystore.yaml create mode 100644 tests/integration/resources/binarystore/limited.yaml create mode 100644 tests/integration/test_binarystore.py create mode 100644 tests/integration/test_binarystore_async.py create mode 100644 tests/test_binarystore_runtime.py diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 5861fdb9e..061a6c622 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -127,6 +127,57 @@ jobs: run: | uv run pytest tests/examples/ + validate-binary-store: + runs-on: ubuntu-latest + env: + CHECKOUT_REPO: ${{ github.repository }} + CHECKOUT_REF: ${{ github.ref }} + DAPR_TEST_REQUIRE_BINARY_STORE: "1" + strategy: + fail-fast: false + matrix: + python_ver: ["3.10", "3.14"] + steps: + - name: Parse repository_dispatch payload + if: github.event_name == 'repository_dispatch' + env: + COMMAND: ${{ github.event.client_payload.command }} + PULL_HEAD_REPO: ${{ github.event.client_payload.pull_head_repo }} + PULL_HEAD_REF: ${{ github.event.client_payload.pull_head_ref }} + run: | + if [ "$COMMAND" = "ok-to-test" ]; then + echo "CHECKOUT_REPO=$PULL_HEAD_REPO" >> "$GITHUB_ENV" + echo "CHECKOUT_REF=$PULL_HEAD_REF" >> "$GITHUB_ENV" + fi + - name: Check out code + uses: actions/checkout@v7 + with: + repository: ${{ env.CHECKOUT_REPO }} + ref: ${{ env.CHECKOUT_REF }} + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python_ver }} + - name: Install uv + uses: astral-sh/setup-uv@v10.3.0 + - name: Install dependencies + run: uv sync --frozen --all-packages --group tests + - name: Set up Dapr CLI + uses: dapr/.github/.github/actions/setup-dapr-cli@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Dapr runtime with binary-store support + uses: dapr/.github/.github/actions/setup-dapr-runtime@main + with: + version: 1.19.0-rc.4 + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Validate binary-store integration and examples + run: >- + uv run pytest -m binary_store + tests/integration/test_binarystore.py + tests/integration/test_binarystore_async.py + tests/examples/test_binary_store.py + # The stateful-history delta path (dapr/durabletask-go#110, reaching dapr via # dapr/dapr#10142) is not in any release yet, so the matrix above cannot # exercise it. This job builds daprd from master and runs the workflow suites diff --git a/README.md b/README.md index a941dbf55..c1310fd13 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ And the following extensions, installed as extras on the core `dapr` package: * [dapr.ext.strands](./dapr/ext/strands): Strands agent session management — `pip install "dapr[strands]"` * [dapr.ext.workflow](./dapr/ext/workflow): Durable workflow authoring — `pip install "dapr[workflow]"` +Binary-store file uploads, streaming downloads, and deletion (Dapr 1.19 alpha) are +available in both clients. See the [sync and async binary-store examples](./examples/binary-store). + ## Getting started ### Prerequisites diff --git a/dapr/aio/clients/grpc/_binarystore.py b/dapr/aio/clients/grpc/_binarystore.py new file mode 100644 index 000000000..b1bb9a632 --- /dev/null +++ b/dapr/aio/clients/grpc/_binarystore.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import asyncio +import operator +from typing import Any, AsyncIterator, Optional + +from grpc.aio import AioRpcError # type: ignore + +from dapr.clients.exceptions import DaprGrpcError +from dapr.clients.grpc._binarystore import _BinaryFileUpload +from dapr.proto import common_v1 +from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1 + + +class _AsyncBinaryFileUpload: + """Pull-based request production with explicit ownership of pending file reads.""" + + def __init__(self, source: _BinaryFileUpload) -> None: + self._source = source + self._initial_sent = False + self._seq = 0 + self._closed = False + self._read_task: Optional[asyncio.Task[bytes]] = None + + def __aiter__(self) -> '_AsyncBinaryFileUpload': + return self + + async def __anext__(self) -> binary_v1.SetBinaryFileRequestAlpha1: + if self._closed: + raise StopAsyncIteration + if not self._initial_sent: + self._initial_sent = True + return self._source.initial + try: + if isinstance(self._source.data, bytes): + chunk = self._source.read() + else: + self._read_task = asyncio.create_task(asyncio.to_thread(self._source.read)) + try: + chunk = await self._read_task + finally: + self._read_task = None + except Exception as error: + # grpc.aio cancels the RPC when a request iterator raises. Retain + # the actual local error so the client can report it to the caller. + self._source.error = error + raise + if self._closed or not chunk: + raise StopAsyncIteration + request = binary_v1.SetBinaryFileRequestAlpha1( + payload=common_v1.StreamPayload(data=chunk, seq=self._seq) + ) + self._seq += 1 + return request + + async def aclose(self) -> None: + self._closed = True + self._source.stop() + if self._read_task is not None: + # This ends the iterator's wait, including after an early server + # rejection. An already-running thread read cannot be interrupted. + task = self._read_task + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +class AsyncBinaryFileResponse: + """An asynchronous binary download with one cursor for iteration and reads. + + Use ``async with`` or :meth:`aclose` when stopping early. An unbounded ``read()`` + buffers the remaining file; bounded reads and iteration preserve streaming. + Concurrent reads on the same response are unsupported. + """ + + def __init__(self, stream: Any) -> None: + self._stream = stream + self._iterator = stream.__aiter__() + self._buffer = b'' + self._expected_seq = 0 + self._closed = False + self._eof = False + + def _check_open(self) -> None: + if self._closed: + raise ValueError('I/O operation on closed binary file response') + + def __aiter__(self) -> AsyncIterator[bytes]: + return self + + async def __anext__(self) -> bytes: + self._check_open() + if self._buffer: + chunk, self._buffer = self._buffer, b'' + return chunk + if self._eof: + raise StopAsyncIteration + try: + while True: + message = await self._iterator.__anext__() + if not message.HasField('payload'): + raise ValueError('binary store response is missing its payload') + if message.payload.seq != self._expected_seq: + raise ValueError('invalid sequence number in binary store response') + self._expected_seq += 1 + if message.payload.data: + return message.payload.data + except StopAsyncIteration: + self._eof = True + raise + except AioRpcError as error: + await self.aclose() + raise DaprGrpcError(error) from error + except BaseException: + await self.aclose() + raise + + async def read(self, size: int = -1) -> bytes: + """Read up to size bytes; a negative size reads the remaining file into memory.""" + self._check_open() + size = operator.index(size) + if size < 0: + return b''.join([chunk async for chunk in self]) + pieces = [] + remaining = size + while remaining: + try: + chunk = await self.__anext__() + except StopAsyncIteration: + break + pieces.append(chunk[:remaining]) + self._buffer = chunk[remaining:] + remaining -= min(remaining, len(chunk)) + return b''.join(pieces) + + async def aclose(self) -> None: + """Cancel an unfinished download without closing the client.""" + if not self._closed: + self._closed = True + self._buffer = b'' + if not self._eof: + self._stream.cancel() + + async def __aenter__(self) -> 'AsyncBinaryFileResponse': + self._check_open() + return self + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + await self.aclose() diff --git a/dapr/aio/clients/grpc/client.py b/dapr/aio/clients/grpc/client.py index fae40a3e9..458b63433 100644 --- a/dapr/aio/clients/grpc/client.py +++ b/dapr/aio/clients/grpc/client.py @@ -16,9 +16,10 @@ import asyncio import socket import time -from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Text, Union +from typing import Any, Awaitable, BinaryIO, Callable, Dict, List, Optional, Sequence, Text, Union from urllib.parse import urlencode from warnings import warn +from weakref import WeakSet import grpc.aio # type: ignore from google.protobuf.any_pb2 import Any as GrpcAny @@ -36,6 +37,7 @@ ) from typing_extensions import Self +from dapr.aio.clients.grpc._binarystore import AsyncBinaryFileResponse, _AsyncBinaryFileUpload from dapr.aio.clients.grpc._request import ( DecryptRequestIterator, EncryptRequestIterator, @@ -47,6 +49,12 @@ from dapr.aio.clients.grpc.subscription import Subscription from dapr.clients.exceptions import DaprGrpcError, DaprInternalError from dapr.clients.grpc import conversation +from dapr.clients.grpc._binarystore import ( + DEFAULT_CHUNK_SIZE, + _BinaryFileUpload, + resolve_timeout, + validate_names, +) from dapr.clients.grpc._channel import create_aio_channel, resolve_grpc_endpoint from dapr.clients.grpc._crypto import DecryptOptions, EncryptOptions from dapr.clients.grpc._helpers import ( @@ -93,6 +101,7 @@ from dapr.clients.retry import RetryPolicy from dapr.common.pubsub.subscription import StreamInactiveError from dapr.proto import api_service_v1, api_v1, common_v1 +from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1 class DaprGrpcClientAsync: @@ -146,6 +155,7 @@ def __init__( """ DaprHealth.wait_for_sidecar() self.retry_policy = retry_policy or RetryPolicy() + self._binary_file_calls: WeakSet[Any] = WeakSet() self._uri = resolve_grpc_endpoint(address) credentials = self.get_credentials() if self._uri.tls else None @@ -164,6 +174,12 @@ def get_credentials(): async def close(self): """Closes Dapr runtime gRPC channel.""" + # Older grpc.aio versions find active calls by inspecting task stacks + # and can miss a download paused between reads. Cancel our calls + # explicitly without retaining abandoned/completed responses. + for call in list(self._binary_file_calls): + call.cancel() + self._binary_file_calls.clear() if hasattr(self, '_channel') and self._channel: await self._channel.close() @@ -1360,6 +1376,119 @@ async def unlock(self, store_name: str, resource_id: str, lock_owner: str) -> Un status=UnlockResponseStatus(response.status), headers=await call.initial_metadata() ) + async def set_binary_file_alpha1( + self, + store_name: str, + file_name: str, + data: Union[bytes, BinaryIO], + *, + overwrite: bool = False, + chunk_size: int = DEFAULT_CHUNK_SIZE, + timeout: Optional[float] = None, + ) -> DaprResponse: + """Upload bytes or a binary reader to a binary store (Dapr 1.19 alpha). + + The input remains open and is read from its current position. File reads + run outside the event loop; cancellation cannot interrupt an arbitrary + blocking read already running in a thread. The SDK does not replay uploads. + + Args: + store_name (str): Binary-store component name. + file_name (str): File name, including any component-supported prefix. + data (bytes or BinaryIO): Bytes or a reader returning binary chunks. + overwrite (bool): Replace an existing file; defaults to False. + chunk_size (int): Maximum upload chunk size; defaults to 32 KiB. + timeout (float, optional): Deadline for the whole transfer in seconds; + otherwise uses DAPR_API_TIMEOUT_SECONDS when configured. + + Returns: + DaprResponse: Successful completion and response headers. + + Raises: + TypeError: If an argument or reader result has an invalid type. + ValueError: If names are blank or chunk_size is not positive. + DaprGrpcError: If the runtime rejects or fails the upload. + OSError: If reading the caller's input fails. + asyncio.CancelledError: If the caller cancels the operation. + """ + warn( + 'The Binary Store API is an Alpha version and is subject to change.', + UserWarning, + stacklevel=2, + ) + source = _BinaryFileUpload(store_name, file_name, data, overwrite, chunk_size) + requests = _AsyncBinaryFileUpload(source) + # Supplying the iterator avoids grpc.aio's intercepted write() proxy, + # which can leave queue/poller tasks pending when a server rejects early. + call = self._stub.SetBinaryFileAlpha1(requests, timeout=resolve_timeout(timeout)) + self._binary_file_calls.add(call) + result = asyncio.ensure_future(call) + try: + # wait() keeps an RPC cancellation caused by a local reader failure + # distinct from cancellation of this method by its caller. + await asyncio.wait((result,)) + if source.error is not None: + raise source.error + result.result() + return DaprResponse(headers=await call.initial_metadata()) + except AioRpcError as error: + raise DaprGrpcError(error) from error + finally: + call.cancel() + await requests.aclose() + await asyncio.gather(result, return_exceptions=True) + + async def get_binary_file_alpha1( + self, store_name: str, file_name: str, *, timeout: Optional[float] = None + ) -> AsyncBinaryFileResponse: + """Return an asynchronous binary download from Dapr 1.19's alpha API. + + Use async iteration or bounded read(size) calls for streaming, and async + with or aclose() for early exit. Unbounded read() buffers the remaining + file. Errors surface during consumption; transfers are not replayed. + The optional timeout covers the complete transfer and otherwise uses + DAPR_API_TIMEOUT_SECONDS when configured. + """ + warn( + 'The Binary Store API is an Alpha version and is subject to change.', + UserWarning, + stacklevel=2, + ) + validate_names(store_name, file_name) + request = binary_v1.GetBinaryFileRequestAlpha1( + component_name=store_name, file_name=file_name + ) + call = self._stub.GetBinaryFileAlpha1(request, timeout=resolve_timeout(timeout)) + self._binary_file_calls.add(call) + return AsyncBinaryFileResponse(call) + + async def delete_binary_file_alpha1( + self, store_name: str, file_name: str, *, timeout: Optional[float] = None + ) -> DaprResponse: + """Delete a binary file using Dapr 1.19's alpha API, without SDK retries. + + Returns DaprResponse with response headers. Blank names raise ValueError; + runtime failures raise DaprGrpcError with the original status/details. + The optional timeout is in seconds and otherwise uses the configured + DAPR_API_TIMEOUT_SECONDS. Missing-file behavior comes from the component. + """ + warn( + 'The Binary Store API is an Alpha version and is subject to change.', + UserWarning, + stacklevel=2, + ) + validate_names(store_name, file_name) + request = binary_v1.DeleteBinaryFileRequestAlpha1( + component_name=store_name, file_name=file_name + ) + call = self._stub.DeleteBinaryFileAlpha1(request, timeout=resolve_timeout(timeout)) + self._binary_file_calls.add(call) + try: + await call + except AioRpcError as error: + raise DaprGrpcError(error) from error + return DaprResponse(headers=await call.initial_metadata()) + async def encrypt(self, data: Union[str, bytes], options: EncryptOptions): """Encrypt a stream data with given options. diff --git a/dapr/aio/clients/grpc/interceptors.py b/dapr/aio/clients/grpc/interceptors.py index 688559835..7ce77b190 100644 --- a/dapr/aio/clients/grpc/interceptors.py +++ b/dapr/aio/clients/grpc/interceptors.py @@ -19,6 +19,8 @@ from grpc.aio import ( # type: ignore ClientCallDetails, StreamStreamClientInterceptor, + StreamUnaryClientInterceptor, + UnaryStreamClientInterceptor, UnaryUnaryClientInterceptor, ) @@ -149,3 +151,28 @@ async def intercept_stream_stream(self, continuation, client_call_details, reque # Call continuation response = await continuation(new_call_details, request) return response + + +class _DaprStreamingMetadataInterceptorAsync: + """Share metadata construction without inheriting multiple gRPC interceptor types.""" + + def __init__(self, metadata: List[Tuple[str, str]]): + self._interceptor = DaprClientInterceptorAsync(metadata) + + async def _intercept(self, continuation, client_call_details, request): + details = await self._interceptor._intercept_call(client_call_details) + return await continuation(details, request) + + +class _DaprUnaryStreamInterceptorAsync( + _DaprStreamingMetadataInterceptorAsync, UnaryStreamClientInterceptor +): + async def intercept_unary_stream(self, continuation, client_call_details, request): + return await self._intercept(continuation, client_call_details, request) + + +class _DaprStreamUnaryInterceptorAsync( + _DaprStreamingMetadataInterceptorAsync, StreamUnaryClientInterceptor +): + async def intercept_stream_unary(self, continuation, client_call_details, request_iterator): + return await self._intercept(continuation, client_call_details, request_iterator) diff --git a/dapr/clients/grpc/_binarystore.py b/dapr/clients/grpc/_binarystore.py new file mode 100644 index 000000000..8f4693581 --- /dev/null +++ b/dapr/clients/grpc/_binarystore.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import io +import operator +import threading +from typing import Any, BinaryIO, Iterator, Optional, Union + +from grpc import RpcError # type: ignore + +from dapr.clients.exceptions import DaprGrpcError +from dapr.clients.grpc._helpers import validateNotBlankString +from dapr.conf import settings +from dapr.proto import common_v1 +from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1 + +DEFAULT_CHUNK_SIZE = 32 * 1024 + + +def validate_names(store_name: str, file_name: str) -> None: + for name, value in (('store_name', store_name), ('file_name', file_name)): + if not isinstance(value, str): + raise TypeError(f'{name} must be a string') + validateNotBlankString(store_name=store_name, file_name=file_name) + + +def resolve_timeout(timeout: Optional[float]) -> Optional[float]: + if timeout is not None: + return timeout + configured = settings.DAPR_API_TIMEOUT_SECONDS + return float(configured) if configured is not None else None + + +class _BinaryFileUpload: + """A bounded, single-use upload source. The caller retains ownership of its reader.""" + + def __init__( + self, + store_name: str, + file_name: str, + data: Union[bytes, BinaryIO], + overwrite: bool, + chunk_size: int, + ) -> None: + validate_names(store_name, file_name) + if not isinstance(overwrite, bool): + raise TypeError('overwrite must be a bool') + if not isinstance(chunk_size, int) or isinstance(chunk_size, bool): + raise TypeError('chunk_size must be an integer') + if chunk_size <= 0: + raise ValueError('chunk_size must be positive') + if isinstance(data, io.TextIOBase) or ( + not isinstance(data, bytes) and not callable(getattr(data, 'read', None)) + ): + raise TypeError('data must be bytes or a binary file-like reader') + self.initial = binary_v1.SetBinaryFileRequestAlpha1( + initial_request=binary_v1.SetBinaryFileRequestInitialAlpha1( + component_name=store_name, file_name=file_name, overwrite=overwrite + ) + ) + self.data = data + self.chunk_size = chunk_size + self.error: Optional[Exception] = None + self._offset = 0 + self._stopped = threading.Event() + + def stop(self) -> None: + self._stopped.set() + + def read(self) -> bytes: + if self._stopped.is_set(): + return b'' + if isinstance(self.data, bytes): + chunk = self.data[self._offset : self._offset + self.chunk_size] + self._offset += len(chunk) + else: + chunk = self.data.read(self.chunk_size) + if not isinstance(chunk, bytes): + raise TypeError('binary upload reader must return bytes') + if len(chunk) > self.chunk_size: + raise ValueError('binary upload reader returned more than chunk_size bytes') + return chunk + + def __iter__(self) -> Iterator[binary_v1.SetBinaryFileRequestAlpha1]: + yield self.initial + seq = 0 + try: + while not self._stopped.is_set(): + chunk = self.read() + if not chunk or self._stopped.is_set(): + return + yield binary_v1.SetBinaryFileRequestAlpha1( + payload=common_v1.StreamPayload(data=chunk, seq=seq) + ) + seq += 1 + except Exception as error: + self.error = error + raise + + +class BinaryFileResponse: + """A single-consumer binary download supporting iteration and file-style reads. + + Use a context manager, or call :meth:`close`, when stopping before EOF. Closing this + response cancels its RPC, not the client. ``read()`` buffers the remaining file; + iteration or bounded reads avoid buffering the complete file. + """ + + def __init__(self, stream: Any) -> None: + self._stream = stream + self._iterator = iter(stream) + self._buffer = b'' + self._expected_seq = 0 + self._closed = False + self._eof = False + + def _check_open(self) -> None: + if self._closed: + raise ValueError('I/O operation on closed binary file response') + + def __iter__(self) -> Iterator[bytes]: + return self + + def __next__(self) -> bytes: + self._check_open() + if self._buffer: + chunk, self._buffer = self._buffer, b'' + return chunk + if self._eof: + raise StopIteration + try: + while True: + message = next(self._iterator) + if not message.HasField('payload'): + raise ValueError('binary store response is missing its payload') + if message.payload.seq != self._expected_seq: + raise ValueError('invalid sequence number in binary store response') + self._expected_seq += 1 + if message.payload.data: + return message.payload.data + except StopIteration: + self._eof = True + raise + except RpcError as error: + self.close() + raise DaprGrpcError(error) from error + except BaseException: + self.close() + raise + + def read(self, size: int = -1) -> bytes: + """Read up to size bytes; a negative size reads the remaining file into memory.""" + self._check_open() + size = operator.index(size) + if size < 0: + return b''.join(self) + pieces = [] + remaining = size + while remaining: + try: + chunk = next(self) + except StopIteration: + break + pieces.append(chunk[:remaining]) + self._buffer = chunk[remaining:] + remaining -= min(remaining, len(chunk)) + return b''.join(pieces) + + def close(self) -> None: + """Cancel an unfinished download. Repeated close calls are harmless.""" + if not self._closed: + self._closed = True + self._buffer = b'' + if not self._eof: + cancel = getattr(self._stream, 'cancel', None) + if cancel is not None: + cancel() + else: + # Some tracing interceptors return a generator instead of + # a grpc.Call. Close it to release the underlying stream. + close = getattr(self._iterator, 'close', None) + if close is not None: + close() + + def __enter__(self) -> 'BinaryFileResponse': + self._check_open() + return self + + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + self.close() diff --git a/dapr/clients/grpc/_channel.py b/dapr/clients/grpc/_channel.py index 62827e390..13bb8e08a 100644 --- a/dapr/clients/grpc/_channel.py +++ b/dapr/clients/grpc/_channel.py @@ -70,6 +70,8 @@ def create_aio_channel( from dapr.aio.clients.grpc.interceptors import ( DaprClientInterceptorAsync, DaprClientTimeoutInterceptorAsync, + _DaprStreamUnaryInterceptorAsync, + _DaprUnaryStreamInterceptorAsync, ) useragent = f'dapr-sdk-python/{__version__}' @@ -90,8 +92,15 @@ def create_aio_channel( channel_interceptors: List[grpc.aio.ClientInterceptor] = list(interceptors or []) channel_interceptors.append(DaprClientTimeoutInterceptorAsync()) if settings.DAPR_API_TOKEN: - channel_interceptors.append( - DaprClientInterceptorAsync([('dapr-api-token', settings.DAPR_API_TOKEN)]) + metadata = [('dapr-api-token', settings.DAPR_API_TOKEN)] + # grpc.aio registers only the first matching interface on an interceptor, + # so client- and server-streaming metadata need separate adapters. + channel_interceptors.extend( + [ + DaprClientInterceptorAsync(metadata), + _DaprUnaryStreamInterceptorAsync(metadata), + _DaprStreamUnaryInterceptorAsync(metadata), + ] ) # Process-wide, idempotent setup; applied here so every aio channel diff --git a/dapr/clients/grpc/client.py b/dapr/clients/grpc/client.py index d691f51a6..7e0562297 100644 --- a/dapr/clients/grpc/client.py +++ b/dapr/clients/grpc/client.py @@ -17,7 +17,7 @@ import socket import threading import time -from typing import Any, Callable, Dict, List, Optional, Sequence, Text, Union +from typing import Any, BinaryIO, Callable, Dict, List, Optional, Sequence, Text, Union from urllib.parse import urlencode from warnings import warn @@ -39,6 +39,13 @@ from dapr.clients.exceptions import DaprGrpcError, DaprInternalError from dapr.clients.grpc import conversation +from dapr.clients.grpc._binarystore import ( + DEFAULT_CHUNK_SIZE, + BinaryFileResponse, + _BinaryFileUpload, + resolve_timeout, + validate_names, +) from dapr.clients.grpc._crypto import DecryptOptions, EncryptOptions from dapr.clients.grpc._helpers import ( MetadataTuple, @@ -93,6 +100,7 @@ from dapr.conf import settings from dapr.conf.helpers import GrpcEndpoint from dapr.proto import api_service_v1, api_v1, common_v1 +from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1 from dapr.version import __version__ logger = logging.getLogger(__name__) @@ -1431,6 +1439,110 @@ def unlock(self, store_name: str, resource_id: str, lock_owner: str) -> UnlockRe status=UnlockResponseStatus(response.status), headers=call.initial_metadata() ) + def set_binary_file_alpha1( + self, + store_name: str, + file_name: str, + data: Union[bytes, BinaryIO], + *, + overwrite: bool = False, + chunk_size: int = DEFAULT_CHUNK_SIZE, + timeout: Optional[float] = None, + ) -> DaprResponse: + """Upload bytes or a binary reader to a binary store (Dapr 1.19 alpha). + + The reader is consumed from its current position and remains open. Uploads + are not automatically retried, even when a retry policy is configured. + + Args: + store_name (str): Binary-store component name. + file_name (str): File name, including any component-supported prefix. + data (bytes or BinaryIO): Bytes or a reader returning binary chunks. + overwrite (bool): Replace an existing file; defaults to False. + chunk_size (int): Maximum upload chunk size in bytes; defaults to 32 KiB. + timeout (float, optional): Deadline for the complete transfer in seconds. + When omitted, uses DAPR_API_TIMEOUT_SECONDS if configured. + + Returns: + DaprResponse: Successful completion and response headers. + + Raises: + TypeError: If an argument or reader result has an invalid type. + ValueError: If names are blank or chunk_size is not positive. + DaprGrpcError: If the runtime rejects or fails the upload. + OSError: If reading the caller's input fails. + """ + warn( + 'The Binary Store API is an Alpha version and is subject to change.', + UserWarning, + stacklevel=2, + ) + source = _BinaryFileUpload(store_name, file_name, data, overwrite, chunk_size) + call = self._stub.SetBinaryFileAlpha1.future(iter(source), timeout=resolve_timeout(timeout)) + try: + call.result() + return DaprResponse(headers=call.initial_metadata()) + except RpcError as error: + if source.error is not None: + raise source.error from error + raise DaprGrpcError(error) from error + finally: + source.stop() + call.cancel() + + def get_binary_file_alpha1( + self, store_name: str, file_name: str, *, timeout: Optional[float] = None + ) -> BinaryFileResponse: + """Stream a binary file from Dapr 1.19's alpha Binary Store API. + + Iterate over the response for bounded-memory consumption, or call read() + to buffer the remaining file. Use a context manager or close() on early + exit. RPC errors are raised while consuming the response. The deadline + covers the whole transfer, including time spent consuming it; an omitted + timeout uses DAPR_API_TIMEOUT_SECONDS when configured. Downloads are not + automatically retried. Blank names raise ValueError and runtime failures + raise DaprGrpcError. + """ + warn( + 'The Binary Store API is an Alpha version and is subject to change.', + UserWarning, + stacklevel=2, + ) + validate_names(store_name, file_name) + request = binary_v1.GetBinaryFileRequestAlpha1( + component_name=store_name, file_name=file_name + ) + return BinaryFileResponse( + self._stub.GetBinaryFileAlpha1(request, timeout=resolve_timeout(timeout)) + ) + + def delete_binary_file_alpha1( + self, store_name: str, file_name: str, *, timeout: Optional[float] = None + ) -> DaprResponse: + """Delete a binary file using Dapr 1.19's alpha Binary Store API. + + Returns DaprResponse with response headers. Blank names raise ValueError; + server failures raise DaprGrpcError, preserving the component's missing-file + behavior. No SDK retry is performed. The optional timeout is in seconds and + otherwise uses DAPR_API_TIMEOUT_SECONDS when configured. + """ + warn( + 'The Binary Store API is an Alpha version and is subject to change.', + UserWarning, + stacklevel=2, + ) + validate_names(store_name, file_name) + request = binary_v1.DeleteBinaryFileRequestAlpha1( + component_name=store_name, file_name=file_name + ) + try: + _, call = self._stub.DeleteBinaryFileAlpha1.with_call( + request, timeout=resolve_timeout(timeout) + ) + except RpcError as error: + raise DaprGrpcError(error) from error + return DaprResponse(headers=call.initial_metadata()) + def encrypt(self, data: Union[str, bytes], options: EncryptOptions): """Encrypt a stream data with given options. diff --git a/dapr/clients/grpc/interceptors.py b/dapr/clients/grpc/interceptors.py index 3e654705a..45632169c 100644 --- a/dapr/clients/grpc/interceptors.py +++ b/dapr/clients/grpc/interceptors.py @@ -4,6 +4,8 @@ from grpc import ( # type: ignore ClientCallDetails, StreamStreamClientInterceptor, + StreamUnaryClientInterceptor, + UnaryStreamClientInterceptor, UnaryUnaryClientInterceptor, ) @@ -64,7 +66,12 @@ def intercept_unary_unary(self, continuation, client_call_details, request): return continuation(client_call_details, request) -class DaprClientInterceptor(UnaryUnaryClientInterceptor, StreamStreamClientInterceptor): +class DaprClientInterceptor( + UnaryUnaryClientInterceptor, + StreamStreamClientInterceptor, + StreamUnaryClientInterceptor, + UnaryStreamClientInterceptor, +): """The class implements a UnaryUnaryClientInterceptor from grpc to add an interceptor to add additional headers to all calls as needed. @@ -151,3 +158,9 @@ def intercept_stream_stream(self, continuation, client_call_details, request_ite # Call continuation response = continuation(new_call_details, request_iterator) return response + + def intercept_stream_unary(self, continuation, client_call_details, request_iterator): + return continuation(self._intercept_call(client_call_details), request_iterator) + + def intercept_unary_stream(self, continuation, client_call_details, request): + return continuation(self._intercept_call(client_call_details), request) diff --git a/examples/binary-store/README.md b/examples/binary-store/README.md new file mode 100644 index 000000000..697ab28cf --- /dev/null +++ b/examples/binary-store/README.md @@ -0,0 +1,63 @@ +# Binary store (alpha) + +Upload, download, overwrite, and delete binary files through the Dapr binary-store +API. Both examples use an in-memory component, so they need no cloud credentials. +Its contents are lost when the sidecar stops. + +## Prerequisites + +- Python 3.10 or later +- A Dapr Python SDK build containing the binary-store API +- Dapr runtime 1.19 or later (preview testing: 1.19.0-rc.3 or later) +- Dapr CLI with a locally initialized runtime (`dapr init`) + +From the repository root, install the SDK and development dependencies: + +```bash +uv sync --frozen --all-packages --group dev +``` + +## Run + +From this directory, run either example with the installed SDK environment active: + +```bash +dapr run --app-id binary-store --resources-path ./components -- python3 binary_store.py +dapr run --app-id binary-store-async --resources-path ./components -- python3 binary_store_async.py +``` + +Expected application output for each: + +```text +Binary file round trip: 262144 bytes +Overwritten file: replaced +Binary file deleted +``` + +## API behavior + +`set_binary_file_alpha1(store_name, file_name, data, *, overwrite=False, +chunk_size=32768, timeout=None)` accepts `bytes` or an open binary reader. It reads +from the current position, leaves the caller's reader open, and sends bounded +chunks. Creating an existing file fails unless `overwrite=True`. Async file reads +run in a thread; cancelling the operation cannot interrupt a blocking file read +already in progress. + +`get_binary_file_alpha1(store_name, file_name, *, timeout=None)` returns a download +response. Iterate over bytes or use `read(size)` for bounded reads. `read()` buffers +the remaining file in memory, so use it for small files. Partial reads and iteration +share one cursor; use one consumer per response. Get failures surface while reading. +Use `with`/`close()` (async: `async with`/`aclose()`) to cancel an unfinished download. +The async get method must first be awaited, as shown in the example. + +`delete_binary_file_alpha1(store_name, file_name, *, timeout=None)` deletes a file. +Missing-file behavior depends on the component; the in-memory component reports +`NOT_FOUND` for both get and delete. The optional timeout on every method covers +the entire RPC, including download consumption; when omitted, the SDK uses +`DAPR_API_TIMEOUT_SECONDS` if configured. The SDK does not replay binary-store +operations. gRPC failures use `DaprGrpcError` and preserve the status supplied by +the transport. Some gRPC async versions can report an early upload rejection as +`INTERNAL` when a write races the server closing the stream +([grpc/grpc#36066](https://github.com/grpc/grpc/issues/36066)). + +All three methods emit an alpha `UserWarning`. These APIs may change. diff --git a/examples/binary-store/binary_store.py b/examples/binary-store/binary_store.py new file mode 100644 index 000000000..9a7752b46 --- /dev/null +++ b/examples/binary-store/binary_store.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from tempfile import TemporaryFile + +from dapr.clients import DaprClient + + +def main(): + data = bytes(range(256)) * 1024 + with DaprClient() as client, TemporaryFile() as source, TemporaryFile() as destination: + source.write(data) + source.seek(0) + client.set_binary_file_alpha1('binarystore', 'example/data.bin', source) + with client.get_binary_file_alpha1('binarystore', 'example/data.bin') as response: + for chunk in response: + destination.write(chunk) + destination.seek(0) + assert destination.read() == data + print(f'Binary file round trip: {len(data)} bytes') + + client.set_binary_file_alpha1( + 'binarystore', 'example/data.bin', b'replaced', overwrite=True + ) + with client.get_binary_file_alpha1('binarystore', 'example/data.bin') as response: + print(f'Overwritten file: {response.read().decode()}') + client.delete_binary_file_alpha1('binarystore', 'example/data.bin') + print('Binary file deleted') + + +if __name__ == '__main__': + main() diff --git a/examples/binary-store/binary_store_async.py b/examples/binary-store/binary_store_async.py new file mode 100644 index 000000000..26bf2d566 --- /dev/null +++ b/examples/binary-store/binary_store_async.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import asyncio +from tempfile import TemporaryFile + +from dapr.aio.clients import DaprClient + + +async def main(): + data = bytes(range(256)) * 1024 + with TemporaryFile() as source, TemporaryFile() as destination: + source.write(data) + source.seek(0) + async with DaprClient() as client: + await client.set_binary_file_alpha1('binarystore', 'example/data.bin', source) + response = await client.get_binary_file_alpha1('binarystore', 'example/data.bin') + async with response: + async for chunk in response: + await asyncio.to_thread(destination.write, chunk) + destination.seek(0) + assert destination.read() == data + print(f'Binary file round trip: {len(data)} bytes') + + await client.set_binary_file_alpha1( + 'binarystore', 'example/data.bin', b'replaced', overwrite=True + ) + response = await client.get_binary_file_alpha1('binarystore', 'example/data.bin') + async with response: + print(f'Overwritten file: {(await response.read()).decode()}') + await client.delete_binary_file_alpha1('binarystore', 'example/data.bin') + print('Binary file deleted') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/binary-store/components/binarystore.yaml b/examples/binary-store/components/binarystore.yaml new file mode 100644 index 000000000..7def012d0 --- /dev/null +++ b/examples/binary-store/components/binarystore.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: binarystore +spec: + type: binarystore.in-memory + version: v1 + metadata: + - name: maxSize + value: "8388608" + - name: maxTotalSize + value: "33554432" diff --git a/examples/binary-store/requirements.txt b/examples/binary-store/requirements.txt new file mode 100644 index 000000000..53c597519 --- /dev/null +++ b/examples/binary-store/requirements.txt @@ -0,0 +1 @@ +dapr diff --git a/pyproject.toml b/pyproject.toml index fb08b3985..2c5f922a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -329,6 +329,7 @@ ignore_errors = true [tool.pytest.ini_options] markers = [ + 'binary_store: tests requiring Dapr runtime 1.19 binary-store APIs', 'example_dir(name): set the example directory for the dapr fixture', 'perf: timing-sensitive dispatch regression tests', 'e2e: tests requiring a running Dapr sidecar', diff --git a/tests/actor/test_grpc_host.py b/tests/actor/test_grpc_host.py index 60d00781c..b6c508c94 100644 --- a/tests/actor/test_grpc_host.py +++ b/tests/actor/test_grpc_host.py @@ -41,10 +41,11 @@ from dapr.actor.actor_interface import ActorInterface, actormethod from dapr.actor.runtime.actor import Actor from dapr.actor.runtime.config import ActorRuntimeConfig -from dapr.actor.runtime.grpc_host import ActorGrpcHost +from dapr.actor.runtime.grpc_host import ActorGrpcHost, _StreamSession from dapr.actor.runtime.reentrancy_context import reentrancy_ctx from dapr.actor.runtime.remindable import Remindable from dapr.actor.runtime.runtime import ActorRuntime +from dapr.conf import settings from dapr.proto import api_v1 from dapr.serializers import DefaultJSONSerializer from tests.clients.fake_dapr_server import FakeDaprSidecar @@ -450,6 +451,36 @@ async def test_reconnects_and_reregisters_after_disconnect(self): reply_ids = {reply.invoke_response.id for reply in replies} self.assertEqual({'req-1', 'req-2'}, reply_ids) + @mock.patch.object(settings, 'DAPR_API_TOKEN', 'actor-token') + async def test_token_channel_reconnect_does_not_leak_request_tasks(self): + before = asyncio.all_tasks() + # Retain stream handles so garbage collection cannot hide an unfinished + # request poller belonging to a disconnected session. + with mock.patch( + 'dapr.actor.runtime.grpc_host._StreamSession', side_effect=_StreamSession + ) as sessions: + with mock.patch('dapr.actor.runtime.grpc_host._RECONNECT_DELAY_SECONDS', 0.05): + await self._serve_plans( + _plan([_invoke_message('first', 'GetName')], end='abort'), + _plan([_invoke_message('second', 'GetName')]), + ) + await self._wait_until( + lambda: len(self._fake_dapr_server.actor_stream_initials) >= 2 + ) + await self._await_replies(2) + await self.host.close() + self.assertGreaterEqual(sessions.call_count, 2) + finishing = asyncio.all_tasks() - before + if finishing: + await asyncio.wait(finishing, timeout=1) + pending = [task for task in asyncio.all_tasks() - before if not task.done()] + try: + self.assertEqual([], [task.get_coro().__qualname__ for task in pending]) + finally: + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + async def test_run_task_death_after_registration_is_logged_and_resets(self): """A non-transient failure after a successful registration has no start() call to land in: it must be logged and reset the host so a diff --git a/tests/binarystore_utils.py b/tests/binarystore_utils.py new file mode 100644 index 000000000..e97b90652 --- /dev/null +++ b/tests/binarystore_utils.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import io +import os +import re +import subprocess +import threading + +import pytest + + +def require_binary_store_runtime() -> None: + """Gate before sidecar startup; the dedicated feature job must never skip.""" + reason = 'Binary store requires Dapr runtime 1.19.0-rc.3 or later' + try: + result = subprocess.run( + ['dapr', '--version'], capture_output=True, text=True, timeout=15, check=True + ) + match = re.search(r'Runtime version:\s*v?(\d+)\.(\d+)\.(\d+)([^\s]*)', result.stdout) + if match: + version = tuple(int(part) for part in match.group(1, 2, 3)) + # Build metadata does not change version precedence (for example, + # a locally built 1.19.0+sha runtime still contains the API). + suffix = match.group(4).split('+', 1)[0] + rc = re.fullmatch(r'-rc\.(\d+)', suffix) + if version > (1, 19, 0) or ( + version == (1, 19, 0) and (not suffix or (rc and int(rc[1]) >= 3)) + ): + return + reason += f'; selected CLI reports: {result.stdout.strip()}' + except (OSError, subprocess.SubprocessError) as error: + reason += f'; runtime probe failed: {error}' + if os.environ.get('DAPR_TEST_REQUIRE_BINARY_STORE') == '1': + pytest.fail(reason) + pytest.skip(reason) + + +class EndOfUploadReader(io.BytesIO): + """Hold EOF so tests can inspect a component before the upload can commit.""" + + def __init__(self, data: bytes): + super().__init__(data) + self.ready = threading.Event() + self.release = threading.Event() + + def read(self, size: int = -1) -> bytes: + chunk = super().read(size) + if not chunk: + self.ready.set() + if not self.release.wait(10): + raise TimeoutError('test did not release the upload EOF gate') + return chunk diff --git a/tests/clients/binarystore_server.py b/tests/clients/binarystore_server.py new file mode 100644 index 000000000..a6df7487e --- /dev/null +++ b/tests/clients/binarystore_server.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import threading + +import grpc + +from dapr.proto import common_v1 +from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1 +from tests.clients.fake_dapr_server import FakeDaprSidecar + + +class BinaryStoreSidecar(FakeDaprSidecar): + """Existing localhost sidecar extended with controllable binary-store RPCs.""" + + def __init__(self): + super().__init__() + self.files = {} + self.uploads = [] + self.calls = [] + self.upload_error = None + self.upload_error_after_body = None + self.download_error = None + self.download_messages = None + self.wait_for_upload = False + self.upload_received = threading.Event() + self.upload_release = threading.Event() + self.hold_upload_body = False + self.upload_body_started = threading.Event() + self.upload_body_release = threading.Event() + self.hold_download = False + self.download_started = threading.Event() + self.download_cancelled = threading.Event() + self.token = None + + def _record(self, method, context): + metadata = list(context.invocation_metadata()) + self.calls.append((method, metadata, context.time_remaining())) + if self.token is not None and ('dapr-api-token', self.token) not in metadata: + context.abort(grpc.StatusCode.UNAUTHENTICATED, 'missing token') + context.send_initial_metadata((('binary-header', 'value'),)) + + def SetBinaryFileAlpha1(self, requests, context): + self._record('set', context) + messages = [] + self.uploads.append(messages) + first = next(requests) + messages.append(first) + if self.upload_error is not None: + context.abort(self.upload_error, 'upload rejected') + initial = first.initial_request + key = (initial.component_name, initial.file_name) + if key in self.files and not initial.overwrite: + context.abort(grpc.StatusCode.ALREADY_EXISTS, 'file exists') + self.upload_body_started.set() + if self.hold_upload_body: + while context.is_active() and not self.upload_body_release.wait(0.01): + pass + chunks = [] + for request in requests: + messages.append(request) + chunks.append(request.payload.data) + if self.upload_error_after_body is not None: + context.abort(self.upload_error_after_body, 'upload interrupted') + self.upload_received.set() + if self.wait_for_upload: + while context.is_active() and not self.upload_release.wait(0.01): + pass + self.files[key] = b''.join(chunks) + return binary_v1.SetBinaryFileResponseAlpha1() + + def GetBinaryFileAlpha1(self, request, context): + self._record('get', context) + context.add_callback(self.download_cancelled.set) + key = (request.component_name, request.file_name) + if self.download_messages is not None: + yield from self.download_messages + elif key in self.files: + data = self.files[key] + for seq, start in enumerate(range(0, len(data), 32768)): + yield binary_v1.GetBinaryFileResponseAlpha1( + payload=common_v1.StreamPayload(data=data[start : start + 32768], seq=seq) + ) + else: + context.abort(grpc.StatusCode.NOT_FOUND, 'missing file') + self.download_started.set() + if self.download_error is not None: + context.abort(self.download_error, 'download interrupted') + if self.hold_download: + self.download_cancelled.wait(10) + + def DeleteBinaryFileAlpha1(self, request, context): + self._record('delete', context) + key = (request.component_name, request.file_name) + if key not in self.files: + context.abort(grpc.StatusCode.NOT_FOUND, 'missing file') + del self.files[key] + return binary_v1.DeleteBinaryFileResponseAlpha1() + + def EncryptAlpha1(self, requests, context): + self._record('encrypt', context) + yield from super().EncryptAlpha1(requests, context) diff --git a/tests/clients/test_binarystore.py b/tests/clients/test_binarystore.py new file mode 100644 index 000000000..7fcb6e0e5 --- /dev/null +++ b/tests/clients/test_binarystore.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import io +import unittest +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import MagicMock, patch + +import grpc +import pytest + +from dapr.clients import DaprClient +from dapr.clients.exceptions import DaprGrpcError +from dapr.clients.grpc._binarystore import BinaryFileResponse, _BinaryFileUpload +from dapr.clients.grpc._crypto import EncryptOptions +from dapr.clients.grpc.client import DaprGrpcClient +from dapr.clients.retry import RetryPolicy +from dapr.conf import settings +from dapr.proto import api_v1, common_v1 +from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1 +from tests.clients.binarystore_server import BinaryStoreSidecar + +pytestmark = pytest.mark.filterwarnings('ignore:The Binary Store API:UserWarning') + + +def message(data, seq=0): + return binary_v1.GetBinaryFileResponseAlpha1( + payload=common_v1.StreamPayload(data=data, seq=seq) + ) + + +class BinaryFileReaderTests(unittest.TestCase): + def response(self, *messages): + stream = MagicMock() + stream.__iter__.return_value = iter(messages) + return BinaryFileResponse(stream), stream + + def test_partial_reads_iteration_and_read_all_share_one_cursor(self): + response, _ = self.response(message(b'abc'), message(b'def', 1), message(b'ghi', 2)) + self.assertEqual(b'', response.read(0)) + self.assertEqual(b'a', response.read(1)) + self.assertEqual(b'b', response.read(1)) + self.assertEqual(b'c', next(response)) + self.assertEqual(b'de', response.read(2)) + self.assertEqual(b'fghi', response.read()) + self.assertEqual(b'', response.read()) + self.assertEqual([], list(response)) + + def test_read_sizes_empty_chunks_and_eof(self): + response, _ = self.response(message(b''), message(b'abc', 1)) + self.assertEqual(b'abc', response.read(20)) + self.assertEqual(b'', response.read(-2)) + with self.assertRaises(TypeError): + response.read(1.5) + + def test_invalid_sequence_or_missing_payload_cancels(self): + for invalid in [message(b'x', 1), binary_v1.GetBinaryFileResponseAlpha1()]: + with self.subTest(message=invalid): + response, stream = self.response(invalid) + with self.assertRaises(ValueError): + response.read() + stream.cancel.assert_called_once() + + def test_close_context_exit_and_eof(self): + response, stream = self.response(message(b'abc')) + with response: + self.assertEqual(b'a', response.read(1)) + response.close() + stream.cancel.assert_called_once() + with self.assertRaises(ValueError): + response.read() + response, stream = self.response() + self.assertEqual(b'', response.read()) + response.close() + stream.cancel.assert_not_called() + + def test_upload_validation_and_initial_before_any_read(self): + reader = MagicMock() + source = _BinaryFileUpload('files', 'file', reader, False, 2) + initial = next(iter(source)) + self.assertEqual('initial_request', initial.WhichOneof('set_binary_file_request_type')) + reader.read.assert_not_called() + for size in [0, -1, True, 1.5]: + with self.subTest(size=size), self.assertRaises((TypeError, ValueError)): + _BinaryFileUpload('files', 'file', b'', False, size) + for data in ['text', io.StringIO('text'), 42]: + with self.subTest(data=data), self.assertRaises(TypeError): + _BinaryFileUpload('files', 'file', data, False, 2) + + +class BinaryStoreClientTests(unittest.TestCase): + def setUp(self): + self.server = BinaryStoreSidecar() + self.server.start() + self.addCleanup(self.server.stop) + health = patch('dapr.clients.grpc.client.DaprHealth.wait_for_sidecar') + health.start() + self.addCleanup(health.stop) + self.client = DaprGrpcClient(f'127.0.0.1:{self.server.grpc_port}') + self.addCleanup(self.client.close) + + def test_public_client_inherits_methods(self): + self.assertIs(DaprClient.get_binary_file_alpha1, DaprGrpcClient.get_binary_file_alpha1) + + def test_framing_binary_round_trip_custom_chunks_and_headers(self): + payload = bytes(range(256)) * 257 + result = self.client.set_binary_file_alpha1('files', 'nested/file', payload) + messages = self.server.uploads[0] + self.assertEqual('initial_request', messages[0].WhichOneof('set_binary_file_request_type')) + self.assertFalse(messages[0].initial_request.overwrite) + self.assertEqual([0, 1, 2], [m.payload.seq for m in messages[1:]]) + self.assertEqual([32768, 32768, 256], [len(m.payload.data) for m in messages[1:]]) + self.assertEqual(['value'], result.headers['binary-header']) + with self.client.get_binary_file_alpha1('files', 'nested/file') as response: + self.assertEqual(payload, response.read()) + source = io.BytesIO(b'prefix\x00\xff\x80') + source.seek(6) + self.client.set_binary_file_alpha1( + 'files', 'nested/file', source, overwrite=True, chunk_size=2 + ) + self.assertFalse(source.closed) + self.assertEqual( + b'\x00\xff\x80', self.client.get_binary_file_alpha1('files', 'nested/file').read() + ) + self.assertTrue(self.server.uploads[-1][0].initial_request.overwrite) + self.assertEqual( + ['value'], + self.client.delete_binary_file_alpha1('files', 'nested/file').headers['binary-header'], + ) + + def test_empty_upload_and_no_replay_on_server_rejection(self): + self.client.retry_policy = RetryPolicy(max_attempts=3) + self.client.set_binary_file_alpha1('files', 'empty', b'') + self.assertEqual(1, len(self.server.uploads[0])) + self.assertEqual(b'', self.client.get_binary_file_alpha1('files', 'empty').read()) + for code in [ + grpc.StatusCode.ALREADY_EXISTS, + grpc.StatusCode.UNAVAILABLE, + grpc.StatusCode.UNIMPLEMENTED, + ]: + self.server.upload_error = code + before = len(self.server.uploads) + with self.assertRaises(DaprGrpcError) as caught: + self.client.set_binary_file_alpha1('files', 'file', b'x' * 65536) + self.assertEqual(code, caught.exception.code()) + self.assertEqual(before + 1, len(self.server.uploads)) + + def test_local_reader_failures_short_reads_and_no_unbounded_reads(self): + failure = OSError('disk read failed') + reader = MagicMock() + reader.read.side_effect = [b'ab', failure] + with self.assertRaises(OSError) as caught: + self.client.set_binary_file_alpha1('files', 'file', reader, chunk_size=4) + self.assertIs(failure, caught.exception) + reader.close.assert_not_called() + reader.read.side_effect = [b'a', b'b', b''] + self.client.set_binary_file_alpha1('files', 'short', reader, chunk_size=4) + self.assertEqual(b'ab', self.server.files[('files', 'short')]) + self.assertTrue(all(call.args == (4,) for call in reader.read.call_args_list)) + for returned in ['text', None, b'too long']: + reader.read.side_effect = None + reader.read.return_value = returned + with self.subTest(returned=returned), self.assertRaises((TypeError, ValueError)): + self.client.set_binary_file_alpha1('files', 'bad', reader, chunk_size=4) + + def test_success_waits_for_server_completion(self): + self.server.wait_for_upload = True + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(self.client.set_binary_file_alpha1, 'files', 'file', b'x') + try: + self.assertTrue(self.server.upload_received.wait(5)) + self.assertFalse(future.done()) + finally: + self.server.upload_release.set() + future.result(timeout=5) + + def test_partial_download_error_and_missing_file_preserve_status(self): + self.server.files[('files', 'file')] = b'abc' + self.server.download_error = grpc.StatusCode.UNAVAILABLE + response = self.client.get_binary_file_alpha1('files', 'file') + self.assertEqual(b'abc', next(response)) + with self.assertRaises(DaprGrpcError) as caught: + next(response) + self.assertEqual(grpc.StatusCode.UNAVAILABLE, caught.exception.code()) + with self.assertRaises(DaprGrpcError) as caught: + self.client.get_binary_file_alpha1('files', 'missing').read() + self.assertEqual(grpc.StatusCode.NOT_FOUND, caught.exception.code()) + with self.assertRaises(DaprGrpcError): + self.client.delete_binary_file_alpha1('files', 'missing') + + def test_early_close_cancels_actual_rpc(self): + self.server.files[('files', 'file')] = b'abc' + self.server.hold_download = True + with self.client.get_binary_file_alpha1('files', 'file') as response: + self.assertEqual(b'abc', next(response)) + self.assertTrue(self.server.download_cancelled.wait(5)) + self.client.set_binary_file_alpha1('files', 'still-usable', b'ok') + + def test_client_shutdown_cancels_download(self): + self.server.files[('files', 'file')] = b'abc' + self.server.hold_download = True + response = self.client.get_binary_file_alpha1('files', 'file') + self.assertEqual(b'abc', response.read(3)) + self.client.close() + with self.assertRaises(DaprGrpcError) as caught: + response.read() + self.assertEqual(grpc.StatusCode.CANCELLED, caught.exception.code()) + self.assertTrue(self.server.download_cancelled.wait(5)) + + def test_deadline_and_invalid_arguments(self): + self.server.wait_for_upload = True + with self.assertRaises(DaprGrpcError) as caught: + self.client.set_binary_file_alpha1('files', 'file', b'x', timeout=0.05) + self.assertEqual(grpc.StatusCode.DEADLINE_EXCEEDED, caught.exception.code()) + self.server.upload_release.set() + for store, name in [('', 'file'), ('files', ' '), (None, 'file')]: + for method in [ + self.client.get_binary_file_alpha1, + self.client.delete_binary_file_alpha1, + ]: + with ( + self.subTest(store=store, name=name), + self.assertRaises((TypeError, ValueError)), + ): + method(store, name) + with self.assertWarnsRegex(UserWarning, 'Alpha'): + self.client.set_binary_file_alpha1('files', 'warn', b'') + + @patch.object(settings, 'DAPR_API_TOKEN', 'test-token') + @patch.object(settings, 'DAPR_API_TIMEOUT_SECONDS', 7) + def test_token_metadata_and_timeout_through_real_channel(self): + self.server.token = 'test-token' + with DaprGrpcClient(f'127.0.0.1:{self.server.grpc_port}') as client: + client.set_binary_file_alpha1('files', 'file', b'hello') + self.assertEqual(b'hello', client.get_binary_file_alpha1('files', 'file').read()) + client.delete_binary_file_alpha1('files', 'file', timeout=3) + self.assertEqual( + b'ABC', client.encrypt(b'abc', EncryptOptions('crypto', 'key', 'RSA')).read() + ) + self.assertEqual(['set', 'get', 'delete', 'encrypt'], [c[0] for c in self.server.calls]) + for method, metadata, deadline in self.server.calls: + self.assertEqual(1, metadata.count(('dapr-api-token', 'test-token'))) + if method in ['set', 'get']: + self.assertGreater(deadline, 5) + self.assertLessEqual(deadline, 7.1) + elif method == 'delete': + self.assertLessEqual(deadline, 3.1) + + @patch.object(settings, 'DAPR_API_TOKEN', 'test-token') + def test_custom_metadata_survives_all_rpc_shapes(self): + self.server.token = 'test-token' + metadata = (('custom-header', 'custom-value'),) + with DaprGrpcClient(f'127.0.0.1:{self.server.grpc_port}') as client: + client._stub.SetBinaryFileAlpha1( + iter(_BinaryFileUpload('files', 'metadata', b'x', False, 32768)), + metadata=metadata, + ) + list( + client._stub.GetBinaryFileAlpha1( + binary_v1.GetBinaryFileRequestAlpha1( + component_name='files', file_name='metadata' + ), + metadata=metadata, + ) + ) + client._stub.DeleteBinaryFileAlpha1( + binary_v1.DeleteBinaryFileRequestAlpha1( + component_name='files', file_name='metadata' + ), + metadata=metadata, + ) + list( + client._stub.EncryptAlpha1( + iter([api_v1.EncryptRequest(payload=common_v1.StreamPayload(data=b'abc'))]), + metadata=metadata, + ) + ) + self.assertEqual(['set', 'get', 'delete', 'encrypt'], [c[0] for c in self.server.calls]) + for _, headers, _ in self.server.calls: + self.assertEqual(1, headers.count(('dapr-api-token', 'test-token'))) + self.assertIn(('custom-header', 'custom-value'), headers) diff --git a/tests/clients/test_binarystore_async.py b/tests/clients/test_binarystore_async.py new file mode 100644 index 000000000..771c348be --- /dev/null +++ b/tests/clients/test_binarystore_async.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import asyncio +import io +import threading +import unittest +from unittest.mock import MagicMock, patch + +import grpc +import pytest + +from dapr.aio.clients import DaprClient +from dapr.aio.clients.grpc._binarystore import AsyncBinaryFileResponse +from dapr.aio.clients.grpc.client import DaprGrpcClientAsync +from dapr.clients.exceptions import DaprGrpcError +from dapr.clients.grpc._binarystore import _BinaryFileUpload +from dapr.clients.retry import RetryPolicy +from dapr.conf import settings +from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1 +from tests.clients.binarystore_server import BinaryStoreSidecar +from tests.clients.test_binarystore import message + +pytestmark = pytest.mark.filterwarnings('ignore:The Binary Store API:UserWarning') + + +class AsyncMessages: + def __init__(self, messages): + self.messages = iter(messages) + self.cancel = MagicMock() + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.messages) + except StopIteration: + raise StopAsyncIteration + + +class AsyncBinaryFileReaderTests(unittest.IsolatedAsyncioTestCase): + async def test_partial_reads_iteration_and_read_all_share_cursor(self): + response = AsyncBinaryFileResponse(AsyncMessages([message(b'abc'), message(b'def', 1)])) + self.assertEqual(b'', await response.read(0)) + self.assertEqual(b'a', await response.read(1)) + self.assertEqual(b'b', await response.read(1)) + self.assertEqual(b'c', await response.__anext__()) + self.assertEqual(b'd', await response.read(1)) + self.assertEqual(b'ef', await response.read()) + self.assertEqual(b'', await response.read()) + self.assertEqual([], [chunk async for chunk in response]) + await response.aclose() + + async def test_invalid_sequence_missing_payload_and_close(self): + for invalid in [message(b'x', 1), binary_v1.GetBinaryFileResponseAlpha1()]: + stream = AsyncMessages([invalid]) + response = AsyncBinaryFileResponse(stream) + with self.subTest(message=invalid), self.assertRaises(ValueError): + await response.read() + stream.cancel.assert_called_once() + stream = AsyncMessages([message(b'abc')]) + async with AsyncBinaryFileResponse(stream) as response: + self.assertEqual(b'a', await response.read(1)) + await response.aclose() + stream.cancel.assert_called_once() + with self.assertRaises(ValueError): + await response.read() + + async def test_negative_sizes_empty_chunks_and_eof(self): + stream = AsyncMessages([message(b''), message(b'abc', 1)]) + response = AsyncBinaryFileResponse(stream) + with self.assertRaises(TypeError): + await response.read(1.5) + self.assertEqual(b'abc', await response.read(-2)) + self.assertEqual(b'', await response.read(100)) + await response.aclose() + stream.cancel.assert_not_called() + + +class AsyncBinaryStoreClientTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.server = BinaryStoreSidecar() + self.server.start() + self.addCleanup(self.server.stop) + health = patch('dapr.clients.grpc.client.DaprHealth.wait_for_sidecar') + health.start() + self.addCleanup(health.stop) + self.client = DaprGrpcClientAsync(f'127.0.0.1:{self.server.grpc_port}') + self.addAsyncCleanup(self.client.close) + + def test_public_client_inherits_methods(self): + self.assertIs(DaprClient.get_binary_file_alpha1, DaprGrpcClientAsync.get_binary_file_alpha1) + + async def test_framing_round_trip_empty_file_and_metadata(self): + data = bytes(range(256)) * 257 + result = await self.client.set_binary_file_alpha1('files', 'file', data) + self.assertEqual(['value'], result.headers['binary-header']) + messages = self.server.uploads[0] + self.assertEqual('initial_request', messages[0].WhichOneof('set_binary_file_request_type')) + self.assertEqual([0, 1, 2], [m.payload.seq for m in messages[1:]]) + self.assertEqual([32768, 32768, 256], [len(m.payload.data) for m in messages[1:]]) + response = await self.client.get_binary_file_alpha1('files', 'file') + async with response: + self.assertEqual(data, await response.read()) + source = io.BytesIO(b'prefix\x00\xff\x80') + source.seek(6) + await self.client.set_binary_file_alpha1( + 'files', 'file', source, overwrite=True, chunk_size=2 + ) + self.assertFalse(source.closed) + self.assertTrue(self.server.uploads[-1][0].initial_request.overwrite) + result = await self.client.delete_binary_file_alpha1('files', 'file') + self.assertEqual(['value'], result.headers['binary-header']) + await self.client.set_binary_file_alpha1('files', 'empty', b'') + self.assertEqual(1, len(self.server.uploads[-1])) + response = await self.client.get_binary_file_alpha1('files', 'empty') + self.assertEqual(b'', await response.read()) + + async def test_source_errors_leave_caller_input_open(self): + failure = OSError('disk read failed') + source = MagicMock() + source.read.side_effect = [b'a', failure] + with self.assertRaises(OSError) as caught: + await self.client.set_binary_file_alpha1('files', 'file', source, chunk_size=2) + self.assertIs(failure, caught.exception) + source.close.assert_not_called() + + async def test_server_rejection_preserves_available_status_without_replay(self): + self.client.retry_policy = RetryPolicy(max_attempts=3) + for code in [ + grpc.StatusCode.ALREADY_EXISTS, + grpc.StatusCode.RESOURCE_EXHAUSTED, + grpc.StatusCode.UNAVAILABLE, + grpc.StatusCode.UNIMPLEMENTED, + ]: + # Test both rejection of the initial message and failure after a + # multi-chunk body. Concurrent writes during an early abort can + # mask the server's status in grpc.aio itself (grpc/grpc#36066). + for early in [True, False]: + self.server.upload_error = code if early else None + self.server.upload_error_after_body = None if early else code + before = len(self.server.uploads) + with ( + self.subTest(code=code, early=early), + self.assertRaises(DaprGrpcError) as caught, + ): + await self.client.set_binary_file_alpha1( + 'files', 'file', b'' if early else b'x' * 100000 + ) + self.assertEqual(code, caught.exception.code(), caught.exception.details()) + self.assertEqual(before + 1, len(self.server.uploads)) + + async def test_upload_completion_and_caller_cancellation(self): + self.server.wait_for_upload = True + task = asyncio.create_task(self.client.set_binary_file_alpha1('files', 'file', b'x')) + try: + self.assertTrue(await asyncio.to_thread(self.server.upload_received.wait, 5)) + self.assertFalse(task.done()) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + finally: + self.server.upload_release.set() + self.assertTrue(task.cancelled()) + + async def test_blocking_file_read_does_not_block_event_loop_or_continue_after_cancel(self): + entered = threading.Event() + release = threading.Event() + source = MagicMock() + + def read(size): + entered.set() + release.wait(5) + return b'x' + + source.read.side_effect = read + task = asyncio.create_task(self.client.set_binary_file_alpha1('files', 'file', source)) + try: + self.assertTrue(await asyncio.to_thread(entered.wait, 5)) + # The upload is in read(), but the event loop can still cancel it. + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + finally: + release.set() + self.assertEqual(1, source.read.call_count) + source.close.assert_not_called() + + async def test_download_error_and_early_close(self): + self.server.files[('files', 'file')] = b'abc' + self.server.download_error = grpc.StatusCode.UNAVAILABLE + response = await self.client.get_binary_file_alpha1('files', 'file') + self.assertEqual(b'abc', await response.__anext__()) + with self.assertRaises(DaprGrpcError) as caught: + await response.read() + self.assertEqual(grpc.StatusCode.UNAVAILABLE, caught.exception.code()) + self.server.download_error = None + self.server.hold_download = True + self.server.download_cancelled.clear() + response = await self.client.get_binary_file_alpha1('files', 'file') + async with response: + self.assertEqual(b'abc', await response.read(3)) + self.assertTrue(await asyncio.to_thread(self.server.download_cancelled.wait, 5)) + + async def test_deadline_ends_upload_while_file_read_is_blocked(self): + entered = threading.Event() + release = threading.Event() + source = MagicMock() + + def read(size): + entered.set() + release.wait(5) + return b'x' + + source.read.side_effect = read + try: + with self.assertRaises(DaprGrpcError) as caught: + await asyncio.wait_for( + self.client.set_binary_file_alpha1('files', 'file', source, timeout=0.1), + timeout=2, + ) + self.assertTrue(entered.is_set()) + self.assertEqual(grpc.StatusCode.DEADLINE_EXCEEDED, caught.exception.code()) + finally: + release.set() + self.assertEqual(1, source.read.call_count) + source.close.assert_not_called() + + @patch.object(settings, 'DAPR_API_TOKEN', 'test-token') + async def test_upload_deadline_leaves_no_request_tasks_with_interceptors(self): + release = threading.Event() + source = MagicMock() + source.read.side_effect = lambda size: release.wait(5) and b'x' + self.server.token = 'test-token' + before = asyncio.all_tasks() + try: + async with DaprGrpcClientAsync(f'127.0.0.1:{self.server.grpc_port}') as client: + with self.assertRaises(DaprGrpcError) as caught: + await client.set_binary_file_alpha1('files', 'file', source, timeout=0.1) + self.assertEqual(grpc.StatusCode.DEADLINE_EXCEEDED, caught.exception.code()) + # Let scheduled cleanup finish, while keeping the client open: + # closing the channel must not be needed to clean up each RPC. + finishing = asyncio.all_tasks() - before + if finishing: + await asyncio.wait(finishing, timeout=1) + pending = [task for task in asyncio.all_tasks() - before if not task.done()] + self.assertEqual([], [task.get_coro().__qualname__ for task in pending]) + finally: + release.set() + source.close.assert_not_called() + + async def test_slow_server_applies_backpressure_to_file_reader(self): + self.server.hold_upload_body = True + source = MagicMock() + source.read.side_effect = [b'x' * 32768] * 4096 + [b''] + task = asyncio.create_task(self.client.set_binary_file_alpha1('files', 'file', source)) + try: + self.assertTrue(await asyncio.to_thread(self.server.upload_body_started.wait, 5)) + await asyncio.sleep(0.1) + self.assertFalse(task.done()) + self.assertLess(source.read.call_count, 4096) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + finally: + task.cancel() + self.server.upload_body_release.set() + await asyncio.gather(task, return_exceptions=True) + source.close.assert_not_called() + + async def test_client_shutdown_cancels_download(self): + self.server.files[('files', 'file')] = b'abc' + self.server.hold_download = True + response = await self.client.get_binary_file_alpha1('files', 'file') + self.assertEqual(b'abc', await response.read(3)) + await self.client.close() + with self.assertRaises(asyncio.CancelledError): + await response.read() + self.assertTrue(await asyncio.to_thread(self.server.download_cancelled.wait, 5)) + + async def test_cancel_blocked_download_preserves_cancellation(self): + self.server.files[('files', 'file')] = b'abc' + self.server.hold_download = True + response = await self.client.get_binary_file_alpha1('files', 'file') + self.assertEqual(b'abc', await response.read(3)) + task = asyncio.create_task(response.read()) + await asyncio.sleep(0) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + self.assertTrue(await asyncio.to_thread(self.server.download_cancelled.wait, 5)) + + async def test_invalid_input_and_timeout(self): + for data in ['text', io.StringIO('text'), None]: + with self.subTest(data=data), self.assertRaises(TypeError): + await self.client.set_binary_file_alpha1('files', 'file', data) + for method in [self.client.get_binary_file_alpha1, self.client.delete_binary_file_alpha1]: + with self.assertRaises(ValueError): + await method('files', '') + self.server.wait_for_upload = True + try: + with self.assertRaises(DaprGrpcError) as caught: + await self.client.set_binary_file_alpha1('files', 'file', b'x', timeout=0.05) + self.assertEqual(grpc.StatusCode.DEADLINE_EXCEEDED, caught.exception.code()) + finally: + self.server.upload_release.set() + + @patch.object(settings, 'DAPR_API_TOKEN', 'test-token') + @patch.object(settings, 'DAPR_API_TIMEOUT_SECONDS', 7) + async def test_authentication_for_binary_store_rpc_shapes_over_actual_aio_channel(self): + self.server.token = 'test-token' + async with DaprGrpcClientAsync(f'127.0.0.1:{self.server.grpc_port}') as client: + await client.set_binary_file_alpha1('files', 'file', b'hello') + response = await client.get_binary_file_alpha1('files', 'file') + self.assertEqual(b'hello', await response.read()) + await client.delete_binary_file_alpha1('files', 'file', timeout=3) + self.assertEqual(['set', 'get', 'delete'], [c[0] for c in self.server.calls]) + for method, metadata, deadline in self.server.calls: + self.assertEqual(1, metadata.count(('dapr-api-token', 'test-token'))) + if method in ['set', 'get']: + self.assertGreater(deadline, 5) + self.assertLessEqual(deadline, 7.1) + elif method == 'delete': + self.assertLessEqual(deadline, 3.1) + + @patch.object(settings, 'DAPR_API_TOKEN', 'test-token') + async def test_custom_metadata_survives_binary_store_rpc_shapes(self): + self.server.token = 'test-token' + metadata = (('custom-header', 'custom-value'),) + async with DaprGrpcClientAsync(f'127.0.0.1:{self.server.grpc_port}') as client: + await client._stub.SetBinaryFileAlpha1( + iter(_BinaryFileUpload('files', 'metadata', b'x', False, 32768)), + metadata=metadata, + ) + response = client._stub.GetBinaryFileAlpha1( + binary_v1.GetBinaryFileRequestAlpha1(component_name='files', file_name='metadata'), + metadata=metadata, + ) + self.assertEqual(1, len([chunk async for chunk in response])) + await client._stub.DeleteBinaryFileAlpha1( + binary_v1.DeleteBinaryFileRequestAlpha1( + component_name='files', file_name='metadata' + ), + metadata=metadata, + ) + self.assertEqual(['set', 'get', 'delete'], [c[0] for c in self.server.calls]) + for _, headers, _ in self.server.calls: + self.assertEqual(1, headers.count(('dapr-api-token', 'test-token'))) + self.assertIn(('custom-header', 'custom-value'), headers) diff --git a/tests/examples/test_binary_store.py b/tests/examples/test_binary_store.py new file mode 100644 index 000000000..6f1c9073b --- /dev/null +++ b/tests/examples/test_binary_store.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest + +from tests.binarystore_utils import require_binary_store_runtime + +pytestmark = [pytest.mark.binary_store, pytest.mark.example_dir('binary-store')] + + +@pytest.mark.parametrize('script', ['binary_store.py', 'binary_store_async.py']) +def test_binary_store(dapr, script): + require_binary_store_runtime() + output = dapr.run( + f'--app-id binary-store-example --resources-path ./components -- python3 {script}', + timeout=45, + ) + for line in [ + 'Binary file round trip: 262144 bytes', + 'Overwritten file: replaced', + 'Binary file deleted', + ]: + assert line in output, f'Missing in output: {line}' diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 28e790be2..f4c2b7cc2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,14 +1,17 @@ +import os import shlex import subprocess from contextlib import contextmanager from pathlib import Path from typing import Any, Generator, Iterator +from unittest.mock import patch import httpx import pytest from dapr.clients import DaprClient from dapr.conf import settings +from tests.binarystore_utils import require_binary_store_runtime from tests.crypto_utils import remove_test_keys, write_test_keys from tests.port_utils import wait_for_ports_free from tests.process_utils import get_kwargs_for_process_group, terminate_process_group @@ -22,6 +25,23 @@ CRYPTO_KEYS_DIR = INTEGRATION_DIR / 'keys' +@pytest.fixture(scope='module') +def binary_store_client(dapr_env): + """A token-protected 1.19 sidecar with isolated, in-memory resources.""" + require_binary_store_runtime() + token = 'binary-store-integration-token' + with ( + patch.dict(os.environ, {'DAPR_API_TOKEN': token}), + patch.object(settings, 'DAPR_API_TOKEN', token), + ): + try: + yield dapr_env.start_sidecar( + app_id='test-binary-store', resources=RESOURCES_DIR / 'binarystore' + ) + finally: + dapr_env.cleanup() + + class DaprTestEnvironment: """Manages Dapr sidecars and returns SDK clients for programmatic testing. diff --git a/tests/integration/resources/binarystore/binarystore.yaml b/tests/integration/resources/binarystore/binarystore.yaml new file mode 100644 index 000000000..7def012d0 --- /dev/null +++ b/tests/integration/resources/binarystore/binarystore.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: binarystore +spec: + type: binarystore.in-memory + version: v1 + metadata: + - name: maxSize + value: "8388608" + - name: maxTotalSize + value: "33554432" diff --git a/tests/integration/resources/binarystore/limited.yaml b/tests/integration/resources/binarystore/limited.yaml new file mode 100644 index 000000000..1380b9159 --- /dev/null +++ b/tests/integration/resources/binarystore/limited.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: limited +spec: + type: binarystore.in-memory + version: v1 + metadata: + - name: maxSize + value: "64" + - name: maxTotalSize + value: "128" diff --git a/tests/integration/test_binarystore.py b/tests/integration/test_binarystore.py new file mode 100644 index 000000000..a194ad401 --- /dev/null +++ b/tests/integration/test_binarystore.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import hashlib +import io +from concurrent.futures import ThreadPoolExecutor + +import grpc +import pytest + +from dapr.clients.exceptions import DaprGrpcError +from tests.binarystore_utils import EndOfUploadReader + +pytestmark = [ + pytest.mark.binary_store, + pytest.mark.filterwarnings('ignore:The Binary Store API:UserWarning'), +] + + +@pytest.mark.parametrize('size', [0, 1, 32767, 32768, 32769, 98309, 5 * 1024 * 1024]) +def test_streaming_round_trip(binary_store_client, size): + client = binary_store_client + name = f'sync/round-trip-{size}' + data = (bytes(range(256)) * (size // 256 + 1))[:size] + source = io.BytesIO(data) + client.set_binary_file_alpha1('binarystore', name, source) + assert not source.closed + digest = hashlib.sha256() + with client.get_binary_file_alpha1('binarystore', name) as response: + assert response.read(0) == b'' + digest.update(response.read(7)) + for chunk in response: + digest.update(chunk) + assert digest.digest() == hashlib.sha256(data).digest() + client.delete_binary_file_alpha1('binarystore', name) + with pytest.raises(DaprGrpcError) as caught: + client.get_binary_file_alpha1('binarystore', name).read() + assert caught.value.code() == grpc.StatusCode.NOT_FOUND + + +def test_overwrite_and_missing_resources(binary_store_client): + client = binary_store_client + client.set_binary_file_alpha1('binarystore', 'sync/overwrite', b'original') + with pytest.raises(DaprGrpcError) as caught: + client.set_binary_file_alpha1('binarystore', 'sync/overwrite', b'rejected') + assert caught.value.code() == grpc.StatusCode.ALREADY_EXISTS + client.set_binary_file_alpha1('binarystore', 'sync/overwrite', b'replaced', overwrite=True) + with client.get_binary_file_alpha1('binarystore', 'sync/overwrite') as response: + assert response.read() == b'replaced' + client.delete_binary_file_alpha1('binarystore', 'sync/overwrite') + with pytest.raises(DaprGrpcError) as caught: + client.delete_binary_file_alpha1('binarystore', 'sync/overwrite') + assert caught.value.code() == grpc.StatusCode.NOT_FOUND + for method in [ + client.set_binary_file_alpha1, + client.get_binary_file_alpha1, + client.delete_binary_file_alpha1, + ]: + with pytest.raises(DaprGrpcError) as caught: + if method == client.set_binary_file_alpha1: + method('missing', 'file', b'') + elif method == client.get_binary_file_alpha1: + method('missing', 'file').read() + else: + method('missing', 'file') + assert caught.value.code() == grpc.StatusCode.INVALID_ARGUMENT + + +def test_capacity_rejection_and_early_close(binary_store_client): + client = binary_store_client + with pytest.raises(DaprGrpcError) as caught: + client.set_binary_file_alpha1('limited', 'too-big', b'x' * 65) + assert caught.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED + client.set_binary_file_alpha1('binarystore', 'sync/early', b'x' * 100000) + with client.get_binary_file_alpha1('binarystore', 'sync/early') as response: + assert response.read(1) == b'x' + client.delete_binary_file_alpha1('binarystore', 'sync/early') + + +def test_capacity_recovery_and_failed_overwrite_preserves_original(binary_store_client): + client = binary_store_client + client.set_binary_file_alpha1('limited', 'capacity-a', b'a' * 64) + client.set_binary_file_alpha1('limited', 'capacity-b', b'b' * 64) + with pytest.raises(DaprGrpcError) as caught: + client.set_binary_file_alpha1('limited', 'overflow', b'x') + assert caught.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED + with pytest.raises(DaprGrpcError) as caught: + client.set_binary_file_alpha1('limited', 'capacity-a', b'c' * 65, overwrite=True) + assert caught.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED + with client.get_binary_file_alpha1('limited', 'capacity-a') as response: + assert response.read() == b'a' * 64 + with pytest.raises(DaprGrpcError) as caught: + client.get_binary_file_alpha1('limited', 'overflow').read() + assert caught.value.code() == grpc.StatusCode.NOT_FOUND + client.delete_binary_file_alpha1('limited', 'capacity-b') + client.set_binary_file_alpha1('limited', 'capacity-c', b'c' * 64) + client.delete_binary_file_alpha1('limited', 'capacity-a') + client.delete_binary_file_alpha1('limited', 'capacity-c') + + +def test_concurrent_calls_on_shared_client(binary_store_client): + client = binary_store_client + + def round_trip(index): + name = f'concurrent/{index}' + data = bytes([index]) * (256 * 1024 + index) + client.set_binary_file_alpha1('binarystore', name, data) + with client.get_binary_file_alpha1('binarystore', name) as response: + assert response.read() == data + client.delete_binary_file_alpha1('binarystore', name) + + with ThreadPoolExecutor(max_workers=4) as pool: + list(pool.map(round_trip, range(4))) + + +@pytest.mark.parametrize('overwrite', [False, True]) +def test_incomplete_upload_is_not_published(binary_store_client, overwrite): + client = binary_store_client + name = f'pending/{overwrite}' + if overwrite: + client.set_binary_file_alpha1('binarystore', name, b'original') + with EndOfUploadReader(b'x' * 128000) as source, ThreadPoolExecutor(max_workers=1) as pool: + upload = pool.submit( + client.set_binary_file_alpha1, 'binarystore', name, source, overwrite=overwrite + ) + try: + assert source.ready.wait(5) + assert not upload.done() + if overwrite: + with client.get_binary_file_alpha1('binarystore', name) as response: + assert response.read() == b'original' + else: + with pytest.raises(DaprGrpcError) as caught: + client.get_binary_file_alpha1('binarystore', name).read() + assert caught.value.code() == grpc.StatusCode.NOT_FOUND + finally: + source.release.set() + upload.result(timeout=5) + assert not source.closed + with client.get_binary_file_alpha1('binarystore', name) as response: + assert response.read() == b'x' * 128000 + client.delete_binary_file_alpha1('binarystore', name) + + +def test_large_duplicate_rejections_preserve_original(binary_store_client): + client = binary_store_client + client.set_binary_file_alpha1('binarystore', 'duplicate', b'original') + for _ in range(8): + with io.BytesIO(b'x' * (1024 * 1024)) as source: + with pytest.raises(DaprGrpcError) as caught: + client.set_binary_file_alpha1('binarystore', 'duplicate', source, chunk_size=1024) + assert caught.value.code() == grpc.StatusCode.ALREADY_EXISTS + assert not source.closed + with client.get_binary_file_alpha1('binarystore', 'duplicate') as response: + assert response.read() == b'original' + client.delete_binary_file_alpha1('binarystore', 'duplicate') diff --git a/tests/integration/test_binarystore_async.py b/tests/integration/test_binarystore_async.py new file mode 100644 index 000000000..2e19833d5 --- /dev/null +++ b/tests/integration/test_binarystore_async.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import asyncio +import hashlib +import io +import warnings + +import grpc +import pytest + +from dapr.aio.clients import DaprClient +from dapr.clients.exceptions import DaprGrpcError +from tests.binarystore_utils import EndOfUploadReader + +pytestmark = [ + pytest.mark.binary_store, + pytest.mark.filterwarnings('ignore:The Binary Store API:UserWarning'), +] + + +@pytest.mark.parametrize('size', [0, 1, 32767, 32768, 32769, 98309, 5 * 1024 * 1024]) +async def test_streaming_round_trip(binary_store_client, size): + name = f'async/round-trip-{size}' + data = (bytes(range(256)) * (size // 256 + 1))[:size] + async with DaprClient(address='127.0.0.1:13501') as client: + source = io.BytesIO(data) + await client.set_binary_file_alpha1('binarystore', name, source) + assert not source.closed + digest = hashlib.sha256() + response = await client.get_binary_file_alpha1('binarystore', name) + async with response: + digest.update(await response.read(7)) + async for chunk in response: + digest.update(chunk) + assert digest.digest() == hashlib.sha256(data).digest() + await client.delete_binary_file_alpha1('binarystore', name) + with pytest.raises(DaprGrpcError) as caught: + response = await client.get_binary_file_alpha1('binarystore', name) + await response.read() + assert caught.value.code() == grpc.StatusCode.NOT_FOUND + + +async def test_overwrite_errors_and_early_close(binary_store_client): + async with DaprClient(address='127.0.0.1:13501') as client: + await client.set_binary_file_alpha1('binarystore', 'async/overwrite', b'original') + with pytest.raises(DaprGrpcError) as caught: + await client.set_binary_file_alpha1('binarystore', 'async/overwrite', b'rejected') + assert caught.value.code() == grpc.StatusCode.ALREADY_EXISTS + await client.set_binary_file_alpha1( + 'binarystore', 'async/overwrite', b'x' * 100000, overwrite=True + ) + response = await client.get_binary_file_alpha1('binarystore', 'async/overwrite') + async with response: + assert await response.read(1) == b'x' + await client.delete_binary_file_alpha1('binarystore', 'async/overwrite') + with pytest.raises(DaprGrpcError) as caught: + await client.delete_binary_file_alpha1('binarystore', 'async/overwrite') + assert caught.value.code() == grpc.StatusCode.NOT_FOUND + with pytest.raises(DaprGrpcError) as caught: + await client.set_binary_file_alpha1('limited', 'too-big', b'x' * 65) + assert caught.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED + with pytest.raises(DaprGrpcError) as caught: + await client.set_binary_file_alpha1('missing', 'file', b'') + assert caught.value.code() == grpc.StatusCode.INVALID_ARGUMENT + with pytest.raises(DaprGrpcError) as caught: + response = await client.get_binary_file_alpha1('missing', 'file') + await response.read() + assert caught.value.code() == grpc.StatusCode.INVALID_ARGUMENT + + +async def test_capacity_recovery_and_failed_overwrite_preserves_original(binary_store_client): + async with DaprClient(address='127.0.0.1:13501') as client: + await client.set_binary_file_alpha1('limited', 'capacity-a', b'a' * 64) + await client.set_binary_file_alpha1('limited', 'capacity-b', b'b' * 64) + with pytest.raises(DaprGrpcError) as caught: + await client.set_binary_file_alpha1('limited', 'overflow', b'x') + assert caught.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED + with pytest.raises(DaprGrpcError) as caught: + await client.set_binary_file_alpha1('limited', 'capacity-a', b'c' * 65, overwrite=True) + assert caught.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED + response = await client.get_binary_file_alpha1('limited', 'capacity-a') + async with response: + assert await response.read() == b'a' * 64 + with pytest.raises(DaprGrpcError) as caught: + response = await client.get_binary_file_alpha1('limited', 'overflow') + await response.read() + assert caught.value.code() == grpc.StatusCode.NOT_FOUND + await client.delete_binary_file_alpha1('limited', 'capacity-b') + await client.set_binary_file_alpha1('limited', 'capacity-c', b'c' * 64) + await client.delete_binary_file_alpha1('limited', 'capacity-a') + await client.delete_binary_file_alpha1('limited', 'capacity-c') + + +async def test_concurrent_calls_on_shared_client(binary_store_client): + async with DaprClient(address='127.0.0.1:13501') as client: + + async def round_trip(index): + name = f'concurrent/{index}' + data = bytes([index]) * (256 * 1024 + index) + await client.set_binary_file_alpha1('binarystore', name, data) + response = await client.get_binary_file_alpha1('binarystore', name) + async with response: + assert await response.read() == data + await client.delete_binary_file_alpha1('binarystore', name) + + await asyncio.gather(*(round_trip(index) for index in range(4))) + + +@pytest.mark.parametrize('overwrite', [False, True]) +async def test_incomplete_upload_is_not_published(binary_store_client, overwrite): + async with DaprClient(address='127.0.0.1:13501') as client: + name = f'pending/{overwrite}' + if overwrite: + await client.set_binary_file_alpha1('binarystore', name, b'original') + with EndOfUploadReader(b'x' * 128000) as source: + upload = asyncio.create_task( + client.set_binary_file_alpha1('binarystore', name, source, overwrite=overwrite) + ) + try: + assert await asyncio.to_thread(source.ready.wait, 5) + assert not upload.done() + if overwrite: + response = await client.get_binary_file_alpha1('binarystore', name) + async with response: + assert await response.read() == b'original' + else: + with pytest.raises(DaprGrpcError) as caught: + response = await client.get_binary_file_alpha1('binarystore', name) + await response.read() + assert caught.value.code() == grpc.StatusCode.NOT_FOUND + finally: + source.release.set() + await asyncio.wait_for(upload, timeout=5) + assert not source.closed + response = await client.get_binary_file_alpha1('binarystore', name) + async with response: + assert await response.read() == b'x' * 128000 + await client.delete_binary_file_alpha1('binarystore', name) + + +async def test_large_duplicate_rejections_preserve_original(binary_store_client): + async with DaprClient(address='127.0.0.1:13501') as client: + await client.set_binary_file_alpha1('binarystore', 'duplicate', b'original') + masked_statuses = 0 + for _ in range(8): + with io.BytesIO(b'x' * (1024 * 1024)) as source: + with pytest.raises(DaprGrpcError) as caught: + await client.set_binary_file_alpha1( + 'binarystore', 'duplicate', source, chunk_size=1024 + ) + if caught.value.code() == grpc.StatusCode.INTERNAL: + # grpc/grpc#36066 can overwrite the server's rejection in + # Core. Accept only that precise transport failure, keeping + # the SDK's error wrapper/cause and data safety under test. + cause = caught.value.__cause__ + assert isinstance(cause, grpc.aio.AioRpcError) + assert caught.value.details() == 'Internal error from Core' + assert 'execute_batch' in cause.debug_error_string() + masked_statuses += 1 + else: + assert caught.value.code() == grpc.StatusCode.ALREADY_EXISTS + assert not source.closed + response = await client.get_binary_file_alpha1('binarystore', 'duplicate') + async with response: + assert await response.read() == b'original' + await client.delete_binary_file_alpha1('binarystore', 'duplicate') + if masked_statuses: + warnings.warn( + f'grpc/grpc#36066 masked ALREADY_EXISTS as INTERNAL in ' + f'{masked_statuses}/8 early upload rejections; original data was preserved', + RuntimeWarning, + stacklevel=1, + ) diff --git a/tests/test_binarystore_runtime.py b/tests/test_binarystore_runtime.py new file mode 100644 index 000000000..9452b3da0 --- /dev/null +++ b/tests/test_binarystore_runtime.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import subprocess +import unittest +from unittest.mock import patch + +import pytest + +from tests.binarystore_utils import require_binary_store_runtime + + +class BinaryStoreRuntimeGateTests(unittest.TestCase): + @patch.dict('os.environ', {'DAPR_TEST_REQUIRE_BINARY_STORE': '1'}) + @patch('tests.binarystore_utils.subprocess.run') + def test_capable_selected_runtime(self, run): + for version in [ + '1.19.0-rc.3', + '1.19.0-rc.4', + '1.19.0', + '1.19.1', + '1.20.0', + 'v1.19.0', + '1.19.0+local', + '1.19.0-rc.3+sha', + ]: + with self.subTest(version=version): + run.return_value.stdout = f'CLI version: 1.18.2\nRuntime version: {version}\n' + require_binary_store_runtime() + + @patch.dict('os.environ', {'DAPR_TEST_REQUIRE_BINARY_STORE': ''}) + @patch('tests.binarystore_utils.subprocess.run') + def test_older_or_unknown_runtime_skips_before_component_loading(self, run): + for version in ['n/a', '1.18.0', '1.19.0-rc.1', '1.19.0-rc.2', '1.19.0-rc.2+sha']: + with self.subTest(version=version), self.assertRaises(pytest.skip.Exception): + run.return_value.stdout = f'Runtime version: {version}' + require_binary_store_runtime() + + @patch.dict('os.environ', {'DAPR_TEST_REQUIRE_BINARY_STORE': '1'}) + @patch('tests.binarystore_utils.subprocess.run') + def test_strict_job_fails_on_missing_or_incompatible_runtime(self, run): + run.return_value.stdout = 'Runtime version: 1.18.0' + with self.assertRaises(pytest.fail.Exception): + require_binary_store_runtime() + for failure in [FileNotFoundError(), subprocess.TimeoutExpired('dapr', 15)]: + with self.subTest(failure=failure), self.assertRaises(pytest.fail.Exception): + run.side_effect = failure + require_binary_store_runtime() From afaa72e68eaf8d5b590f20090f2a31acd01124f1 Mon Sep 17 00:00:00 2001 From: Adepu Sri Charan Date: Fri, 23 Oct 2026 10:24:53 +0530 Subject: [PATCH 3/3] fix(clients): address Binary Store review feedback Signed-off-by: Adepu Sri Charan --- .github/workflows/run-tests.yaml | 13 +- dapr/aio/clients/grpc/_binarystore.py | 43 +++++- dapr/aio/clients/grpc/interceptors.py | 4 +- dapr/clients/grpc/interceptors.py | 4 +- examples/binary-store/README.md | 3 + examples/binary-store/requirements.txt | 2 + tests/clients/binarystore_server.py | 9 +- tests/clients/test_binarystore.py | 38 +++++ tests/clients/test_binarystore_async.py | 146 +++++++++++++++++++- tests/clients/test_client_interceptor.py | 18 +++ tests/integration/conftest.py | 25 +++- tests/integration/test_binarystore_async.py | 24 ++-- 12 files changed, 306 insertions(+), 23 deletions(-) diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 061a6c622..ab4921e42 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -171,6 +171,11 @@ jobs: with: version: 1.19.0-rc.4 github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Verify Dapr runtime version + run: | + runtime_version=$("$HOME/.dapr/bin/daprd" --version) + echo "Dapr runtime: $runtime_version" + test "$runtime_version" = "1.19.0-rc.4" - name: Validate binary-store integration and examples run: >- uv run pytest -m binary_store @@ -286,7 +291,7 @@ jobs: # changes. Skipped required checks count as passing, hence `if: always()` # plus an explicit result check instead of relying on `needs` alone. validate-complete: - needs: [validate, validate-dapr-head] + needs: [validate, validate-dapr-head, validate-binary-store] if: always() runs-on: ubuntu-latest steps: @@ -302,3 +307,9 @@ jobs: echo "validate-dapr-head result: ${{ needs.validate-dapr-head.result }}" exit 1 fi + - name: Fail unless the binary-store matrix succeeded + run: | + if [ "${{ needs.validate-binary-store.result }}" != "success" ]; then + echo "validate-binary-store result: ${{ needs.validate-binary-store.result }}" + exit 1 + fi diff --git a/dapr/aio/clients/grpc/_binarystore.py b/dapr/aio/clients/grpc/_binarystore.py index b1bb9a632..7585533e6 100644 --- a/dapr/aio/clients/grpc/_binarystore.py +++ b/dapr/aio/clients/grpc/_binarystore.py @@ -17,7 +17,8 @@ import operator from typing import Any, AsyncIterator, Optional -from grpc.aio import AioRpcError # type: ignore +from grpc import StatusCode # type: ignore +from grpc.aio import AioRpcError, Metadata # type: ignore from dapr.clients.exceptions import DaprGrpcError from dapr.clients.grpc._binarystore import _BinaryFileUpload @@ -92,6 +93,7 @@ def __init__(self, stream: Any) -> None: self._expected_seq = 0 self._closed = False self._eof = False + self._read_task: Optional[asyncio.Future[Any]] = None def _check_open(self) -> None: if self._closed: @@ -100,6 +102,29 @@ def _check_open(self) -> None: def __aiter__(self) -> AsyncIterator[bytes]: return self + async def _next_message(self) -> binary_v1.GetBinaryFileResponseAlpha1: + task = asyncio.ensure_future(self._iterator.__anext__()) + self._read_task = task + try: + # wait() raises CancelledError only for cancellation of this reader's + # task. Inspecting the result separately also works on Python 3.10, + # where Task.cancelling() is unavailable. + await asyncio.wait((task,)) + try: + return task.result() + except asyncio.CancelledError as error: + raise AioRpcError( + StatusCode.CANCELLED, + Metadata(), + Metadata(), + 'Binary file download was cancelled', + ) from error + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + self._read_task = None + async def __anext__(self) -> bytes: self._check_open() if self._buffer: @@ -109,7 +134,7 @@ async def __anext__(self) -> bytes: raise StopAsyncIteration try: while True: - message = await self._iterator.__anext__() + message = await self._next_message() if not message.HasField('payload'): raise ValueError('binary store response is missing its payload') if message.payload.seq != self._expected_seq: @@ -151,7 +176,19 @@ async def aclose(self) -> None: self._closed = True self._buffer = b'' if not self._eof: - self._stream.cancel() + cancel = getattr(self._stream, 'cancel', None) + if cancel is not None: + cancel() + else: + # A tracing interceptor may expose only an async iterator. + # Stop an active read before closing its generator. + task = self._read_task + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + close = getattr(self._iterator, 'aclose', None) + if close is not None: + await close() async def __aenter__(self) -> 'AsyncBinaryFileResponse': self._check_open() diff --git a/dapr/aio/clients/grpc/interceptors.py b/dapr/aio/clients/grpc/interceptors.py index 7ce77b190..5efba698c 100644 --- a/dapr/aio/clients/grpc/interceptors.py +++ b/dapr/aio/clients/grpc/interceptors.py @@ -105,7 +105,9 @@ async def _intercept_call(self, client_call_details: ClientCallDetails) -> Clien metadata: List[Tuple[str, str]] = [] if client_call_details.metadata is not None: metadata = list(client_call_details.metadata) # type: ignore[arg-type] - metadata.extend(self._metadata) + for header in self._metadata: + if header[0] != 'dapr-api-token' or header not in metadata: + metadata.append(header) new_call_details = _ClientCallDetailsAsync( client_call_details.method, diff --git a/dapr/clients/grpc/interceptors.py b/dapr/clients/grpc/interceptors.py index 45632169c..1642c031f 100644 --- a/dapr/clients/grpc/interceptors.py +++ b/dapr/clients/grpc/interceptors.py @@ -111,7 +111,9 @@ def _intercept_call(self, client_call_details: ClientCallDetails) -> ClientCallD metadata = [] if client_call_details.metadata is not None: metadata = list(client_call_details.metadata) - metadata.extend(self._metadata) + for header in self._metadata: + if header[0] != 'dapr-api-token' or header not in metadata: + metadata.append(header) new_call_details = _ClientCallDetails( client_call_details.method, diff --git a/examples/binary-store/README.md b/examples/binary-store/README.md index 697ab28cf..4aea19115 100644 --- a/examples/binary-store/README.md +++ b/examples/binary-store/README.md @@ -49,6 +49,9 @@ the remaining file in memory, so use it for small files. Partial reads and itera share one cursor; use one consumer per response. Get failures surface while reading. Use `with`/`close()` (async: `async with`/`aclose()`) to cancel an unfinished download. The async get method must first be awaited, as shown in the example. +If closing the client or response interrupts an async read, that read raises +`DaprGrpcError` with status `CANCELLED`. Cancelling the reader's own task preserves +`asyncio.CancelledError`. `delete_binary_file_alpha1(store_name, file_name, *, timeout=None)` deletes a file. Missing-file behavior depends on the component; the in-memory component reports diff --git a/examples/binary-store/requirements.txt b/examples/binary-store/requirements.txt index 53c597519..9ea7ded38 100644 --- a/examples/binary-store/requirements.txt +++ b/examples/binary-store/requirements.txt @@ -1 +1,3 @@ +# The binary-store API is not released on PyPI yet. Install the SDK from this +# checkout as described in README.md before running these examples. dapr diff --git a/tests/clients/binarystore_server.py b/tests/clients/binarystore_server.py index a6df7487e..8bcb999ff 100644 --- a/tests/clients/binarystore_server.py +++ b/tests/clients/binarystore_server.py @@ -17,7 +17,7 @@ import grpc -from dapr.proto import common_v1 +from dapr.proto import api_v1, common_v1 from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1 from tests.clients.fake_dapr_server import FakeDaprSidecar @@ -112,3 +112,10 @@ def DeleteBinaryFileAlpha1(self, request, context): def EncryptAlpha1(self, requests, context): self._record('encrypt', context) yield from super().EncryptAlpha1(requests, context) + + def SubscribeConfiguration(self, request, context): + self._record('configuration', context) + yield api_v1.SubscribeConfigurationResponse(id='configuration-subscription') + + def SubscribeConfigurationAlpha1(self, request, context): + yield from self.SubscribeConfiguration(request, context) diff --git a/tests/clients/test_binarystore.py b/tests/clients/test_binarystore.py index 7fcb6e0e5..c0ff17e8c 100644 --- a/tests/clients/test_binarystore.py +++ b/tests/clients/test_binarystore.py @@ -99,6 +99,22 @@ def test_upload_validation_and_initial_before_any_read(self): with self.subTest(data=data), self.assertRaises(TypeError): _BinaryFileUpload('files', 'file', data, False, 2) + def test_close_generator_releases_stream(self): + closed = [] + + def stream(): + try: + yield message(b'abc') + yield message(b'def', 1) + finally: + closed.append(True) + + response = BinaryFileResponse(stream()) + self.assertEqual(b'a', response.read(1)) + response.close() + response.close() + self.assertEqual([True], closed) + class BinaryStoreClientTests(unittest.TestCase): def setUp(self): @@ -258,6 +274,28 @@ def test_token_metadata_and_timeout_through_real_channel(self): elif method == 'delete': self.assertLessEqual(deadline, 3.1) + @patch.object(settings, 'DAPR_API_TOKEN', 'test-token') + def test_existing_configuration_stream_sends_one_token(self): + self.server.token = 'test-token' + cases = [(), (('custom-header', 'first'), ('another-header', 'second'))] + cases.append((*cases[-1], ('dapr-api-token', 'test-token'))) + with DaprGrpcClient(f'127.0.0.1:{self.server.grpc_port}') as client: + for rpc in ['SubscribeConfiguration', 'SubscribeConfigurationAlpha1']: + for metadata in cases: + with self.subTest(rpc=rpc, metadata=metadata): + responses = list( + getattr(client._stub, rpc)( + api_v1.SubscribeConfigurationRequest(store_name='configuration'), + metadata=metadata, + ) + ) + self.assertEqual('configuration-subscription', responses[0].id) + method, headers, _ = self.server.calls[-1] + self.assertEqual('configuration', method) + self.assertEqual(1, headers.count(('dapr-api-token', 'test-token'))) + for header in metadata: + self.assertIn(header, headers) + @patch.object(settings, 'DAPR_API_TOKEN', 'test-token') def test_custom_metadata_survives_all_rpc_shapes(self): self.server.token = 'test-token' diff --git a/tests/clients/test_binarystore_async.py b/tests/clients/test_binarystore_async.py index 771c348be..e55bfe421 100644 --- a/tests/clients/test_binarystore_async.py +++ b/tests/clients/test_binarystore_async.py @@ -14,6 +14,7 @@ """ import asyncio +import builtins import io import threading import unittest @@ -29,6 +30,7 @@ from dapr.clients.grpc._binarystore import _BinaryFileUpload from dapr.clients.retry import RetryPolicy from dapr.conf import settings +from dapr.proto import api_v1 from dapr.proto.runtime.v1 import binarystore_pb2 as binary_v1 from tests.clients.binarystore_server import BinaryStoreSidecar from tests.clients.test_binarystore import message @@ -89,6 +91,103 @@ async def test_negative_sizes_empty_chunks_and_eof(self): await response.aclose() stream.cancel.assert_not_called() + async def test_close_async_generator_releases_stream(self): + closed = [] + + async def stream(): + try: + yield message(b'abc') + yield message(b'def', 1) + finally: + closed.append(True) + + response = AsyncBinaryFileResponse(stream()) + self.assertEqual(b'a', await response.read(1)) + await response.aclose() + await response.aclose() + self.assertEqual([True], closed) + + async def test_external_close_ends_pending_generator_read_as_rpc_error(self): + started = asyncio.Event() + closed = asyncio.Event() + + async def stream(): + try: + started.set() + await asyncio.Future() + yield message(b'never reached') + finally: + closed.set() + + response = AsyncBinaryFileResponse(stream()) + reader = asyncio.create_task(response.read()) + try: + await asyncio.wait_for(started.wait(), 2) + await response.aclose() + with self.assertRaises(DaprGrpcError) as caught: + await asyncio.wait_for(reader, 2) + self.assertEqual(grpc.StatusCode.CANCELLED, caught.exception.code()) + self.assertFalse(reader.cancelled()) + self.assertTrue(closed.is_set()) + finally: + reader.cancel() + await asyncio.gather(reader, return_exceptions=True) + + async def test_caller_cancellation_closes_generator(self): + started = asyncio.Event() + closed = asyncio.Event() + + async def stream(): + try: + started.set() + await asyncio.Future() + yield message(b'never reached') + finally: + closed.set() + + response = AsyncBinaryFileResponse(stream()) + reader = asyncio.create_task(response.read()) + try: + await asyncio.wait_for(started.wait(), 2) + reader.cancel() + with self.assertRaises(asyncio.CancelledError): + await reader + self.assertTrue(reader.cancelled()) + self.assertTrue(closed.is_set()) + finally: + reader.cancel() + await asyncio.gather(reader, return_exceptions=True) + + async def test_malformed_response_closes_async_generator(self): + closed = [] + + async def stream(): + try: + yield message(b'invalid sequence', 1) + finally: + closed.append(True) + + response = AsyncBinaryFileResponse(stream()) + with self.assertRaisesRegex(ValueError, 'sequence number'): + await response.read() + self.assertEqual([True], closed) + + @unittest.skipUnless(hasattr(asyncio, 'TaskGroup'), 'TaskGroup requires Python 3.11') + async def test_rpc_cancellation_is_reported_by_task_group(self): + async def stream(): + raise asyncio.CancelledError() + yield message(b'never reached') + + response = AsyncBinaryFileResponse(stream()) + with self.assertRaises(getattr(builtins, 'ExceptionGroup')) as caught: + async with getattr(asyncio, 'TaskGroup')() as group: + reader = group.create_task(response.read()) + self.assertFalse(reader.cancelled()) + self.assertEqual(1, len(caught.exception.exceptions)) + error = caught.exception.exceptions[0] + self.assertIsInstance(error, DaprGrpcError) + self.assertEqual(grpc.StatusCode.CANCELLED, error.code()) + class AsyncBinaryStoreClientTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -207,6 +306,9 @@ async def test_download_error_and_early_close(self): with self.assertRaises(DaprGrpcError) as caught: await response.read() self.assertEqual(grpc.StatusCode.UNAVAILABLE, caught.exception.code()) + with self.assertRaises(DaprGrpcError) as caught: + await self.client.delete_binary_file_alpha1('files', 'missing') + self.assertEqual(grpc.StatusCode.NOT_FOUND, caught.exception.code()) self.server.download_error = None self.server.hold_download = True self.server.download_cancelled.clear() @@ -287,8 +389,27 @@ async def test_client_shutdown_cancels_download(self): response = await self.client.get_binary_file_alpha1('files', 'file') self.assertEqual(b'abc', await response.read(3)) await self.client.close() - with self.assertRaises(asyncio.CancelledError): + with self.assertRaises(DaprGrpcError) as caught: await response.read() + self.assertEqual(grpc.StatusCode.CANCELLED, caught.exception.code()) + self.assertTrue(await asyncio.to_thread(self.server.download_cancelled.wait, 5)) + + async def test_close_ends_blocked_download_as_rpc_error(self): + self.server.files[('files', 'file')] = b'abc' + self.server.hold_download = True + response = await self.client.get_binary_file_alpha1('files', 'file') + self.assertEqual(b'abc', await response.read(3)) + reader = asyncio.create_task(response.read()) + try: + await asyncio.sleep(0) + await response.aclose() + with self.assertRaises(DaprGrpcError) as caught: + await asyncio.wait_for(reader, 2) + self.assertEqual(grpc.StatusCode.CANCELLED, caught.exception.code()) + self.assertFalse(reader.cancelled()) + finally: + reader.cancel() + await asyncio.gather(reader, return_exceptions=True) self.assertTrue(await asyncio.to_thread(self.server.download_cancelled.wait, 5)) async def test_cancel_blocked_download_preserves_cancellation(self): @@ -336,6 +457,29 @@ async def test_authentication_for_binary_store_rpc_shapes_over_actual_aio_channe elif method == 'delete': self.assertLessEqual(deadline, 3.1) + @patch.object(settings, 'DAPR_API_TOKEN', 'test-token') + async def test_existing_configuration_stream_sends_one_token(self): + self.server.token = 'test-token' + cases = [(), (('custom-header', 'first'), ('another-header', 'second'))] + cases.append((*cases[-1], ('dapr-api-token', 'test-token'))) + async with DaprGrpcClientAsync(f'127.0.0.1:{self.server.grpc_port}') as client: + for rpc in ['SubscribeConfiguration', 'SubscribeConfigurationAlpha1']: + for metadata in cases: + with self.subTest(rpc=rpc, metadata=metadata): + responses = [ + response + async for response in getattr(client._stub, rpc)( + api_v1.SubscribeConfigurationRequest(store_name='configuration'), + metadata=metadata, + ) + ] + self.assertEqual('configuration-subscription', responses[0].id) + method, headers, _ = self.server.calls[-1] + self.assertEqual('configuration', method) + self.assertEqual(1, headers.count(('dapr-api-token', 'test-token'))) + for header in metadata: + self.assertIn(header, headers) + @patch.object(settings, 'DAPR_API_TOKEN', 'test-token') async def test_custom_metadata_survives_binary_store_rpc_shapes(self): self.server.token = 'test-token' diff --git a/tests/clients/test_client_interceptor.py b/tests/clients/test_client_interceptor.py index 52ca9618b..292629ea9 100644 --- a/tests/clients/test_client_interceptor.py +++ b/tests/clients/test_client_interceptor.py @@ -46,3 +46,21 @@ def test_intercept_unary_unary_existing_metadata(self): self.assertIsNotNone(response) self.assertEqual(2, len(response.metadata)) self.assertEqual([('header', 'value'), ('api-token', 'test-token')], response.metadata) + + def test_matching_api_token_is_not_duplicated_and_other_values_are_preserved(self): + interceptor = DaprClientInterceptor([('dapr-api-token', 'test-token')]) + metadata = [ + ('header', 'first'), + ('header', 'second'), + ('dapr-api-token', 'test-token'), + ] + call_details = _ClientCallDetails('method1', 10, metadata, None, None, None) + for intercept in [ + interceptor.intercept_unary_unary, + interceptor.intercept_unary_stream, + interceptor.intercept_stream_unary, + interceptor.intercept_stream_stream, + ]: + with self.subTest(intercept=intercept.__name__): + response = intercept(self.fake_continuation, call_details, self._fake_request) + self.assertEqual(metadata, response.metadata) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f4c2b7cc2..cbc4a25ff 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -26,7 +26,7 @@ @pytest.fixture(scope='module') -def binary_store_client(dapr_env): +def binary_store_sidecar(dapr_env): """A token-protected 1.19 sidecar with isolated, in-memory resources.""" require_binary_store_runtime() token = 'binary-store-integration-token' @@ -35,13 +35,32 @@ def binary_store_client(dapr_env): patch.object(settings, 'DAPR_API_TOKEN', token), ): try: - yield dapr_env.start_sidecar( - app_id='test-binary-store', resources=RESOURCES_DIR / 'binarystore' + grpc_port = 13511 + client = dapr_env.start_sidecar( + app_id='test-binary-store', + resources=RESOURCES_DIR / 'binarystore', + grpc_port=grpc_port, + http_port=13510, + internal_grpc_port=13512, + metrics_port=13513, ) + yield client, f'127.0.0.1:{grpc_port}' finally: dapr_env.cleanup() +@pytest.fixture(scope='module') +def binary_store_client(binary_store_sidecar): + client, _ = binary_store_sidecar + return client + + +@pytest.fixture(scope='module') +def binary_store_endpoint(binary_store_sidecar): + _, endpoint = binary_store_sidecar + return endpoint + + class DaprTestEnvironment: """Manages Dapr sidecars and returns SDK clients for programmatic testing. diff --git a/tests/integration/test_binarystore_async.py b/tests/integration/test_binarystore_async.py index 2e19833d5..6820b0e21 100644 --- a/tests/integration/test_binarystore_async.py +++ b/tests/integration/test_binarystore_async.py @@ -32,10 +32,10 @@ @pytest.mark.parametrize('size', [0, 1, 32767, 32768, 32769, 98309, 5 * 1024 * 1024]) -async def test_streaming_round_trip(binary_store_client, size): +async def test_streaming_round_trip(binary_store_endpoint, size): name = f'async/round-trip-{size}' data = (bytes(range(256)) * (size // 256 + 1))[:size] - async with DaprClient(address='127.0.0.1:13501') as client: + async with DaprClient(address=binary_store_endpoint) as client: source = io.BytesIO(data) await client.set_binary_file_alpha1('binarystore', name, source) assert not source.closed @@ -53,8 +53,8 @@ async def test_streaming_round_trip(binary_store_client, size): assert caught.value.code() == grpc.StatusCode.NOT_FOUND -async def test_overwrite_errors_and_early_close(binary_store_client): - async with DaprClient(address='127.0.0.1:13501') as client: +async def test_overwrite_errors_and_early_close(binary_store_endpoint): + async with DaprClient(address=binary_store_endpoint) as client: await client.set_binary_file_alpha1('binarystore', 'async/overwrite', b'original') with pytest.raises(DaprGrpcError) as caught: await client.set_binary_file_alpha1('binarystore', 'async/overwrite', b'rejected') @@ -81,8 +81,8 @@ async def test_overwrite_errors_and_early_close(binary_store_client): assert caught.value.code() == grpc.StatusCode.INVALID_ARGUMENT -async def test_capacity_recovery_and_failed_overwrite_preserves_original(binary_store_client): - async with DaprClient(address='127.0.0.1:13501') as client: +async def test_capacity_recovery_and_failed_overwrite_preserves_original(binary_store_endpoint): + async with DaprClient(address=binary_store_endpoint) as client: await client.set_binary_file_alpha1('limited', 'capacity-a', b'a' * 64) await client.set_binary_file_alpha1('limited', 'capacity-b', b'b' * 64) with pytest.raises(DaprGrpcError) as caught: @@ -104,8 +104,8 @@ async def test_capacity_recovery_and_failed_overwrite_preserves_original(binary_ await client.delete_binary_file_alpha1('limited', 'capacity-c') -async def test_concurrent_calls_on_shared_client(binary_store_client): - async with DaprClient(address='127.0.0.1:13501') as client: +async def test_concurrent_calls_on_shared_client(binary_store_endpoint): + async with DaprClient(address=binary_store_endpoint) as client: async def round_trip(index): name = f'concurrent/{index}' @@ -120,8 +120,8 @@ async def round_trip(index): @pytest.mark.parametrize('overwrite', [False, True]) -async def test_incomplete_upload_is_not_published(binary_store_client, overwrite): - async with DaprClient(address='127.0.0.1:13501') as client: +async def test_incomplete_upload_is_not_published(binary_store_endpoint, overwrite): + async with DaprClient(address=binary_store_endpoint) as client: name = f'pending/{overwrite}' if overwrite: await client.set_binary_file_alpha1('binarystore', name, b'original') @@ -151,8 +151,8 @@ async def test_incomplete_upload_is_not_published(binary_store_client, overwrite await client.delete_binary_file_alpha1('binarystore', name) -async def test_large_duplicate_rejections_preserve_original(binary_store_client): - async with DaprClient(address='127.0.0.1:13501') as client: +async def test_large_duplicate_rejections_preserve_original(binary_store_endpoint): + async with DaprClient(address=binary_store_endpoint) as client: await client.set_binary_file_alpha1('binarystore', 'duplicate', b'original') masked_statuses = 0 for _ in range(8):