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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Pre-commit hooks include YAML checks, EOF fixer, `sync-with-uv`, Ruff, and `ty`.
- Type hints are used broadly across public APIs and internals.
- Prefer dataclasses and explicit domain objects for request/response translation.
- Service modules generally wrap generated gRPC stubs and convert to internal Pythonic types.
- Logging uses `loguru` in several packages; workflows also supports explicit logger/tracer configuration.
- Workflows uses a lightweight structured facade over stdlib logging, with separate internal and task channels;
- Tests use `pytest`, with async coverage (`pytest-asyncio`) and property-based testing (`hypothesis`) in multiple packages.

### Import-Time Discipline
Expand Down
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.62.0] - 2026-09-16

### Added

- `tilebox-workflows`: Log task-input values, including dataclasses, paths, and geospatial objects, as structured
attributes through `context.logger`. Unsupported values fall back to text instead of failing task execution.
- `tilebox-workflows`: Disable local console output for direct runners with
`configure_console_logging(enabled=False)` while continuing to export logs to Tilebox and configured backends.

### Changed

- `tilebox-datasets`, `tilebox-workflows`: Use `TILEBOX_API_URL` as the default API URL when no explicit client URL is provided, falling back to production when the environment variable is unset or empty.
- `tilebox-datasets`, `tilebox-workflows`: Use `TILEBOX_API_URL` as the default API URL when no explicit client URL
is provided, falling back to production when the environment variable is unset or empty.
- `tilebox-workflows`: Configure runner logging automatically, with release-runner logs available through the CLI
and direct-runner logs printed to stdout by default.
- `tilebox-workflows`: Control task log verbosity independently of Tilebox diagnostics. Task logs default to `INFO`
and follow `TILEBOX_LOG_LEVEL` or the CLI's `--log-level`; diagnostics default to `ERROR`, with `TILEBOX_DEBUG=true`
enabling debug messages. Override both settings in Python with
`observability.logging.configure_log_level(level, tilebox_debug=False)`.
- `tilebox-workflows`: Export logs to additional OpenTelemetry backends or customize console output without
interrupting logging to Tilebox or the CLI.
- `tilebox-datasets`: Send user notices to stderr, keeping stdout available for program output. Disable colors
automatically when output is redirected or `NO_COLOR` is set.

### Deprecated

- `tilebox-workflows`: `Client.configure_logging()` is no longer needed and has no effect. Use
`observability.logging.configure_log_level()` to change log levels.

## [0.61.0] - 2026-09-04

Expand Down
2 changes: 1 addition & 1 deletion prek.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ hooks = [

[[repos]]
repo = "https://github.com/astral-sh/ty-pre-commit"
rev = "v0.0.79"
rev = "v0.0.81"
hooks = [
{ id = "ty" },
]
9 changes: 4 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,11 @@ dev = [
# DeprecationWarning: Pyarrow will become a required dependency of pandas in the next major release of pandas (pandas 3.0)
"pyarrow>=17.0.0",
# some dev tooling
"ruff>=0.14.10",
"ruff",
"ty",
"prek",
"types-protobuf>=6.30",
"junitparser>=3.2.0",
# https://github.com/astral-sh/ty/issues/2759
"ty==0.0.14",
"prek>=0.2.27",
]

[project.scripts]
Expand Down Expand Up @@ -93,7 +92,7 @@ ignore = [
"G004", # logging-f-string: allow usage of f-strings in logging calls
"PLR2004", # magic-value-comparison: sometimes comparison with constants (e.g. 0) makes sense
"TRY003", # raise-vanilla-args: exceptions like this make sense in python
"TRY400", # error-instead-of-exception: logger.error is ok with loguru
"TRY400", # error-instead-of-exception: allow errors without a traceback
"CPY001", # missing-copyright-notice: we don't add copyright notices to our source files
# disabled because of formatter
"E501", # line-too-long -> formatter takes care of this
Expand Down
1 change: 0 additions & 1 deletion tilebox-datasets/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ dependencies = [
# below version pins are some minimum sane versions that we tested against to make sure they are working
"numpy>=1.24",
"xarray>=2023.11",
"loguru>=0.7",
"tqdm>=4.65",
"pandas>=2.1",
"shapely>=2",
Expand Down
4 changes: 3 additions & 1 deletion tilebox-datasets/tests/data/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from dataclasses import replace
from datetime import datetime, timedelta
from functools import lru_cache
from typing import Literal
from uuid import UUID

import numpy as np
Expand Down Expand Up @@ -78,10 +79,11 @@ def field_dicts(draw: DrawFn) -> FieldDict:
)
)
annotation = draw(field_annotations())
primary_title: Literal["primary_title"] = "primary_title"
roles = draw(
one_of(
lists(sampled_from(FieldRole), unique=True),
lists(just("primary_title"), unique=True),
lists(just(primary_title), unique=True),
)
)

Expand Down
2 changes: 1 addition & 1 deletion tilebox-datasets/tests/data/test_data_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def test_query_filters_reject_invalid_filter() -> None:
datetime(2026, 7, 27, tzinfo=timezone.utc),
datetime(2026, 7, 28, tzinfo=timezone.utc),
),
filter="quality > 80", # type: ignore[arg-type]
filter="quality > 80", # ty: ignore[invalid-argument-type]
)


