Skip to content

Commit 5790a38

Browse files
Use /public prefix for unauthenticated clients
1 parent bac5821 commit 5790a38

12 files changed

Lines changed: 219 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
## [0.50.1] - 2026-03-31
10+
## [0.50.1] - 2026-04-01
1111

1212
### Added
1313

tilebox-datasets/tests/test_aio.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
1+
import os
12
from unittest.mock import MagicMock, patch
23

4+
import pytest
5+
36
from tilebox.datasets.aio import Client
7+
from tilebox.datasets.client import _TILEBOX_API_URL, _TILEBOX_DEV_API_URL
48

59

610
@patch("tilebox.datasets.aio.client.open_channel")
711
def test_tilebox_client_init_opens_channel(open_channel_mock: MagicMock) -> None:
812
Client(url="some-url", token="some-token") # noqa: S106
9-
open_channel_mock.assert_called_once_with("some-url", "some-token")
13+
open_channel_mock.assert_called_once_with("some-url", "some-token", rpc_method_prefix=None)
14+
15+
16+
@pytest.mark.parametrize("url", [_TILEBOX_API_URL, _TILEBOX_DEV_API_URL, f"{_TILEBOX_API_URL}/"])
17+
@patch.dict(os.environ, {}, clear=True)
18+
@patch("tilebox.datasets.aio.client.open_channel")
19+
def test_tilebox_client_init_uses_public_rpc_prefix_for_tilebox_urls(open_channel_mock: MagicMock, url: str) -> None:
20+
Client(url=url, token=None)
21+
open_channel_mock.assert_called_once_with(url.removesuffix("/"), None, rpc_method_prefix="/public")
22+
23+
24+
@patch.dict(os.environ, {}, clear=True)
25+
@patch("tilebox.datasets.aio.client.open_channel")
26+
def test_tilebox_client_init_skips_public_rpc_prefix_for_custom_urls(open_channel_mock: MagicMock) -> None:
27+
Client(url="some-url", token=None)
28+
open_channel_mock.assert_called_once_with("some-url", None, rpc_method_prefix=None)

tilebox-datasets/tests/test_client.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
from datetime import datetime, timezone
33
from pathlib import Path
4-
from unittest.mock import patch
4+
from unittest.mock import MagicMock, patch
55

66
import pytest
77
import xarray as xr
@@ -10,10 +10,26 @@
1010
from _tilebox.grpc.error import NotFoundError
1111
from _tilebox.grpc.replay import open_recording_channel, open_replay_channel
1212
from tilebox.datasets import Client, DatasetClient
13+
from tilebox.datasets.client import _TILEBOX_API_URL, _TILEBOX_DEV_API_URL
1314
from tilebox.datasets.data.datapoint import QueryResultPage
1415
from tilebox.datasets.query.time_interval import us_to_datetime
1516

1617

18+
@pytest.mark.parametrize("url", [_TILEBOX_API_URL, _TILEBOX_DEV_API_URL, f"{_TILEBOX_API_URL}/"])
19+
@patch.dict(os.environ, {}, clear=True)
20+
@patch("tilebox.datasets.sync.client.open_channel")
21+
def test_tilebox_client_init_uses_public_rpc_prefix_for_tilebox_urls(open_channel_mock: MagicMock, url: str) -> None:
22+
Client(url=url, token=None)
23+
open_channel_mock.assert_called_once_with(url.removesuffix("/"), None, rpc_method_prefix="/public")
24+
25+
26+
@patch.dict(os.environ, {}, clear=True)
27+
@patch("tilebox.datasets.sync.client.open_channel")
28+
def test_tilebox_client_init_skips_public_rpc_prefix_for_custom_urls(open_channel_mock: MagicMock) -> None:
29+
Client(url="some-url", token=None)
30+
open_channel_mock.assert_called_once_with("some-url", None, rpc_method_prefix=None)
31+
32+
1733
def replay_client(replay_file: str, assert_request_matches: bool = True) -> Client:
1834
replay = Path(__file__).parent / "testdata" / "recordings" / replay_file
1935
replay_channel = open_replay_channel(replay, assert_request_matches)

