Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ repos:
hooks:
- id: sync-with-uv
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.14.14
rev: v0.15.7
hooks:
- id: ruff-check
args: [--fix, --exit-non-zero-on-fix]
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.50.1] - 2026-04-01

### Added

- `tilebox-datasets`: Allow anonymous access to open data datasets.

## [0.50.0] - 2026-03-06

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

[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.50.0...HEAD
[0.50.1]: https://github.com/tilebox/tilebox-python/compare/v0.50.0...v0.50.1
[0.50.0]: https://github.com/tilebox/tilebox-python/compare/v0.49.0...v0.50.0
[0.49.0]: https://github.com/tilebox/tilebox-python/compare/v0.48.0...v0.49.0
[0.48.0]: https://github.com/tilebox/tilebox-python/compare/v0.47.0...v0.48.0
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ dev = [
"ruff>=0.14.10",
"types-protobuf>=6.30",
"junitparser>=3.2.0",
"ty>=0.0.14",
# https://github.com/astral-sh/ty/issues/2759
"ty==0.0.14",
"prek>=0.2.27",
]

Expand Down Expand Up @@ -116,5 +117,5 @@ exclude = [
# auto-generated code
"**/*_pb2.py",
"**/*_pb2.pyi",
"**/*pb2_grpc.py"
"**/*pb2_grpc.py",
]
2 changes: 1 addition & 1 deletion tilebox-datasets/tests/query/time_interval.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def datetime_scalar_for_datetime(draw: DrawFn, dt: datetime) -> DatetimeScalar:
"""
understood_formats = [
lambda dt: dt, # converting a datetime to a datetime scalar should be a no-op
lambda dt: pd.to_datetime(dt), # pandas Timestamp objects are also supported
lambda dt: pd.to_datetime(dt), # noqa: PLW0108 # pandas Timestamp objects are also supported
lambda dt: pd.to_datetime(dt).to_datetime64(), # and so are numpy datetime64 objects
lambda dt: datetime_to_us(dt) * 10**3, # timestamp in nanoseconds
# as well as strings in various formats
Expand Down
21 changes: 20 additions & 1 deletion tilebox-datasets/tests/test_aio.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import os
from unittest.mock import MagicMock, patch

import pytest

from tilebox.datasets.aio import Client
from tilebox.datasets.client import _TILEBOX_API_URL, _TILEBOX_DEV_API_URL


@patch("tilebox.datasets.aio.client.open_channel")
def test_tilebox_client_init_opens_channel(open_channel_mock: MagicMock) -> None:
Client(url="some-url", token="some-token") # noqa: S106
open_channel_mock.assert_called_once_with("some-url", "some-token")
open_channel_mock.assert_called_once_with("some-url", "some-token", rpc_method_prefix=None)


@pytest.mark.parametrize("url", [_TILEBOX_API_URL, _TILEBOX_DEV_API_URL, f"{_TILEBOX_API_URL}/"])
@patch.dict(os.environ, {}, clear=True)
@patch("tilebox.datasets.aio.client.open_channel")
def test_tilebox_client_init_uses_public_rpc_prefix_for_tilebox_urls(open_channel_mock: MagicMock, url: str) -> None:
Client(url=url, token=None)
open_channel_mock.assert_called_once_with(url.removesuffix("/"), None, rpc_method_prefix="/public")


@patch.dict(os.environ, {}, clear=True)
@patch("tilebox.datasets.aio.client.open_channel")
def test_tilebox_client_init_skips_public_rpc_prefix_for_custom_urls(open_channel_mock: MagicMock) -> None:
Client(url="some-url", token=None)
open_channel_mock.assert_called_once_with("some-url", None, rpc_method_prefix=None)
18 changes: 17 additions & 1 deletion tilebox-datasets/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch
from unittest.mock import MagicMock, patch

import pytest
import xarray as xr
Expand All @@ -10,10 +10,26 @@
from _tilebox.grpc.error import NotFoundError
from _tilebox.grpc.replay import open_recording_channel, open_replay_channel
from tilebox.datasets import Client, DatasetClient
from tilebox.datasets.client import _TILEBOX_API_URL, _TILEBOX_DEV_API_URL
from tilebox.datasets.data.datapoint import QueryResultPage
from tilebox.datasets.query.time_interval import us_to_datetime


@pytest.mark.parametrize("url", [_TILEBOX_API_URL, _TILEBOX_DEV_API_URL, f"{_TILEBOX_API_URL}/"])
@patch.dict(os.environ, {}, clear=True)
@patch("tilebox.datasets.sync.client.open_channel")
def test_tilebox_client_init_uses_public_rpc_prefix_for_tilebox_urls(open_channel_mock: MagicMock, url: str) -> None:
Client(url=url, token=None)
open_channel_mock.assert_called_once_with(url.removesuffix("/"), None, rpc_method_prefix="/public")


@patch.dict(os.environ, {}, clear=True)
@patch("tilebox.datasets.sync.client.open_channel")
def test_tilebox_client_init_skips_public_rpc_prefix_for_custom_urls(open_channel_mock: MagicMock) -> None:
Client(url="some-url", token=None)
open_channel_mock.assert_called_once_with("some-url", None, rpc_method_prefix=None)


def replay_client(replay_file: str, assert_request_matches: bool = True) -> Client:
replay = Path(__file__).parent / "testdata" / "recordings" / replay_file
replay_channel = open_replay_channel(replay, assert_request_matches)
Expand Down
33 changes: 30 additions & 3 deletions tilebox-datasets/tilebox/datasets/aio/client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import os
from uuid import UUID

from loguru import logger

from _tilebox.grpc.aio.channel import open_channel
from _tilebox.grpc.aio.error import with_pythonic_errors
from _tilebox.grpc.error import NotFoundError
from _tilebox.grpc.public import _PUBLIC_RPC_METHOD_PREFIX
from tilebox.datasets.aio.dataset import DatasetClient
from tilebox.datasets.client import _TILEBOX_API_KEY_ENV_VAR, _TILEBOX_API_URL, _TILEBOX_DEV_API_URL
from tilebox.datasets.client import Client as BaseClient
from tilebox.datasets.client import token_from_env
from tilebox.datasets.data.datasets import DatasetKind, FieldDict
from tilebox.datasets.datasets.v1.collections_pb2_grpc import CollectionServiceStub
from tilebox.datasets.datasets.v1.data_access_pb2_grpc import DataAccessServiceStub
Expand All @@ -16,15 +20,38 @@


class Client:
def __init__(self, *, url: str = "https://api.tilebox.com", token: str | None = None) -> None:
def __init__(
self, *, url: str = _TILEBOX_API_URL, token: str | None = None, warn_if_unauthenticated: bool = True
) -> None:
"""
Create a Tilebox datasets client.

Args:
url: Tilebox API Url. Defaults to "https://api.tilebox.com".
token: The API Key to authenticate with. If not set the `TILEBOX_API_KEY` environment variable will be used.
If no token is provided or found, anonymous open data access will be used.
warn_if_unauthenticated: Whether to warn if no API key is provided and the client is used with the default
Tilebox API URL. Defaults to True.
"""
channel = open_channel(url, token_from_env(url, token))
url = url.removesuffix("/")

if token is None:
token = os.environ.get(_TILEBOX_API_KEY_ENV_VAR, None)

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

channel = open_channel(
url,
token,
rpc_method_prefix=_PUBLIC_RPC_METHOD_PREFIX if (is_tilebox_deployment and token is None) else None,
)
dataset_service_stub = with_pythonic_errors(DatasetServiceStub(channel))
collection_service_stub = with_pythonic_errors(CollectionServiceStub(channel))
data_access_service_stub = with_pythonic_errors(DataAccessServiceStub(channel))
Expand Down
18 changes: 4 additions & 14 deletions tilebox-datasets/tilebox/datasets/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
from loguru import logger
from promise import Promise

from _tilebox.grpc.channel import parse_channel_info
from tilebox.datasets.data.datasets import Dataset, DatasetGroup, DatasetKind, FieldDict, ListDatasetsResponse
from tilebox.datasets.group import Group
from tilebox.datasets.message_pool import register_once
from tilebox.datasets.service import TileboxDatasetService
from tilebox.datasets.uuid import as_uuid

_TILEBOX_API_URL = "https://api.tilebox.com"
_TILEBOX_DEV_API_URL = "https://api.tilebox.dev"
_TILEBOX_API_KEY_ENV_VAR = "TILEBOX_API_KEY"


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


def token_from_env(url: str, token: str | None) -> str | None:
if token is None: # if no token is provided, try to get it from the environment
token = os.environ.get("TILEBOX_API_KEY", None)

if token is None and parse_channel_info(url).address == "api.tilebox.com":
raise ValueError(
"No API key provided and no TILEBOX_API_KEY environment variable set. Please specify an API key using "
"the token argument. For example: `Client(token='YOUR_TILEBOX_API_KEY')`"
)

return token


def _log_server_message(response: ListDatasetsResponse) -> ListDatasetsResponse:
if response.server_message:
logger.opt(colors=True).info(response.server_message + "\n")
Expand Down
8 changes: 4 additions & 4 deletions tilebox-datasets/tilebox/datasets/data/data_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ class SpatialFilter:
def from_message(cls, filter_message: data_access_pb2.SpatialFilter) -> "SpatialFilter":
return SpatialFilter(
geometry=from_wkb(filter_message.geometry.wkb),
mode=_filter_mode_int_to_enum.get(filter_message.mode, None),
coordinate_system=_coordinate_system_int_to_enum.get(filter_message.coordinate_system, None),
mode=_filter_mode_int_to_enum.get(filter_message.mode),
coordinate_system=_coordinate_system_int_to_enum.get(filter_message.coordinate_system),
)

def to_message(self) -> data_access_pb2.SpatialFilter:
Expand Down Expand Up @@ -91,10 +91,10 @@ def parse(cls, spatial_filter_like: SpatialFilterLike) -> "SpatialFilter":
if isinstance(spatial_filter_like, dict):
mode = spatial_filter_like.get("mode", None)
if isinstance(mode, str):
mode = _filter_modes_from_string.get(mode.lower(), None)
mode = _filter_modes_from_string.get(mode.lower())
coordinate_system = spatial_filter_like.get("coordinate_system", None)
if isinstance(coordinate_system, str):
coordinate_system = _coordinate_systems_from_string.get(coordinate_system.lower(), None)
coordinate_system = _coordinate_systems_from_string.get(coordinate_system.lower())
return SpatialFilter(
geometry=spatial_filter_like["geometry"], mode=mode, coordinate_system=coordinate_system
)
Expand Down
2 changes: 1 addition & 1 deletion tilebox-datasets/tilebox/datasets/data/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ class DatasetType:
@classmethod
def from_message(cls, dataset_type: dataset_type_pb2.DatasetType) -> "DatasetType":
return cls(
kind=_dataset_kind_int_to_enum.get(dataset_type.kind, None),
kind=_dataset_kind_int_to_enum.get(dataset_type.kind),
fields=[Field.from_message(f) for f in dataset_type.fields],
)

Expand Down
33 changes: 30 additions & 3 deletions tilebox-datasets/tilebox/datasets/sync/client.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import os
from uuid import UUID

from loguru import logger

from _tilebox.grpc.channel import open_channel
from _tilebox.grpc.error import NotFoundError, with_pythonic_errors
from _tilebox.grpc.public import _PUBLIC_RPC_METHOD_PREFIX
from tilebox.datasets.client import _TILEBOX_API_KEY_ENV_VAR, _TILEBOX_API_URL, _TILEBOX_DEV_API_URL
from tilebox.datasets.client import Client as BaseClient
from tilebox.datasets.client import token_from_env
from tilebox.datasets.data.datasets import DatasetKind, FieldDict
from tilebox.datasets.datasets.v1.collections_pb2_grpc import CollectionServiceStub
from tilebox.datasets.datasets.v1.data_access_pb2_grpc import DataAccessServiceStub
Expand All @@ -15,15 +19,38 @@


class Client:
def __init__(self, *, url: str = "https://api.tilebox.com", token: str | None = None) -> None:
def __init__(
self, *, url: str = _TILEBOX_API_URL, token: str | None = None, warn_if_unauthenticated: bool = True
) -> None:
"""
Create a Tilebox datasets client.

Args:
url: Tilebox API Url. Defaults to "https://api.tilebox.com".
token: The API Key to authenticate with. If not set the `TILEBOX_API_KEY` environment variable will be used.
If no token is provided or found, anonymous open data access will be used.
warn_if_unauthenticated: Whether to warn if no API key is provided and the client is used with the default
Tilebox API URL. Defaults to True.
"""
channel = open_channel(url, token_from_env(url, token))
url = url.removesuffix("/")

if token is None:
token = os.environ.get(_TILEBOX_API_KEY_ENV_VAR, None)

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

channel = open_channel(
url,
token,
rpc_method_prefix=_PUBLIC_RPC_METHOD_PREFIX if (is_tilebox_deployment and token is None) else None,
)
dataset_service_stub = with_pythonic_errors(DatasetServiceStub(channel))
collection_service_stub = with_pythonic_errors(CollectionServiceStub(channel))
data_access_service_stub = with_pythonic_errors(DataAccessServiceStub(channel))
Expand Down
29 changes: 27 additions & 2 deletions tilebox-grpc/_tilebox/grpc/aio/channel.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from collections.abc import Callable, Sequence
from typing import TypeVar

from _tilebox.grpc.channel import CHANNEL_OPTIONS, ChannelInfo, ChannelProtocol, add_metadata, parse_channel_info
from _tilebox.grpc.channel import (
CHANNEL_OPTIONS,
ChannelInfo,
ChannelProtocol,
add_metadata,
parse_channel_info,
update_method,
)
from grpc import Compression, ssl_channel_credentials
from grpc.aio import (
Channel,
Expand All @@ -14,13 +21,14 @@
)


def open_channel(url: str, auth_token: str | None = None) -> Channel:
def open_channel(url: str, auth_token: str | None = None, rpc_method_prefix: str | None = None) -> Channel:
"""Open an async gRPC channel to the given URL.

Args:
url: The URL to open a channel to. Depending on the URL, the channel will be a secure (SSL) or insecure channel.
auth_token: Authentication token for the channel. If set, an interceptor channel will be created which adds
the given token as metadata to each request.
rpc_method_prefix: Optional prefix to prepend to each outgoing RPC method path, e.g. `/public`.

Returns:
A gRPC channel.
Expand All @@ -29,6 +37,8 @@ def open_channel(url: str, auth_token: str | None = None) -> Channel:
interceptors: list[ClientInterceptor] = []
if auth_token is not None:
interceptors = [_AuthMetadataInterceptor(auth_token), *interceptors] # add auth interceptor as the first one
if rpc_method_prefix is not None:
interceptors = [*interceptors, _RpcMethodPrefixInterceptor(rpc_method_prefix)]

return _open_channel(channel_info, interceptors)

Expand Down Expand Up @@ -78,3 +88,18 @@ async def intercept_unary_unary(
request: RequestType,
) -> UnaryUnaryCall:
return await continuation(add_metadata(client_call_details, [self._auth]), request)


class _RpcMethodPrefixInterceptor(UnaryUnaryClientInterceptor):
def __init__(self, prefix: str) -> None:
"""A gRPC channel interceptor which prefixes every outgoing RPC method path."""
super().__init__()
self._prefix = prefix

async def intercept_unary_unary(
self,
continuation: Callable[[ClientCallDetails, RequestType], UnaryUnaryCall],
client_call_details: ClientCallDetails,
request: RequestType,
) -> UnaryUnaryCall:
return await continuation(update_method(client_call_details, self._prefix), request)
Loading
Loading