Skip to content

Commit 78645d9

Browse files
committed
1 parent 0e9f036 commit 78645d9

5 files changed

Lines changed: 148 additions & 7 deletions

File tree

tilebox-grpc/_tilebox/grpc/aio/channel.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
CHANNEL_OPTIONS,
66
ChannelInfo,
77
ChannelProtocol,
8+
_client_metadata,
89
add_metadata,
910
parse_channel_info,
1011
update_method,
@@ -34,7 +35,7 @@ def open_channel(url: str, auth_token: str | None = None, rpc_method_prefix: str
3435
A gRPC channel.
3536
"""
3637
channel_info = parse_channel_info(url)
37-
interceptors: list[ClientInterceptor] = []
38+
interceptors: list[ClientInterceptor] = [_ClientMetadataInterceptor()]
3839
if auth_token is not None:
3940
interceptors = [_AuthMetadataInterceptor(auth_token), *interceptors] # add auth interceptor as the first one
4041
if rpc_method_prefix is not None:
@@ -90,6 +91,20 @@ async def intercept_unary_unary(
9091
return await continuation(add_metadata(client_call_details, [self._auth]), request)
9192

9293

94+
class _ClientMetadataInterceptor(UnaryUnaryClientInterceptor):
95+
def __init__(self) -> None:
96+
super().__init__()
97+
self._metadata = list(_client_metadata().items())
98+
99+
async def intercept_unary_unary(
100+
self,
101+
continuation: Callable[[ClientCallDetails, RequestType], UnaryUnaryCall],
102+
client_call_details: ClientCallDetails,
103+
request: RequestType,
104+
) -> UnaryUnaryCall:
105+
return await continuation(add_metadata(client_call_details, self._metadata), request)
106+
107+
93108
class _RpcMethodPrefixInterceptor(UnaryUnaryClientInterceptor):
94109
def __init__(self, prefix: str) -> None:
95110
"""A gRPC channel interceptor which prefixes every outgoing RPC method path."""

tilebox-grpc/_tilebox/grpc/channel.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from collections.abc import Awaitable, Callable, Mapping
44
from dataclasses import dataclass
55
from enum import Enum
6+
from importlib.metadata import PackageNotFoundError
7+
from importlib.metadata import version as package_version
68
from typing import Literal, TypeVar
79

810
from _tilebox.grpc.error import async_wrap_connect_rpc, wrap_connect_rpc
@@ -46,6 +48,9 @@
4648
("grpc.service_config", json.dumps(_SERVICE_CONFIG)),
4749
]
4850

51+
CLIENT_SOURCE_HEADER = "tilebox-client-source"
52+
CLIENT_VERSION_HEADER = "tilebox-client-version"
53+
4954

5055
class ChannelProtocol(Enum):
5156
HTTPS = 1
@@ -77,7 +82,7 @@ def open_channel(url: str, auth_token: str | None = None, rpc_method_prefix: str
7782
A sync gRPC channel.
7883
"""
7984
channel_info = parse_channel_info(url)
80-
interceptors: list[UnaryUnaryClientInterceptor] = []
85+
interceptors: list[UnaryUnaryClientInterceptor] = [_ClientMetadataInterceptor()]
8186
if auth_token is not None:
8287
interceptors = [_AuthMetadataInterceptor(auth_token), *interceptors] # add auth interceptor as the first one
8388
if rpc_method_prefix is not None:
@@ -185,7 +190,7 @@ def __init__(
185190
method_path_prefix = _rpc_method_prefix_path(rpc_method_prefix)
186191
service_name = _connect_service_name(client)
187192
self._client = client
188-
self._headers = headers
193+
self._headers = {**(headers or {}), **_client_metadata()}
189194

190195
for connect_name in _connect_client_methods(client):
191196
grpc_name = _snake_to_pascal_case(connect_name)
@@ -211,7 +216,7 @@ def __init__(
211216
method_path_prefix = _rpc_method_prefix_path(rpc_method_prefix)
212217
service_name = _connect_service_name(client)
213218
self._client = client
214-
self._headers = headers
219+
self._headers = {**(headers or {}), **_client_metadata()}
215220

216221
for connect_name in _connect_client_methods(client):
217222
grpc_name = _snake_to_pascal_case(connect_name)
@@ -278,6 +283,20 @@ def intercept_unary_unary(
278283
return continuation(add_metadata(client_call_details, [self._auth]), request)
279284

280285

286+
class _ClientMetadataInterceptor(UnaryUnaryClientInterceptor):
287+
def __init__(self) -> None:
288+
super().__init__()
289+
self._metadata = list(_client_metadata().items())
290+
291+
def intercept_unary_unary(
292+
self,
293+
continuation: Callable[[ClientCallDetails, RequestType], ResponseType],
294+
client_call_details: ClientCallDetails,
295+
request: RequestType,
296+
) -> ResponseType:
297+
return continuation(add_metadata(client_call_details, self._metadata), request)
298+
299+
281300
class _RpcMethodPrefixInterceptor(UnaryUnaryClientInterceptor):
282301
def __init__(self, prefix: str) -> None:
283302
"""A sync gRPC channel interceptor which prefixes every outgoing RPC method path."""
@@ -301,6 +320,17 @@ def add_metadata(
301320
return _replace_call_details(client_call_details, metadata=metadata)
302321

303322

323+
def _client_metadata() -> dict[str, str]:
324+
try:
325+
client_version = package_version("tilebox-grpc")
326+
except PackageNotFoundError:
327+
client_version = "dev"
328+
return {
329+
CLIENT_SOURCE_HEADER: "python_sdk",
330+
CLIENT_VERSION_HEADER: client_version,
331+
}
332+
333+
304334
def update_method(client_call_details: ClientCallDetails, prefix: str) -> ClientCallDetails:
305335
return _replace_call_details(client_call_details, method=prefix_rpc_method(client_call_details.method, prefix))
306336

tilebox-grpc/_tilebox/grpc/replay.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from _tilebox.grpc.channel import (
1010
_AuthMetadataInterceptor,
11+
_ClientMetadataInterceptor,
1112
_open_channel,
1213
parse_channel_info,
1314
)
@@ -32,7 +33,7 @@
3233
def open_recording_channel(url: str, auth_token: str | None, recording: str | Path) -> Channel:
3334
"""Open a gRPC channel to the given URL and record all requests and responses to a file."""
3435
channel_info = parse_channel_info(url)
35-
interceptors: list[UnaryUnaryClientInterceptor] = [_RecordRPCsInterceptor(recording)]
36+
interceptors: list[UnaryUnaryClientInterceptor] = [_ClientMetadataInterceptor(), _RecordRPCsInterceptor(recording)]
3637
if auth_token is not None:
3738
interceptors = [_AuthMetadataInterceptor(auth_token), *interceptors] # add auth interceptor as the first one
3839

tilebox-grpc/tests/aio/test_channel.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
import pytest
44

5-
from _tilebox.grpc.aio.channel import ClientCallDetails, _AuthMetadataInterceptor, _RpcMethodPrefixInterceptor
5+
from _tilebox.grpc.aio.channel import (
6+
ClientCallDetails,
7+
_AuthMetadataInterceptor,
8+
_ClientMetadataInterceptor,
9+
_RpcMethodPrefixInterceptor,
10+
)
11+
from _tilebox.grpc.channel import CLIENT_SOURCE_HEADER, CLIENT_VERSION_HEADER
612

713

814
@pytest.mark.asyncio
@@ -22,6 +28,23 @@ async def test_auth_interceptor(req_metadata: list[tuple[str, str]] | None) -> N
2228
assert ("authorization", "Bearer very-secret") in updated_call_details.metadata
2329

2430

31+
@pytest.mark.asyncio
32+
async def test_client_metadata_interceptor() -> None:
33+
interceptor = _ClientMetadataInterceptor()
34+
mock_method = AsyncMock()
35+
36+
await interceptor.intercept_unary_unary(
37+
mock_method,
38+
ClientCallDetails("/some-rpc-method", 10, [("authorization", "Bearer token")], None, True),
39+
AsyncMock(),
40+
)
41+
42+
metadata = mock_method.call_args[0][0].metadata
43+
assert ("authorization", "Bearer token") in metadata
44+
assert (CLIENT_SOURCE_HEADER, "python_sdk") in metadata
45+
assert any(key == CLIENT_VERSION_HEADER and value for key, value in metadata)
46+
47+
2548
@pytest.mark.asyncio
2649
async def test_rpc_method_prefix_interceptor() -> None:
2750
interceptor = _RpcMethodPrefixInterceptor("/public")

tilebox-grpc/tests/test_channel.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1+
from importlib.metadata import PackageNotFoundError
12
from unittest.mock import MagicMock, patch
23

34
import pytest
45

56
from _tilebox.grpc.channel import (
67
CHANNEL_OPTIONS,
8+
CLIENT_SOURCE_HEADER,
9+
CLIENT_VERSION_HEADER,
10+
AsyncConnectStubAdapter,
711
ChannelProtocol,
812
ClientCallDetails,
13+
ConnectStubAdapter,
14+
_ClientMetadataInterceptor,
915
_RpcMethodPrefixInterceptor,
1016
connect_address,
1117
open_channel,
@@ -39,14 +45,80 @@ def test_open_authenticated_channel(open_func: MagicMock, intercept_func: MagicM
3945
assert intercept_func.call_args[0][1]._auth == ("authorization", "Bearer very-secret")
4046

4147

48+
@patch("_tilebox.grpc.channel.package_version", return_value="1.2.3")
49+
def test_client_metadata_interceptor(package_version: MagicMock) -> None:
50+
interceptor = _ClientMetadataInterceptor()
51+
continuation = MagicMock()
52+
53+
interceptor.intercept_unary_unary(
54+
continuation,
55+
ClientCallDetails("/some-rpc-method", 10, [("authorization", "Bearer token")], None, True),
56+
MagicMock(),
57+
)
58+
59+
package_version.assert_called_once_with("tilebox-grpc")
60+
metadata = continuation.call_args[0][0].metadata
61+
assert ("authorization", "Bearer token") in metadata
62+
assert (CLIENT_SOURCE_HEADER, "python_sdk") in metadata
63+
assert (CLIENT_VERSION_HEADER, "1.2.3") in metadata
64+
65+
66+
def test_client_metadata_uses_dev_version_when_package_is_not_installed() -> None:
67+
with patch("_tilebox.grpc.channel.package_version", side_effect=PackageNotFoundError):
68+
interceptor = _ClientMetadataInterceptor()
69+
70+
assert (CLIENT_VERSION_HEADER, "dev") in interceptor._metadata
71+
72+
73+
class _ConnectClient:
74+
def get_value(self, request: str, *, headers: dict[str, str]) -> tuple[str, dict[str, str]]:
75+
return request, headers
76+
77+
78+
class _AsyncConnectClient:
79+
async def get_value(self, request: str, *, headers: dict[str, str]) -> tuple[str, dict[str, str]]:
80+
return request, headers
81+
82+
83+
@patch("_tilebox.grpc.channel.package_version", return_value="1.2.3")
84+
def test_connect_stub_adapter_adds_client_metadata(package_version: MagicMock) -> None:
85+
adapter = ConnectStubAdapter(_ConnectClient(), {"authorization": "Bearer token"})
86+
87+
request, headers = adapter.GetValue("request") # ty: ignore[unresolved-attribute] # added dynamically
88+
89+
assert request == "request"
90+
assert headers == {
91+
"authorization": "Bearer token",
92+
CLIENT_SOURCE_HEADER: "python_sdk",
93+
CLIENT_VERSION_HEADER: "1.2.3",
94+
}
95+
package_version.assert_called_once_with("tilebox-grpc")
96+
97+
98+
@pytest.mark.asyncio
99+
@patch("_tilebox.grpc.channel.package_version", return_value="1.2.3")
100+
async def test_async_connect_stub_adapter_adds_client_metadata(package_version: MagicMock) -> None:
101+
adapter = AsyncConnectStubAdapter(_AsyncConnectClient(), {"authorization": "Bearer token"})
102+
103+
request, headers = await adapter.GetValue("request") # ty: ignore[unresolved-attribute] # added dynamically
104+
105+
assert request == "request"
106+
assert headers == {
107+
"authorization": "Bearer token",
108+
CLIENT_SOURCE_HEADER: "python_sdk",
109+
CLIENT_VERSION_HEADER: "1.2.3",
110+
}
111+
package_version.assert_called_once_with("tilebox-grpc")
112+
113+
42114
@patch("_tilebox.grpc.channel.intercept_channel")
43115
@patch("_tilebox.grpc.channel.secure_channel")
44116
def test_open_channel_with_rpc_method_prefix(open_func: MagicMock, intercept_func: MagicMock) -> None:
45117
open_channel("api.tilebox.com", rpc_method_prefix="/public")
46118
open_func.assert_called_once()
47119
intercept_func.assert_called_once()
48120

49-
assert intercept_func.call_args[0][1]._prefix == "/public"
121+
assert intercept_func.call_args[0][2]._prefix == "/public"
50122

51123

52124
def test_rpc_method_prefix_interceptor() -> None:

0 commit comments

Comments
 (0)