Skip to content

Commit 5feba17

Browse files
Allow anonymous access to open data datasets (#34)
* Allow anonymous access to open data datasets * Ruff format fixes * Use /public prefix for unauthenticated clients * Provide descriptive error message for Cloudflare 429
1 parent ef6c252 commit 5feba17

22 files changed

Lines changed: 1129 additions & 797 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ repos:
99
hooks:
1010
- id: sync-with-uv
1111
- repo: https://github.com/charliermarsh/ruff-pre-commit
12-
rev: v0.14.14
12+
rev: v0.15.7
1313
hooks:
1414
- id: ruff-check
1515
args: [--fix, --exit-non-zero-on-fix]

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.50.1] - 2026-04-01
11+
12+
### Added
13+
14+
- `tilebox-datasets`: Allow anonymous access to open data datasets.
15+
1016
## [0.50.0] - 2026-03-06
1117

1218
### Added
@@ -342,6 +348,7 @@ the first client that does not cache data (since it's already on the local file
342348
- Released packages: `tilebox-datasets`, `tilebox-workflows`, `tilebox-storage`, `tilebox-grpc`
343349

344350
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.50.0...HEAD
351+
[0.50.1]: https://github.com/tilebox/tilebox-python/compare/v0.50.0...v0.50.1
345352
[0.50.0]: https://github.com/tilebox/tilebox-python/compare/v0.49.0...v0.50.0
346353
[0.49.0]: https://github.com/tilebox/tilebox-python/compare/v0.48.0...v0.49.0
347354
[0.48.0]: https://github.com/tilebox/tilebox-python/compare/v0.47.0...v0.48.0

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ dev = [
2626
"ruff>=0.14.10",
2727
"types-protobuf>=6.30",
2828
"junitparser>=3.2.0",
29-
"ty>=0.0.14",
29+
# https://github.com/astral-sh/ty/issues/2759
30+
"ty==0.0.14",
3031
"prek>=0.2.27",
3132
]
3233

@@ -116,5 +117,5 @@ exclude = [
116117
# auto-generated code
117118
"**/*_pb2.py",
118119
"**/*_pb2.pyi",
119-
"**/*pb2_grpc.py"
120+
"**/*pb2_grpc.py",
120121
]

tilebox-datasets/tests/query/time_interval.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def datetime_scalar_for_datetime(draw: DrawFn, dt: datetime) -> DatetimeScalar:
4949
"""
5050
understood_formats = [
5151
lambda dt: dt, # converting a datetime to a datetime scalar should be a no-op
52-
lambda dt: pd.to_datetime(dt), # pandas Timestamp objects are also supported
52+
lambda dt: pd.to_datetime(dt), # noqa: PLW0108 # pandas Timestamp objects are also supported
5353
lambda dt: pd.to_datetime(dt).to_datetime64(), # and so are numpy datetime64 objects
5454
lambda dt: datetime_to_us(dt) * 10**3, # timestamp in nanoseconds
5555
# as well as strings in various formats

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: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import os
12
from uuid import UUID
23

4+
from loguru import logger
5+
36
from _tilebox.grpc.aio.channel import open_channel
47
from _tilebox.grpc.aio.error import with_pythonic_errors
58
from _tilebox.grpc.error import NotFoundError
9+
from _tilebox.grpc.public import _PUBLIC_RPC_METHOD_PREFIX
610
from tilebox.datasets.aio.dataset import DatasetClient
11+
from tilebox.datasets.client import _TILEBOX_API_KEY_ENV_VAR, _TILEBOX_API_URL, _TILEBOX_DEV_API_URL
712
from tilebox.datasets.client import Client as BaseClient
8-
from tilebox.datasets.client import token_from_env
913
from tilebox.datasets.data.datasets import DatasetKind, FieldDict
1014
from tilebox.datasets.datasets.v1.collections_pb2_grpc import CollectionServiceStub
1115
from tilebox.datasets.datasets.v1.data_access_pb2_grpc import DataAccessServiceStub
@@ -16,15 +20,38 @@
1620

1721

1822
class Client:
19-
def __init__(self, *, url: str = "https://api.tilebox.com", token: str | None = None) -> None:
23+
def __init__(
24+
self, *, url: str = _TILEBOX_API_URL, token: str | None = None, warn_if_unauthenticated: bool = True
25+
) -> None:
2026
"""
2127
Create a Tilebox datasets client.
2228
2329
Args:
2430
url: Tilebox API Url. Defaults to "https://api.tilebox.com".
2531
token: The API Key to authenticate with. If not set the `TILEBOX_API_KEY` environment variable will be used.
32+
If no token is provided or found, anonymous open data access will be used.
33+
warn_if_unauthenticated: Whether to warn if no API key is provided and the client is used with the default
34+
Tilebox API URL. Defaults to True.
2635
"""
27-
channel = open_channel(url, token_from_env(url, token))
36+
url = url.removesuffix("/")
37+
38+
if token is None:
39+
token = os.environ.get(_TILEBOX_API_KEY_ENV_VAR, None)
40+
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:
43+
logger.opt(colors=True).info(
44+
"<yellow>"
45+
"No Tilebox API key detected. Using <bold>anonymous open data access</bold> without authentication. "
46+
"For higher throughput and rate limits, sign up for a free account at https://console.tilebox.com."
47+
"</yellow>"
48+
)
49+
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+
)
2855
dataset_service_stub = with_pythonic_errors(DatasetServiceStub(channel))
2956
collection_service_stub = with_pythonic_errors(CollectionServiceStub(channel))
3057
data_access_service_stub = with_pythonic_errors(DataAccessServiceStub(channel))

tilebox-datasets/tilebox/datasets/client.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
from loguru import logger
77
from promise import Promise
88

9-
from _tilebox.grpc.channel import parse_channel_info
109
from tilebox.datasets.data.datasets import Dataset, DatasetGroup, DatasetKind, FieldDict, ListDatasetsResponse
1110
from tilebox.datasets.group import Group
1211
from tilebox.datasets.message_pool import register_once
1312
from tilebox.datasets.service import TileboxDatasetService
1413
from tilebox.datasets.uuid import as_uuid
1514

15+
_TILEBOX_API_URL = "https://api.tilebox.com"
16+
_TILEBOX_DEV_API_URL = "https://api.tilebox.dev"
17+
_TILEBOX_API_KEY_ENV_VAR = "TILEBOX_API_KEY"
18+
1619

1720
class TimeseriesDatasetLike(Protocol):
1821
def __init__(self, service: TileboxDatasetService, dataset: Dataset) -> None:
@@ -81,19 +84,6 @@ def _dataset_by_id(self, dataset_id: str | UUID, dataset_type: type[T]) -> Promi
8184
)
8285

8386

84-
def token_from_env(url: str, token: str | None) -> str | None:
85-
if token is None: # if no token is provided, try to get it from the environment
86-
token = os.environ.get("TILEBOX_API_KEY", None)
87-
88-
if token is None and parse_channel_info(url).address == "api.tilebox.com":
89-
raise ValueError(
90-
"No API key provided and no TILEBOX_API_KEY environment variable set. Please specify an API key using "
91-
"the token argument. For example: `Client(token='YOUR_TILEBOX_API_KEY')`"
92-
)
93-
94-
return token
95-
96-
9787
def _log_server_message(response: ListDatasetsResponse) -> ListDatasetsResponse:
9888
if response.server_message:
9989
logger.opt(colors=True).info(response.server_message + "\n")

tilebox-datasets/tilebox/datasets/data/data_access.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ class SpatialFilter:
6262
def from_message(cls, filter_message: data_access_pb2.SpatialFilter) -> "SpatialFilter":
6363
return SpatialFilter(
6464
geometry=from_wkb(filter_message.geometry.wkb),
65-
mode=_filter_mode_int_to_enum.get(filter_message.mode, None),
66-
coordinate_system=_coordinate_system_int_to_enum.get(filter_message.coordinate_system, None),
65+
mode=_filter_mode_int_to_enum.get(filter_message.mode),
66+
coordinate_system=_coordinate_system_int_to_enum.get(filter_message.coordinate_system),
6767
)
6868

6969
def to_message(self) -> data_access_pb2.SpatialFilter:
@@ -91,10 +91,10 @@ def parse(cls, spatial_filter_like: SpatialFilterLike) -> "SpatialFilter":
9191
if isinstance(spatial_filter_like, dict):
9292
mode = spatial_filter_like.get("mode", None)
9393
if isinstance(mode, str):
94-
mode = _filter_modes_from_string.get(mode.lower(), None)
94+
mode = _filter_modes_from_string.get(mode.lower())
9595
coordinate_system = spatial_filter_like.get("coordinate_system", None)
9696
if isinstance(coordinate_system, str):
97-
coordinate_system = _coordinate_systems_from_string.get(coordinate_system.lower(), None)
97+
coordinate_system = _coordinate_systems_from_string.get(coordinate_system.lower())
9898
return SpatialFilter(
9999
geometry=spatial_filter_like["geometry"], mode=mode, coordinate_system=coordinate_system
100100
)

tilebox-datasets/tilebox/datasets/data/datasets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ class DatasetType:
136136
@classmethod
137137
def from_message(cls, dataset_type: dataset_type_pb2.DatasetType) -> "DatasetType":
138138
return cls(
139-
kind=_dataset_kind_int_to_enum.get(dataset_type.kind, None),
139+
kind=_dataset_kind_int_to_enum.get(dataset_type.kind),
140140
fields=[Field.from_message(f) for f in dataset_type.fields],
141141
)
142142

0 commit comments

Comments
 (0)