-
Notifications
You must be signed in to change notification settings - Fork 299
Add gRPC user agent interceptor #2172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
malsomesh9
wants to merge
1
commit into
hiero-ledger:main
from
malsomesh9:codex/grpc-user-agent-header
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from collections import namedtuple | ||
| from importlib import metadata as importlib_metadata | ||
|
|
||
| import grpc | ||
|
|
||
|
|
||
| _SDK_PACKAGE_NAME = "hiero-sdk-python" | ||
| _USER_AGENT_HEADER = "x-user-agent" | ||
|
|
||
|
|
||
| def _get_sdk_version() -> str: | ||
| """Return the installed SDK version, or dev for local checkouts.""" | ||
| try: | ||
| return importlib_metadata.version(_SDK_PACKAGE_NAME) | ||
| except importlib_metadata.PackageNotFoundError: | ||
| return "dev" | ||
|
|
||
|
|
||
| class _ClientCallDetails( | ||
| namedtuple( | ||
| "_ClientCallDetails", | ||
| ("method", "timeout", "metadata", "credentials", "wait_for_ready", "compression"), | ||
| ), | ||
| grpc.ClientCallDetails, | ||
| ): | ||
| """Concrete call details used to attach metadata in client interceptors.""" | ||
|
|
||
|
|
||
| class _UserAgentInterceptor(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor): | ||
| """gRPC client interceptor that identifies this SDK to Hiero nodes.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self._user_agent = f"{_SDK_PACKAGE_NAME}/{_get_sdk_version()}" | ||
|
|
||
| def intercept_unary_unary(self, continuation, client_call_details, request): | ||
| return continuation(self._with_user_agent(client_call_details), request) | ||
|
|
||
| def intercept_unary_stream(self, continuation, client_call_details, request): | ||
| return continuation(self._with_user_agent(client_call_details), request) | ||
|
|
||
| def _with_user_agent(self, client_call_details): | ||
| metadata = list(client_call_details.metadata or ()) | ||
| metadata.append((_USER_AGENT_HEADER, self._user_agent)) | ||
|
|
||
| return _ClientCallDetails( | ||
| client_call_details.method, | ||
| client_call_details.timeout, | ||
| metadata, | ||
| client_call_details.credentials, | ||
| client_call_details.wait_for_ready, | ||
| client_call_details.compression, | ||
| ) | ||
|
|
||
|
|
||
| _USER_AGENT_INTERCEPTOR = _UserAgentInterceptor() | ||
|
|
||
|
|
||
| def _apply_user_agent_interceptor(channel: grpc.Channel) -> grpc.Channel: | ||
| """Wrap a channel so every outgoing call includes the SDK user-agent header.""" | ||
| return grpc.intercept_channel(channel, _USER_AGENT_INTERCEPTOR) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from concurrent import futures | ||
| from importlib import metadata as importlib_metadata | ||
| from unittest.mock import Mock, patch | ||
|
|
||
| import grpc | ||
| import pytest | ||
|
|
||
| from hiero_sdk_python.user_agent_interceptor import ( | ||
| _USER_AGENT_HEADER, | ||
| _apply_user_agent_interceptor, | ||
| _ClientCallDetails, | ||
| _get_sdk_version, | ||
| _UserAgentInterceptor, | ||
| ) | ||
|
|
||
|
|
||
| pytestmark = pytest.mark.unit | ||
|
|
||
|
|
||
| def _call_details(metadata=None): | ||
| return _ClientCallDetails( | ||
| method="/proto.Service/Method", | ||
| timeout=30, | ||
| metadata=metadata, | ||
| credentials=None, | ||
| wait_for_ready=None, | ||
| compression=None, | ||
| ) | ||
|
|
||
|
|
||
| def test_get_sdk_version_returns_installed_version(): | ||
| assert _get_sdk_version() | ||
|
|
||
|
|
||
| @patch("hiero_sdk_python.user_agent_interceptor.importlib_metadata.version") | ||
| def test_get_sdk_version_falls_back_to_dev(mock_version): | ||
| mock_version.side_effect = importlib_metadata.PackageNotFoundError | ||
|
|
||
| assert _get_sdk_version() == "dev" | ||
|
|
||
|
|
||
| @patch("hiero_sdk_python.user_agent_interceptor._get_sdk_version", return_value="1.2.3") | ||
| def test_unary_unary_interceptor_adds_user_agent_header(mock_get_version): | ||
| interceptor = _UserAgentInterceptor() | ||
| continuation = Mock(return_value="response") | ||
|
|
||
| response = interceptor.intercept_unary_unary(continuation, _call_details(), request="request") | ||
|
|
||
| assert response == "response" | ||
| modified_call_details = continuation.call_args.args[0] | ||
| assert (_USER_AGENT_HEADER, "hiero-sdk-python/1.2.3") in modified_call_details.metadata | ||
|
|
||
|
|
||
| @patch("hiero_sdk_python.user_agent_interceptor._get_sdk_version", return_value="1.2.3") | ||
| def test_unary_stream_interceptor_adds_user_agent_header(mock_get_version): | ||
| interceptor = _UserAgentInterceptor() | ||
| continuation = Mock(return_value=iter(["response"])) | ||
|
|
||
| response = interceptor.intercept_unary_stream(continuation, _call_details(), request="request") | ||
|
|
||
| assert list(response) == ["response"] | ||
| modified_call_details = continuation.call_args.args[0] | ||
| assert (_USER_AGENT_HEADER, "hiero-sdk-python/1.2.3") in modified_call_details.metadata | ||
|
|
||
|
|
||
| @patch("hiero_sdk_python.user_agent_interceptor._get_sdk_version", return_value="1.2.3") | ||
| def test_interceptor_preserves_existing_metadata(mock_get_version): | ||
| interceptor = _UserAgentInterceptor() | ||
| continuation = Mock(return_value="response") | ||
| original_metadata = [("authorization", "token")] | ||
|
|
||
| interceptor.intercept_unary_unary(continuation, _call_details(original_metadata), request="request") | ||
|
|
||
| modified_metadata = continuation.call_args.args[0].metadata | ||
| assert modified_metadata == [ | ||
| ("authorization", "token"), | ||
| (_USER_AGENT_HEADER, "hiero-sdk-python/1.2.3"), | ||
| ] | ||
|
|
||
|
|
||
| @patch("grpc.intercept_channel") | ||
| def test_apply_user_agent_interceptor_wraps_channel(mock_intercept_channel): | ||
| channel = Mock(spec=grpc.Channel) | ||
| intercepted_channel = Mock(spec=grpc.Channel) | ||
| mock_intercept_channel.return_value = intercepted_channel | ||
|
|
||
| assert _apply_user_agent_interceptor(channel) is intercepted_channel | ||
| mock_intercept_channel.assert_called_once() | ||
| assert mock_intercept_channel.call_args.args[0] is channel | ||
| assert isinstance(mock_intercept_channel.call_args.args[1], _UserAgentInterceptor) | ||
|
|
||
|
|
||
| def test_interceptor_sends_valid_grpc_metadata(): | ||
| received_metadata = [] | ||
|
|
||
| def handle_request(request, context): | ||
| received_metadata.extend(context.invocation_metadata()) | ||
| return b"response" | ||
|
|
||
| server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) | ||
| method_handler = grpc.unary_unary_rpc_method_handler( | ||
| handle_request, | ||
| request_deserializer=lambda request: request, | ||
| response_serializer=lambda response: response, | ||
| ) | ||
| server.add_generic_rpc_handlers((grpc.method_handlers_generic_handler("test.Service", {"Method": method_handler}),)) | ||
| port = server.add_insecure_port("127.0.0.1:0") | ||
| server.start() | ||
|
|
||
| try: | ||
| channel = _apply_user_agent_interceptor(grpc.insecure_channel(f"127.0.0.1:{port}")) | ||
| method = channel.unary_unary( | ||
| "/test.Service/Method", | ||
| request_serializer=lambda request: request, | ||
| response_deserializer=lambda response: response, | ||
| ) | ||
|
|
||
| assert method(b"request", timeout=5) == b"response" | ||
| finally: | ||
| server.stop(0) | ||
|
|
||
| user_agent_values = [value for key, value in received_metadata if key == _USER_AGENT_HEADER] | ||
| assert len(user_agent_values) == 1 | ||
| assert user_agent_values[0].startswith("hiero-sdk-python/") | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move the real gRPC server check out of the unit test set.
Line 19 marks the whole module as unit tests, but Line 102 starts a real gRPC server and Line 113 opens a real loopback channel. Keep this coverage, but move it to an integration test module or remove the module-level unit marker and explicitly mark only the pure tests as unit.
Suggested marking split if this test remains in the same file
As per coding guidelines, "No network calls or external dependencies (unit tests are isolated)."
Also applies to: 95-126