Expand Down
2 changes: 1 addition & 1 deletion tilebox-datasets/tests/data/test_datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def test_field_from_dict(field_dict: FieldDict) -> None:
)
def test_message_types_can_define_dataset_fields(message_type: type, message_name: str) -> None:
scalar = Field.from_dict({"name": "metadata", "type": message_type})
repeated = Field.from_dict({"name": "metadata", "type": list[message_type]}) # type: ignore[typeddict-item,valid-type]
repeated = Field.from_dict({"name": "metadata", "type": list[message_type]}) # ty: ignore[invalid-type-form]

assert scalar.descriptor.type == FieldDescriptorProto.TYPE_MESSAGE
assert scalar.descriptor.type_name == f".{message_name}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def test_dataframe_missing_values_leave_optional_fields_unset() -> None:

def test_iterable_of_column_tuples_is_rejected_as_invalid_records() -> None:
with pytest.raises(TypeError, match="record 0 is tuple"):
to_messages([("time", [datetime(2026, 7, 31, tzinfo=timezone.utc)])], ExampleDatapoint) # type: ignore[arg-type]
to_messages([("time", [datetime(2026, 7, 31, tzinfo=timezone.utc)])], ExampleDatapoint) # ty: ignore[invalid-argument-type]


def test_ignored_columns_do_not_participate_in_shape_validation() -> None:
Expand Down
6 changes: 4 additions & 2 deletions tilebox-datasets/tests/query/test_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ def test_negative_duration_values(value: object, seconds: int, nanos: int) -> No
)
def test_numpy_query_values_preserve_type_when_reflected(value: object) -> None:
field_first = (field("value") == value).to_message().comparison.value
value_first = (value == field("value")).to_message().comparison.value
reflected = value == field("value")
assert isinstance(reflected, Expression)
value_first = reflected.to_message().comparison.value
assert value_first == field_first


Expand Down Expand Up @@ -120,7 +122,7 @@ def test_invalid_query_operators() -> None:
_ = field("enabled") < True

with pytest.raises(TypeError, match="Expected a query expression"):
(field("quality") == 1) & 2 # type: ignore[operator]
(field("quality") == 1) & 2 # ty: ignore[unsupported-operator]


def test_invalid_expression_message() -> None:
Expand Down
8 changes: 4 additions & 4 deletions tilebox-datasets/tests/test_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,12 +275,12 @@ def test_field_names_are_validated_at_runtime() -> None:
({"storage": "assets"}, "must be distinct"),
):
with pytest.raises(ValueError, match=error):
assets.to_fields(fields=fields) # type: ignore[arg-type]
assets.to_fields(fields=fields) # ty: ignore[no-matching-overload]
with pytest.raises(ValueError, match=error):
AssetCollection.from_datapoint(datapoint, fields=fields) # type: ignore[arg-type]
AssetCollection.from_datapoint(datapoint, fields=fields) # ty: ignore[invalid-argument-type]

with pytest.raises(TypeError, match="must be a string"):
assets.to_fields(fields={"assets": 1}) # type: ignore[typeddict-item]
assets.to_fields(fields={"assets": 1}) # ty: ignore[no-matching-overload]