tilebox-datasets/tilebox/datasets/aio/client.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
from _tilebox.grpc.aio.channel import open_channel
77
from _tilebox.grpc.aio.error import with_pythonic_errors
88
from _tilebox.grpc.error import NotFoundError
9+
from _tilebox.grpc.public import _PUBLIC_RPC_METHOD_PREFIX
910
from tilebox.datasets.aio.dataset import DatasetClient
10-
from tilebox.datasets.client import _TILEBOX_API_KEY_ENV_VAR, _TILEBOX_API_URL
11+
from tilebox.datasets.client import _TILEBOX_API_KEY_ENV_VAR, _TILEBOX_API_URL, _TILEBOX_DEV_API_URL
1112
from tilebox.datasets.client import Client as BaseClient
1213
from tilebox.datasets.data.datasets import DatasetKind, FieldDict
1314
from tilebox.datasets.datasets.v1.collections_pb2_grpc import CollectionServiceStub
@@ -32,18 +33,25 @@ def __init__(
3233
warn_if_unauthenticated: Whether to warn if no API key is provided and the client is used with the default
3334
Tilebox API URL. Defaults to True.
3435
"""
36+
url = url.removesuffix("/")
37+
3538
if token is None:
3639
token = os.environ.get(_TILEBOX_API_KEY_ENV_VAR, None)
3740

38-
if token is None and url == _TILEBOX_API_URL and warn_if_unauthenticated:
41+
is_tilebox_deployment = url in (_TILEBOX_API_URL, _TILEBOX_DEV_API_URL)
42+
if token is None and is_tilebox_deployment and warn_if_unauthenticated:
3943
logger.opt(colors=True).info(
4044
"<yellow>"
4145
"No Tilebox API key detected. Using <bold>anonymous open data access</bold> without authentication. "
4246
"For higher throughput and rate limits, sign up for a free account at https://console.tilebox.com."
4347
"</yellow>"
4448
)
4549

46-
channel = open_channel(url, token)
50+
channel = open_channel(
51+
url,
52+
token,
53+
rpc_method_prefix=_PUBLIC_RPC_METHOD_PREFIX if (is_tilebox_deployment and token is None) else None,
54+
)
4755
dataset_service_stub = with_pythonic_errors(DatasetServiceStub(channel))
4856
collection_service_stub = with_pythonic_errors(CollectionServiceStub(channel))
4957
data_access_service_stub = with_pythonic_errors(DataAccessServiceStub(channel))

tilebox-datasets/tilebox/datasets/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from tilebox.datasets.uuid import as_uuid
1414

1515
_TILEBOX_API_URL = "https://api.tilebox.com"
16+
_TILEBOX_DEV_API_URL = "https://api.tilebox.dev"
1617
_TILEBOX_API_KEY_ENV_VAR = "TILEBOX_API_KEY"
1718

1819

tilebox-datasets/tilebox/datasets/sync/client.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
from _tilebox.grpc.channel import open_channel
77
from _tilebox.grpc.error import NotFoundError, with_pythonic_errors
8-
from tilebox.datasets.client import _TILEBOX_API_KEY_ENV_VAR, _TILEBOX_API_URL
8+
from _tilebox.grpc.public import _PUBLIC_RPC_METHOD_PREFIX
9+
from tilebox.datasets.client import _TILEBOX_API_KEY_ENV_VAR, _TILEBOX_API_URL, _TILEBOX_DEV_API_URL
910
from tilebox.datasets.client import Client as BaseClient
1011
from tilebox.datasets.data.datasets import DatasetKind, FieldDict
1112
from tilebox.datasets.datasets.v1.collections_pb2_grpc import CollectionServiceStub
@@ -31,18 +32,25 @@ def __init__(
3132
warn_if_unauthenticated: Whether to warn if no API key is provided and the client is used with the default
3233
Tilebox API URL. Defaults to True.
3334
"""
35+
url = url.removesuffix("/")
36+
3437
if token is None:
3538
token = os.environ.get(_TILEBOX_API_KEY_ENV_VAR, None)
3639

37-
if token is None and url == _TILEBOX_API_URL and warn_if_unauthenticated:
40+
is_tilebox_deployment = url in (_TILEBOX_API_URL, _TILEBOX_DEV_API_URL)
41+
if token is None and is_tilebox_deployment and warn_if_unauthenticated:
3842
logger.opt(colors=True).info(
3943
"<yellow>"
4044
"No Tilebox API key detected. Using <bold>anonymous open data access</bold> without authentication. "
4145
"For higher throughput and rate limits, sign up for a free account at https://console.tilebox.com."
4246
"</yellow>"
4347
)
4448

45-
channel = open_channel(url, token)
49+
channel = open_channel(
50+
url,
51+
token,
52+
rpc_method_prefix=_PUBLIC_RPC_METHOD_PREFIX if (is_tilebox_deployment and token is None) else None,
53+
)
4654
dataset_service_stub = with_pythonic_errors(DatasetServiceStub(channel))
4755
collection_service_stub = with_pythonic_errors(CollectionServiceStub(channel))
4856
data_access_service_stub = with_pythonic_errors(DataAccessServiceStub(channel))

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
from collections.abc import Callable, Sequence
22
from typing import TypeVar
33

4-
from _tilebox.grpc.channel import CHANNEL_OPTIONS, ChannelInfo, ChannelProtocol, add_metadata, parse_channel_info
4+
from _tilebox.grpc.channel import (
5+
CHANNEL_OPTIONS,
6+
ChannelInfo,
7+
ChannelProtocol,
8+
add_metadata,
9+
parse_channel_info,
10+
update_method,
11+
)
512
from grpc import Compression, ssl_channel_credentials
613
from grpc.aio import (
714
Channel,
@@ -14,13 +21,14 @@
1421
)
1522

1623

17-
def open_channel(url: str, auth_token: str | None = None) -> Channel:
24+
def open_channel(url: str, auth_token: str | None = None, rpc_method_prefix: str | None = None) -> Channel:
1825
"""Open an async gRPC channel to the given URL.
1926
2027
Args:
2128
url: The URL to open a channel to. Depending on the URL, the channel will be a secure (SSL) or insecure channel.
2229
auth_token: Authentication token for the channel. If set, an interceptor channel will be created which adds
2330
the given token as metadata to each request.
31+
rpc_method_prefix: Optional prefix to prepend to each outgoing RPC method path, e.g. `/public`.
2432
2533
Returns:
2634
A gRPC channel.
@@ -29,6 +37,8 @@ def open_channel(url: str, auth_token: str | None = None) -> Channel:
2937
interceptors: list[ClientInterceptor] = []
3038
if auth_token is not None:
3139
interceptors = [_AuthMetadataInterceptor(auth_token), *interceptors] # add auth interceptor as the first one
40+
if rpc_method_prefix is not None:
41+
interceptors = [*interceptors, _RpcMethodPrefixInterceptor(rpc_method_prefix)]
3242

3343
return _open_channel(channel_info, interceptors)
3444

@@ -78,3 +88,18 @@ async def intercept_unary_unary(
7888
request: RequestType,
7989
) -> UnaryUnaryCall:
8090
return await continuation(add_metadata(client_call_details, [self._auth]), request)
91+
92+
93+
class _RpcMethodPrefixInterceptor(UnaryUnaryClientInterceptor):
94+
def __init__(self, prefix: str) -> None:
95+
"""A gRPC channel interceptor which prefixes every outgoing RPC method path."""
96+
super().__init__()
97+
self._prefix = prefix
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(update_method(client_call_details, self._prefix), request)

tilebox-grpc/_tilebox/grpc/channel.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,14 @@ class ChannelInfo:
6161
"""The protocol to use for the channel."""
6262

6363

64-
def open_channel(url: str, auth_token: str | None = None) -> Channel:
64+
def open_channel(url: str, auth_token: str | None = None, rpc_method_prefix: str | None = None) -> Channel:
6565
"""Open a sync gRPC channel to the given URL.
6666
6767
Args:
6868
url: The URL to open a channel to. Depending on the URL, the channel will be a secure (SSL) or insecure channel.
6969
auth_token: Authentication token for the channel. If set, an interceptor channel will be created which adds
7070
the given token as metadata to each request.
71+
rpc_method_prefix: Optional prefix to prepend to each outgoing RPC method path, e.g. `/public`.
7172
7273
Returns:
7374
A sync gRPC channel.
@@ -76,6 +77,8 @@ def open_channel(url: str, auth_token: str | None = None) -> Channel:
7677
interceptors: list[UnaryUnaryClientInterceptor] = []
7778
if auth_token is not None:
7879
interceptors = [_AuthMetadataInterceptor(auth_token), *interceptors] # add auth interceptor as the first one
80+
if rpc_method_prefix is not None:
81+
interceptors = [*interceptors, _RpcMethodPrefixInterceptor(rpc_method_prefix)]
7982

8083
return intercept_channel(_open_channel(channel_info), *interceptors)
8184

@@ -165,15 +168,63 @@ def intercept_unary_unary(
165168
return continuation(add_metadata(client_call_details, [self._auth]), request)
166169

167170

171+
class _RpcMethodPrefixInterceptor(UnaryUnaryClientInterceptor):
172+
def __init__(self, prefix: str) -> None:
173+
"""A sync gRPC channel interceptor which prefixes every outgoing RPC method path."""
174+
super().__init__()
175+
self._prefix = prefix
176+
177+
def intercept_unary_unary(
178+
self,
179+
continuation: Callable[[ClientCallDetails, RequestType], ResponseType],
180+
client_call_details: ClientCallDetails,
181+
request: RequestType,
182+
) -> ResponseType:
183+
return continuation(update_method(client_call_details, self._prefix), request)
184+
185+
168186
def add_metadata(
169187
client_call_details: ClientCallDetails, additional_metadata: list[tuple[str, str]]
170188
) -> ClientCallDetails:
171189
metadata = [] if client_call_details.metadata is None else list(client_call_details.metadata)
172190
metadata.extend(additional_metadata)
191+
return _replace_call_details(client_call_details, metadata=metadata)
192+
193+
194+
def update_method(client_call_details: ClientCallDetails, prefix: str) -> ClientCallDetails:
195+
return _replace_call_details(client_call_details, method=prefix_rpc_method(client_call_details.method, prefix))
196+
197+
198+
def prefix_rpc_method(method: str | bytes, prefix: str) -> str | bytes:
199+
normalized_prefix = prefix.strip("/")
200+
if normalized_prefix == "":
201+
return method
202+
203+
if isinstance(method, bytes):
204+
prefix_bytes = f"/{normalized_prefix}".encode()
205+
normalized_method = method if method.startswith(b"/") else b"/" + method
206+
if normalized_method == prefix_bytes or normalized_method.startswith(prefix_bytes + b"/"):
207+
return normalized_method
208+
return prefix_bytes + normalized_method
209+
210+
prefix = f"/{normalized_prefix}"
211+
normalized_method = method if method.startswith("/") else f"/{method}"
212+
if normalized_method == prefix or normalized_method.startswith(f"{prefix}/"):
213+
return normalized_method
214+
215+
return f"{prefix}{normalized_method}"
216+
217+
218+
def _replace_call_details(
219+
client_call_details: ClientCallDetails,
220+
*,
221+
method: str | bytes | None = None,
222+
metadata: list[tuple[str, str]] | None = None,
223+
) -> ClientCallDetails:
173224
return ClientCallDetails(
174-
client_call_details.method,
225+
client_call_details.method if method is None else method,
175226
client_call_details.timeout,
176-
metadata,
227+
client_call_details.metadata if metadata is None else metadata,
177228
client_call_details.credentials,
178229
client_call_details.wait_for_ready,
179230
)

tilebox-grpc/_tilebox/grpc/error.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from collections.abc import Callable, Coroutine
66
from typing import Any, Protocol, TypeVar, cast
77

8+
from _tilebox.grpc.public import _PUBLIC_RPC_METHOD_PREFIX
89
from grpc import RpcError, StatusCode
910
from grpc.aio import AioRpcError
1011

@@ -37,6 +38,10 @@ class InternalServerError(KeyError):
3738
"""InternalServerError indicates that an unexpected error happened on the server side"""
3839

3940

41+
class InvalidRequestError(IOError):
42+
""""""
43+
44+
4045
Stub = TypeVar("Stub")
4146

4247

@@ -61,14 +66,22 @@ def with_pythonic_errors(stub: Stub, async_funcs: bool = False) -> Stub:
6166
return stub
6267

6368

69+
class RPCState(Protocol):
70+
@property
71+
def method(self) -> str: ...
72+
73+
6474
class AnyRpcError(Protocol):
6575
"""Protocol for gRPC errors that works for both sync and async gRPC."""
6676

6777
def code(self) -> StatusCode: ...
6878
def details(self) -> str: ...
6979

80+
@property
81+
def args(self) -> tuple[RPCState, ...]: ...
82+
7083

71-
def translate_rpc_error(err: AnyRpcError) -> BaseException: # noqa: PLR0911, C901
84+
def translate_rpc_error(err: AnyRpcError) -> BaseException: # noqa: PLR0911, PLR0912, C901
7285
# translate specific error codes to more pythonic errors
7386

7487
# https://grpc.io/docs/guides/error/
@@ -100,8 +113,24 @@ def translate_rpc_error(err: AnyRpcError) -> BaseException: # noqa: PLR0911, C9
100113
# Client application cancelled the request
101114
return KeyboardInterrupt(f"Request canceled by user: {err.details()}")
102115
case StatusCode.UNIMPLEMENTED:
103-
# Method not found on server
104-
return NotImplementedError(err.details())
116+
# Method not found on server, either due to a mismatch in Client / server version
117+
# or because no token was provided and the given method is not part of the /public/ prefix
118+
# so let's check which one it is
119+
120+
if len(err.args) >= 1:
121+
method = err.args[0].method
122+
if method.startswith(_PUBLIC_RPC_METHOD_PREFIX):
123+
action = method.rsplit("/", maxsplit=1)[-1]
124+
return AuthenticationError(
125+
f"{action} is only available for authenticated users. Please provide a valid API Key "
126+
f"token in your Client. Create a free account at https://console.tilebox.com"
127+
)
128+
return InvalidRequestError(
129+
f"Requested URL {method} not found. Update your Tilebox Python Clients to the latest version."
130+
)
131+
132+
# if we don't know anything else about the error, just forward the details directly
133+
return InvalidRequestError(err.details())
105134

106135
# for all other errors we raise a generic internal server error
107136
return InternalServerError(f"Oops, something went wrong: {err.details()}")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
_PUBLIC_RPC_METHOD_PREFIX = "/public"

0 commit comments

Comments
 (0)