def test_band_compilation_lifts_metadata_and_interns_profiles() -> None:
Expand Down Expand Up @@ -533,7 +533,7 @@ def test_locations_reuse_generated_storage_and_authentication_messages() -> None
assert location.authentication_schemes["signed"] is authentication_scheme
assert location.authentication_schemes["signed"].flows[0].signed_url == signed_url
with pytest.raises(TypeError):
location.storage_schemes["new"] = scheme # type: ignore[index]
location.storage_schemes["new"] = scheme # ty: ignore[invalid-assignment]


def test_alternate_href_absent_empty_and_nonempty_are_distinct() -> None:
Expand Down
103 changes: 103 additions & 0 deletions tilebox-datasets/tests/test_notices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import os
import sys
from io import StringIO
from unittest.mock import patch

import pytest

from tilebox.datasets.aio.client import Client as AsyncClient
from tilebox.datasets.client import _log_server_message
from tilebox.datasets.data.datasets import ListDatasetsResponse
from tilebox.datasets.notices import print_notice
from tilebox.datasets.sync.client import Client


def test_print_notice_strips_markup_for_non_terminal_output(capsys: pytest.CaptureFixture[str]) -> None:
print_notice("A [bold yellow]styled[/bold yellow] notice")

assert capsys.readouterr() == ("", "A styled notice\n")


@pytest.mark.parametrize("terminal", [False, True])
def test_explicit_output_uses_its_own_terminal_status(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], terminal: bool
) -> None:
output = StringIO()
monkeypatch.setattr(output, "isatty", lambda: terminal)
monkeypatch.setattr(sys.stderr, "isatty", lambda: not terminal)
monkeypatch.delenv("NO_COLOR", raising=False)
monkeypatch.setenv("TERM", "xterm")

print_notice("[red]notice[/red]", output=output)

assert output.getvalue() == ("\033[0m\033[31mnotice\033[0m\n" if terminal else "notice\n")
assert capsys.readouterr() == ("", "")


@pytest.mark.parametrize(
("message", "expected"),
[
(
"[red]red [bold]bold[/bold] red[/red] plain",
"\033[0m\033[31mred \033[0m\033[31;1mbold\033[0m\033[31m red\033[0m plain\n",
),
("[bold blue]blue[/bold blue]", "\033[0m\033[1;34mblue\033[0m\n"),
(
"[green]green [red]red[/red] green[/green]",
"\033[0m\033[32mgreen \033[0m\033[32;31mred\033[0m\033[32m green\033[0m\n",
),
("[cyan]unclosed", "\033[0m\033[36munclosed\033[0m\n"),
("[unknown]literal[/unknown] [/red]", "[unknown]literal[/unknown] [/red]\n"),
],
)
def test_terminal_styles(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], message: str, expected: str
) -> None:
monkeypatch.setattr(sys.stderr, "isatty", lambda: True)
monkeypatch.delenv("NO_COLOR", raising=False)
monkeypatch.setenv("TERM", "xterm")
print_notice(message)
assert capsys.readouterr() == ("", expected)


@pytest.mark.parametrize(("variable", "value"), [("NO_COLOR", ""), ("NO_COLOR", "1"), ("TERM", "dumb")])
def test_terminal_color_opt_out(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], variable: str, value: str
) -> None:
monkeypatch.setattr(sys.stderr, "isatty", lambda: True)
monkeypatch.delenv("NO_COLOR", raising=False)
monkeypatch.setenv("TERM", "xterm")
monkeypatch.setenv(variable, value)
print_notice("[yellow]normal [bold]bold[/bold] normal[/yellow]")
assert capsys.readouterr() == ("", "normal bold normal\n")


@pytest.mark.parametrize("client_type", [Client, AsyncClient])
@patch.dict(os.environ, {}, clear=True)
def test_anonymous_notice_can_be_disabled(
capsys: pytest.CaptureFixture[str], client_type: type[Client] | type[AsyncClient]
) -> None:
with patch(f"{client_type.__module__}.open_channel"):
client_type(warn_if_unauthenticated=False)

assert capsys.readouterr() == ("", "")


@pytest.mark.parametrize("client_type", [Client, AsyncClient])
@patch.dict(os.environ, {}, clear=True)
def test_anonymous_notice_is_printed(
capsys: pytest.CaptureFixture[str], client_type: type[Client] | type[AsyncClient]
) -> None:
with patch(f"{client_type.__module__}.open_channel"):
client_type()

captured = capsys.readouterr()
assert captured.out == ""
assert "Using anonymous open data access" in captured.err


def test_server_message_is_printed(capsys: pytest.CaptureFixture[str]) -> None:
response = ListDatasetsResponse([], [], "A message from the server")

assert _log_server_message(response) is response
assert capsys.readouterr() == ("", "A message from the server\n\n")
34 changes: 32 additions & 2 deletions tilebox-datasets/tests/test_timeseries.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from uuid import uuid4

Expand All @@ -19,6 +19,7 @@
from tilebox.datasets.data.collection import Collection, CollectionInfo
from tilebox.datasets.data.datapoint import AnyMessage, QueryResultPage
from tilebox.datasets.data.datasets import Dataset
from tilebox.datasets.data.timeseries import TimeseriesDatasetChunk
from tilebox.datasets.datasets.v1.collections_pb2 import (
CreateCollectionRequest,
DeleteCollectionRequest,
Expand All @@ -29,6 +30,7 @@
from tilebox.datasets.datasets.v1.collections_pb2_grpc import CollectionServiceStub
from tilebox.datasets.datasets.v1.core_pb2 import Collection as CollectionMessage
from tilebox.datasets.datasets.v1.core_pb2 import CollectionInfo as CollectionInfoMessage
from tilebox.datasets.datasets.v1.timeseries_pb2 import TimeseriesDatasetChunk as TimeseriesDatasetChunkMessage
from tilebox.datasets.query.time_interval import (
_EMPTY_TIME_INTERVAL,
TimeInterval,
Expand All @@ -39,6 +41,34 @@
from tilebox.datasets.uuid import uuid_message_to_uuid, uuid_to_uuid_message


@pytest.mark.parametrize(("start", "end"), [(0, 86400), (-86400, 0), (0.5, 1)])
def test_chunk_preserves_zero_second_timestamps(start: float, end: float) -> None:
interval = TimeInterval(datetime.fromtimestamp(start, timezone.utc), datetime.fromtimestamp(end, timezone.utc))
message = TimeseriesDatasetChunkMessage(
dataset_id=uuid_to_uuid_message(uuid4()),
collection_id=uuid_to_uuid_message(uuid4()),
time_interval=interval.to_message(),
)

assert TimeseriesDatasetChunk.from_message(message).time_interval == interval


@pytest.mark.parametrize("missing_field", ["time_interval", "start_time", "end_time"])
def test_chunk_missing_time_interval_fields(missing_field: str) -> None:
interval = TimeInterval(datetime(2026, 1, 1, tzinfo=timezone.utc), datetime(2026, 1, 2, tzinfo=timezone.utc))
message = TimeseriesDatasetChunkMessage(
dataset_id=uuid_to_uuid_message(uuid4()),
collection_id=uuid_to_uuid_message(uuid4()),
time_interval=interval.to_message(),
)
if missing_field == "time_interval":
message.ClearField(missing_field)
else:
message.time_interval.ClearField(missing_field)

assert TimeseriesDatasetChunk.from_message(message).time_interval is None


def _mocked_dataset() -> tuple[DatasetClient, MagicMock]:
service = MagicMock()

Expand Down Expand Up @@ -286,7 +316,7 @@ def test_timeseries_dataset_query_rejects_invalid_filter_before_request() -> Non
dataset.query(
collections=["collection"],
temporal_extent=interval,
filter="quality > 80", # type: ignore[arg-type]
filter="quality > 80", # ty: ignore[invalid-argument-type]
)

service.get_collections.assert_not_called()
Expand Down
22 changes: 0 additions & 22 deletions tilebox-datasets/tilebox/datasets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import os
import sys
from typing import TYPE_CHECKING, Any

from loguru import logger

if TYPE_CHECKING:
from tilebox.datasets.aio.timeseries import TimeseriesCollection, TimeseriesDataset
from tilebox.datasets.datapoints import iter_datapoints
Expand Down Expand Up @@ -66,21 +62,3 @@ def __getattr__(name: str) -> Any:
def __dir__() -> list[str]:
# Include public lazy exports in dir(module) before they have been loaded.
return sorted(set(globals()) | set(__all__))


def _init_logging(level: str = "INFO") -> None:
logger.remove()
logger.add(sys.stdout, level=level, format="{message}", catch=True)


def _is_debug() -> bool:
try:
return bool(int(os.environ.get("TILEBOX_DEBUG") or 0))
except (TypeError, ValueError):
return False


if _is_debug():
_init_logging("DEBUG")
else:
_init_logging()
Loading
Loading