diff --git a/AGENTS.md b/AGENTS.md
index 30aebb5..f736a99 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67d7c46..cd8db32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/prek.toml b/prek.toml
index a08c73b..466f8a9 100644
--- a/prek.toml
+++ b/prek.toml
@@ -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" },
]
diff --git a/pyproject.toml b/pyproject.toml
index c028ba1..879eddb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
@@ -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
diff --git a/tilebox-datasets/pyproject.toml b/tilebox-datasets/pyproject.toml
index e66c444..ed8503a 100644
--- a/tilebox-datasets/pyproject.toml
+++ b/tilebox-datasets/pyproject.toml
@@ -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",
diff --git a/tilebox-datasets/tests/data/datasets.py b/tilebox-datasets/tests/data/datasets.py
index c83fb90..acf712e 100644
--- a/tilebox-datasets/tests/data/datasets.py
+++ b/tilebox-datasets/tests/data/datasets.py
@@ -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
@@ -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),
)
)
diff --git a/tilebox-datasets/tests/data/test_data_access.py b/tilebox-datasets/tests/data/test_data_access.py
index ff6089a..02a5205 100644
--- a/tilebox-datasets/tests/data/test_data_access.py
+++ b/tilebox-datasets/tests/data/test_data_access.py
@@ -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]
)
diff --git a/tilebox-datasets/tests/data/test_datasets.py b/tilebox-datasets/tests/data/test_datasets.py
index acb9c2b..2b7d9cd 100644
--- a/tilebox-datasets/tests/data/test_datasets.py
+++ b/tilebox-datasets/tests/data/test_datasets.py
@@ -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}"
diff --git a/tilebox-datasets/tests/protobuf_conversion/test_to_protobuf.py b/tilebox-datasets/tests/protobuf_conversion/test_to_protobuf.py
index fe7e314..34f3e8f 100644
--- a/tilebox-datasets/tests/protobuf_conversion/test_to_protobuf.py
+++ b/tilebox-datasets/tests/protobuf_conversion/test_to_protobuf.py
@@ -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:
diff --git a/tilebox-datasets/tests/query/test_expression.py b/tilebox-datasets/tests/query/test_expression.py
index 726e9a4..19714e6 100644
--- a/tilebox-datasets/tests/query/test_expression.py
+++ b/tilebox-datasets/tests/query/test_expression.py
@@ -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
@@ -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:
diff --git a/tilebox-datasets/tests/test_assets.py b/tilebox-datasets/tests/test_assets.py
index fc4dd65..358c89c 100644
--- a/tilebox-datasets/tests/test_assets.py
+++ b/tilebox-datasets/tests/test_assets.py
@@ -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:
@@ -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:
diff --git a/tilebox-datasets/tests/test_notices.py b/tilebox-datasets/tests/test_notices.py
new file mode 100644
index 0000000..108563c
--- /dev/null
+++ b/tilebox-datasets/tests/test_notices.py
@@ -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")
diff --git a/tilebox-datasets/tests/test_timeseries.py b/tilebox-datasets/tests/test_timeseries.py
index f5df6c3..8eeba05 100644
--- a/tilebox-datasets/tests/test_timeseries.py
+++ b/tilebox-datasets/tests/test_timeseries.py
@@ -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
@@ -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,
@@ -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,
@@ -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()
@@ -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()
diff --git a/tilebox-datasets/tilebox/datasets/__init__.py b/tilebox-datasets/tilebox/datasets/__init__.py
index dc0cae2..f6f8599 100644
--- a/tilebox-datasets/tilebox/datasets/__init__.py
+++ b/tilebox-datasets/tilebox/datasets/__init__.py
@@ -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
@@ -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()
diff --git a/tilebox-datasets/tilebox/datasets/aio/client.py b/tilebox-datasets/tilebox/datasets/aio/client.py
index 0400c39..f21b829 100644
--- a/tilebox-datasets/tilebox/datasets/aio/client.py
+++ b/tilebox-datasets/tilebox/datasets/aio/client.py
@@ -1,8 +1,6 @@
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.channel import (
@@ -21,6 +19,7 @@
from tilebox.datasets.datasets.v1.data_ingestion_pb2_grpc import DataIngestionServiceStub
from tilebox.datasets.datasets.v1.datasets_pb2_grpc import DatasetServiceStub
from tilebox.datasets.group import Group
+from tilebox.datasets.notices import print_notice
from tilebox.datasets.service import TileboxDatasetService
@@ -55,11 +54,11 @@ def __init__(
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(
- ""
- "No Tilebox API key detected. Using anonymous open data access without authentication. "
+ print_notice(
+ "[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]"
)
rpc_method_prefix = _PUBLIC_RPC_METHOD_PREFIX if (is_tilebox_deployment and token is None) else None
diff --git a/tilebox-datasets/tilebox/datasets/assets/assets.py b/tilebox-datasets/tilebox/datasets/assets/assets.py
index d8f0926..4ec642c 100644
--- a/tilebox-datasets/tilebox/datasets/assets/assets.py
+++ b/tilebox-datasets/tilebox/datasets/assets/assets.py
@@ -464,12 +464,13 @@ def _validate_field_names(fields: AssetFieldNames | None) -> dict[str, str]:
unknown = set(fields).difference(_ASSET_FIELD_DEFAULTS)
if unknown:
raise ValueError(f"unknown asset field names: {', '.join(sorted(unknown))}")
- overrides = dict(fields)
- for logical_name, physical_name in overrides.items():
+ overrides: dict[str, str] = {}
+ for logical_name, physical_name in fields.items():
if not isinstance(physical_name, str):
raise TypeError(f"field name for {logical_name!r} must be a string")
if not physical_name:
raise ValueError(f"field name for {logical_name!r} cannot be empty")
+ overrides[logical_name] = physical_name
resolved = {**_ASSET_FIELD_DEFAULTS, **overrides}
if len(set(resolved.values())) != len(resolved):
raise ValueError("asset, storage, and authentication field names must be distinct")
@@ -899,7 +900,7 @@ def _lift_sar(bands: tuple[Band, ...], parent: SARProperties | None) -> SARPrope
"""
if not bands:
return parent
- values = {}
+ values: dict[str, Any] = {}
for field in _SAR_FIELDS:
default = _SAR_ENUM_DEFAULTS.get(field)
common = _common(_message_value(band.sar, field, default) for band in bands)
@@ -960,7 +961,7 @@ def _sparse_sar(child: SARProperties | None, parent: SARProperties | None) -> SA
"""
if child is None:
return None
- values = {}
+ values: dict[str, Any] = {}
for field in _SAR_FIELDS:
default = _SAR_ENUM_DEFAULTS.get(field)
value = _message_value(child, field, default)
@@ -1260,7 +1261,7 @@ def _discover_xarray_message(
values = [value for value in _message_values(datapoint[variable_override]) if isinstance(value, message_type)]
if len(values) != 1:
raise ValueError(f"field {variable_override!r} does not contain exactly one {message_name} message")
- return cast(_MessageT, values[0])
+ return values[0]
candidates = []
for name, data in datapoint.variables.items():
values = [value for value in _message_values(data) if isinstance(value, message_type)]
diff --git a/tilebox-datasets/tilebox/datasets/client.py b/tilebox-datasets/tilebox/datasets/client.py
index 1df1941..07abfe0 100644
--- a/tilebox-datasets/tilebox/datasets/client.py
+++ b/tilebox-datasets/tilebox/datasets/client.py
@@ -1,14 +1,12 @@
-import os
-import sys
from typing import Any, Protocol, TypeVar
from uuid import UUID
-from loguru import logger
from promise import Promise
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.notices import print_notice
from tilebox.datasets.service import TileboxDatasetService
from tilebox.datasets.uuid import as_uuid
@@ -86,7 +84,7 @@ def _dataset_by_id(self, dataset_id: str | UUID, dataset_type: type[T]) -> Promi
def _log_server_message(response: ListDatasetsResponse) -> ListDatasetsResponse:
if response.server_message:
- logger.opt(colors=True).info(response.server_message + "\n")
+ print_notice(response.server_message + "\n")
return response
@@ -123,21 +121,3 @@ def _construct_root_group(
__all__ = ["Client"]
-
-
-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()
diff --git a/tilebox-datasets/tilebox/datasets/data/timeseries.py b/tilebox-datasets/tilebox/datasets/data/timeseries.py
index 47fb4c9..a774874 100644
--- a/tilebox-datasets/tilebox/datasets/data/timeseries.py
+++ b/tilebox-datasets/tilebox/datasets/data/timeseries.py
@@ -31,7 +31,11 @@ def from_message(cls, chunk: timeseries_pb2.TimeseriesDatasetChunk) -> "Timeseri
datapoint_interval = IDInterval.from_message(chunk.datapoint_interval)
time_interval = None
- if chunk.time_interval and chunk.time_interval.start_time and chunk.time_interval.end_time:
+ if (
+ chunk.HasField("time_interval")
+ and chunk.time_interval.HasField("start_time")
+ and chunk.time_interval.HasField("end_time")
+ ):
time_interval = TimeInterval.from_message(chunk.time_interval)
return cls(
diff --git a/tilebox-datasets/tilebox/datasets/notices.py b/tilebox-datasets/tilebox/datasets/notices.py
new file mode 100644
index 0000000..8c67843
--- /dev/null
+++ b/tilebox-datasets/tilebox/datasets/notices.py
@@ -0,0 +1,51 @@
+import os
+import re
+import sys
+from typing import TextIO
+
+_STYLES = {
+ "bold": "1",
+ "black": "30",
+ "red": "31",
+ "green": "32",
+ "yellow": "33",
+ "blue": "34",
+ "magenta": "35",
+ "cyan": "36",
+ "white": "37",
+}
+_TAG = re.compile(r"\[(/?)([a-z]+(?: [a-z]+)*)\]")
+_RESET = "\033[0m"
+
+
+def print_notice(message: str, output: TextIO | None = None) -> None:
+ """Print a notice with nested color/bold tags, e.g. `[yellow][bold]Hello[/bold][/yellow]`.
+
+ Tags can combine styles (`[bold red]...[/bold red]`). Unknown or mismatched
+ tags remain literal. Redirected output, NO_COLOR, and dumb terminals use plain text.
+ Output defaults to the current sys.stderr.
+ """
+ if output is None:
+ output = sys.stderr
+ color = output.isatty() and "NO_COLOR" not in os.environ and os.environ.get("TERM") != "dumb"
+ styles: list[str] = []
+
+ def replace_tag(match: re.Match[str]) -> str:
+ closing, style = match.groups()
+ if any(name not in _STYLES for name in style.split()):
+ return match.group()
+ if closing:
+ if not styles or styles[-1] != style:
+ return match.group()
+ styles.pop()
+ else:
+ styles.append(style)
+ if not color:
+ return ""
+ codes = [_STYLES[name] for active in styles for name in active.split()]
+ return _RESET + (f"\033[{';'.join(codes)}m" if codes else "")
+
+ rendered = _TAG.sub(replace_tag, message)
+ if color and styles:
+ rendered += _RESET
+ output.write(rendered + "\n")
diff --git a/tilebox-datasets/tilebox/datasets/protobuf_conversion/field_types.py b/tilebox-datasets/tilebox/datasets/protobuf_conversion/field_types.py
index 07fcb82..f07810f 100644
--- a/tilebox-datasets/tilebox/datasets/protobuf_conversion/field_types.py
+++ b/tilebox-datasets/tilebox/datasets/protobuf_conversion/field_types.py
@@ -178,7 +178,8 @@ def to_proto(self, value: str | float | timedelta | np.timedelta64) -> Duration
if is_missing(value) or (isinstance(value, np.timedelta64) and np.isnat(value)):
return None
# we use pandas to_timedelta function to handle a variety of input types that can be coerced to timedeltas
- seconds, nanos = divmod(to_timedelta(value).value, 10**9)
+ # pandas accepts np.timedelta64 at runtime, but its overloads omit it.
+ seconds, nanos = divmod(to_timedelta(value).value, 10**9) # ty: ignore[no-matching-overload]
return Duration(seconds=seconds, nanos=nanos)
diff --git a/tilebox-datasets/tilebox/datasets/query/expression.py b/tilebox-datasets/tilebox/datasets/query/expression.py
index af6b4f5..aa6d002 100644
--- a/tilebox-datasets/tilebox/datasets/query/expression.py
+++ b/tilebox-datasets/tilebox/datasets/query/expression.py
@@ -95,10 +95,10 @@ class Field:
__hash__ = None
- def __eq__(self, value: object) -> Expression: # type: ignore[override]
+ def __eq__(self, value: object) -> Expression: # ty: ignore[invalid-method-override]
return self._comparison(data_access_pb2.FIELD_COMPARISON_OPERATOR_EQUAL, value)
- def __ne__(self, value: object) -> Expression: # type: ignore[override]
+ def __ne__(self, value: object) -> Expression: # ty: ignore[invalid-method-override]
return self._comparison(data_access_pb2.FIELD_COMPARISON_OPERATOR_NOT_EQUAL, value)
def __lt__(self, value: _QueryScalar) -> Expression:
diff --git a/tilebox-datasets/tilebox/datasets/sync/client.py b/tilebox-datasets/tilebox/datasets/sync/client.py
index 8df6522..c0f3809 100644
--- a/tilebox-datasets/tilebox/datasets/sync/client.py
+++ b/tilebox-datasets/tilebox/datasets/sync/client.py
@@ -1,8 +1,6 @@
import os
from uuid import UUID
-from loguru import logger
-
from _tilebox.grpc.channel import (
ConnectStubAdapter,
Transport,
@@ -19,6 +17,7 @@
from tilebox.datasets.datasets.v1.data_ingestion_pb2_grpc import DataIngestionServiceStub
from tilebox.datasets.datasets.v1.datasets_pb2_grpc import DatasetServiceStub
from tilebox.datasets.group import Group
+from tilebox.datasets.notices import print_notice
from tilebox.datasets.service import TileboxDatasetService
from tilebox.datasets.sync.dataset import DatasetClient
@@ -54,11 +53,11 @@ def __init__(
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(
- ""
- "No Tilebox API key detected. Using anonymous open data access without authentication. "
+ print_notice(
+ "[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]"
)
rpc_method_prefix = _PUBLIC_RPC_METHOD_PREFIX if (is_tilebox_deployment and token is None) else None
diff --git a/tilebox-grpc/_tilebox/grpc/channel.py b/tilebox-grpc/_tilebox/grpc/channel.py
index 446ddc8..71a7cca 100644
--- a/tilebox-grpc/_tilebox/grpc/channel.py
+++ b/tilebox-grpc/_tilebox/grpc/channel.py
@@ -344,7 +344,7 @@ def _replace_call_details(
client_call_details: ClientCallDetails,
*,
method: str | bytes | None = None,
- metadata: list[tuple[str, str]] | None = None,
+ metadata: list[tuple[str, str | bytes]] | None = None,
) -> ClientCallDetails:
return ClientCallDetails(
client_call_details.method if method is None else method,
diff --git a/tilebox-workflows/tests/jobs/test_client.py b/tilebox-workflows/tests/jobs/test_client.py
index ad642a9..3cb195a 100644
--- a/tilebox-workflows/tests/jobs/test_client.py
+++ b/tilebox-workflows/tests/jobs/test_client.py
@@ -7,6 +7,7 @@
from tests.tasks_data import jobs
from _tilebox.grpc.error import NotFoundError
+from tilebox.datasets.query.id_interval import IDInterval
from tilebox.datasets.query.pagination import Pagination
from tilebox.datasets.query.time_interval import datetime_to_timestamp
from tilebox.workflows.data import (
@@ -254,6 +255,25 @@ def test_query_filters_by_clusters() -> None:
assert list(mock_service.query_requests[-1].filters.cluster_slugs) == ["cluster-a", "cluster-b"]
+@pytest.mark.parametrize("use_interval", [False, True])
+def test_query_uuid_interval_preserves_bounds(use_interval: bool) -> None:
+ service = JobService(MagicMock())
+ mock_service = MockJobService()
+ service.service = mock_service
+ job_client = JobClient(service, MagicMock(), NoopWorkflowTracer())
+ start, end = UUID(int=17), UUID(int=93)
+ extent = IDInterval(start, end, start_exclusive=True, end_inclusive=False) if use_interval else (start, end)
+
+ job_client.query(extent)
+
+ filters = mock_service.query_requests[-1].filters
+ assert not filters.HasField("time_interval")
+ assert uuid_message_to_uuid(filters.id_interval.start_id) == start
+ assert uuid_message_to_uuid(filters.id_interval.end_id) == end
+ assert filters.id_interval.start_exclusive is use_interval
+ assert filters.id_interval.end_inclusive is not use_interval
+
+
def test_query_empty_cluster_list_applies_no_cluster_filter() -> None:
service = JobService(MagicMock())
mock_service = MockJobService()
diff --git a/tilebox-workflows/tests/observability/test_logging.py b/tilebox-workflows/tests/observability/test_logging.py
new file mode 100644
index 0000000..d105c32
--- /dev/null
+++ b/tilebox-workflows/tests/observability/test_logging.py
@@ -0,0 +1,363 @@
+import logging
+import os
+import subprocess
+import sys
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass, field, replace
+from datetime import date
+from io import StringIO
+from pathlib import Path
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import msgspec
+import pytest
+from affine import Affine
+from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter, SimpleLogRecordProcessor
+from opentelemetry.sdk.trace import Span, TracerProvider
+
+from tilebox.workflows._codec import registry
+from tilebox.workflows.observability import _logging as structured_logging
+from tilebox.workflows.observability import logging as observability
+from tilebox.workflows.observability import tracing
+from tilebox.workflows.observability._logging import StructuredLogger, internal_logger, logger, root_logger, task_logger
+
+
+@pytest.fixture
+def isolated_logging(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
+ # Reset process-wide handlers, initialization state, and thresholds so each test
+ # configures its own outputs. monkeypatch and caplog restore prior state afterward.
+ monkeypatch.setattr(root_logger, "handlers", [])
+ monkeypatch.setattr(observability, "_api_handler", None)
+ monkeypatch.setattr(observability, "_console_handlers", [])
+ monkeypatch.setattr(observability, "_console_configured", False)
+ monkeypatch.setattr(observability, "_writer", None)
+ monkeypatch.delenv("TILEBOX_LOG_FD", raising=False)
+ caplog.set_level(logging.INFO, logger=task_logger.name)
+ caplog.set_level(logging.ERROR, logger=internal_logger.name)
+
+
+@pytest.mark.usefixtures("isolated_logging")
+@pytest.mark.parametrize(
+ ("task_level", "internal_level"), [(logging.DEBUG, logging.ERROR), (logging.ERROR, logging.DEBUG)]
+)
+def test_one_exporter_with_independent_logger_levels(
+ caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str], task_level: int, internal_level: int
+) -> None:
+ caplog.set_level(task_level, logger=task_logger.name)
+ caplog.set_level(internal_level, logger=internal_logger.name)
+ exporter = InMemoryLogRecordExporter()
+ with patch.object(observability, "_otel_log_exporter", return_value=SimpleLogRecordProcessor(exporter)) as factory:
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ list(pool.map(lambda _: observability.initialize_logging("https://first.example", "first-key"), range(12)))
+ handler = observability._api_handler
+ observability.initialize_logging("https://other.example", "other-key")
+ factory.assert_called_once_with(endpoint="https://first.example", headers={"Authorization": "Bearer first-key"})
+ assert observability._api_handler is handler
+ assert handler is not None
+ assert handler.level == logging.NOTSET
+ assert sum(isinstance(item, observability.OTELLoggingHandler) for item in root_logger.handlers) == 1
+ assert not task_logger.handlers
+ assert not internal_logger.handlers
+
+ for severity in (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR):
+ StructuredLogger(task_logger).log(severity, f"task-{severity}")
+ logger.log(severity, f"internal-{severity}")
+ expected = (
+ ["task-10", "task-20", "task-30", "task-40", "internal-40"]
+ if task_level == logging.DEBUG
+ else ["internal-10", "internal-20", "internal-30", "task-40", "internal-40"]
+ )
+ assert [item.log_record.body for item in exporter.get_finished_logs()] == expected
+ assert [line.split(": ", 2)[2] for line in capsys.readouterr().out.splitlines()] == expected
+
+
+@pytest.mark.usefixtures("isolated_logging")
+def test_both_loggers_preserve_attributes_with_and_without_a_span() -> None:
+ exporter = InMemoryLogRecordExporter()
+ with patch.object(observability, "_otel_log_exporter", return_value=SimpleLogRecordProcessor(exporter)):
+ observability.initialize_logging("https://example.test", "test-key")
+ tracer_provider = TracerProvider()
+ for target in (StructuredLogger(task_logger), logger):
+ target.error("no-context")
+ target.bind(task_id="supplied-task").error(
+ "supplied-context", trace_id="supplied-trace", span_id="supplied-span"
+ )
+ with tracer_provider.get_tracer(__name__).start_as_current_span("task") as span:
+ target.bind(task_id="task-123").error("with-context", attempt=3)
+ span_context = span.get_span_context()
+ plain, supplied, correlated = [item.log_record for item in exporter.get_finished_logs()][-3:]
+ assert not plain.trace_id
+ assert not plain.span_id
+ assert not plain.attributes
+ assert supplied.attributes == {
+ "task_id": "supplied-task",
+ "trace_id": "supplied-trace",
+ "span_id": "supplied-span",
+ }
+ assert correlated.trace_id == span_context.trace_id
+ assert correlated.span_id == span_context.span_id
+ assert correlated.attributes == {
+ "task_id": "task-123",
+ "attempt": 3,
+ "trace_id": f"{span_context.trace_id:032x}",
+ "span_id": f"{span_context.span_id:016x}",
+ }
+ tracer_provider.shutdown()
+
+
+@pytest.mark.parametrize("structured", [False, True])
+def test_export_attributes_capture_context_once_without_mutating_record(structured: bool) -> None:
+ metadata = {"task_id": "task-123", "details": {"attempt": 2}}
+ record = logging.makeLogRecord({"msg": "failed", "exc_info": (ValueError, ValueError({"reason": "bad"}), None)})
+ if structured:
+ record.tilebox_structured_log_attributes = metadata
+ handler = observability.OTELLoggingHandler(logger_provider=observability.LoggerProvider())
+ with patch.object(structured_logging, "_current_span_attributes", return_value={"trace_id": "fallback"}) as context:
+ attributes = handler._get_attributes(record)
+ assert context.call_count == (0 if structured else 1)
+ assert attributes == {
+ **({"task_id": "task-123", "details": '{"attempt":2}'} if structured else {"trace_id": "fallback"}),
+ "exception.type": "ValueError",
+ "exception.message": '{"reason":"bad"}',
+ }
+ assert metadata == {"task_id": "task-123", "details": {"attempt": 2}}
+
+
+@pytest.mark.parametrize("stdlib", [False, True])
+@pytest.mark.parametrize("pipe_first", [False, True])
+def test_codecs_run_once_per_record_across_handlers(stdlib: bool, pipe_first: bool) -> None:
+ codec = registry.find(Affine)
+ assert codec is not None
+ encode = MagicMock(wraps=codec.encode)
+ target = logging.Logger("normalized-records", logging.INFO) # noqa: LOG001 -- isolated from process-wide loggers
+ writer = MagicMock(spec=observability._PipeWriter)
+ exporters = [InMemoryLogRecordExporter(), InMemoryLogRecordExporter()]
+ providers = [observability.LoggerProvider() for _ in exporters]
+ handlers: list[logging.Handler] = [observability._StructuredHandler(writer), tracing.SpanEventLoggingHandler()]
+ for provider, exporter in zip(providers, exporters, strict=True):
+ provider.add_log_record_processor(SimpleLogRecordProcessor(exporter))
+ handlers.append(observability.OTELLoggingHandler(logger_provider=provider))
+ for handler in handlers if pipe_first else reversed(handlers):
+ target.addHandler(handler)
+ tracer_provider = TracerProvider()
+ value = {"transform": Affine(2, 3, 5, 7, 11, 13)}
+ facade = StructuredLogger(target).bind(input=value)
+ try:
+ with patch.dict(registry._cache, {Affine: replace(codec, encode=encode)}):
+ facade.debug("filtered")
+ encode.assert_not_called()
+ with tracer_provider.get_tracer(__name__).start_as_current_span("task") as span:
+ assert isinstance(span, Span)
+ for index in range(2):
+ if stdlib:
+ target.info("record-%s", index, extra={"tilebox_structured_log_attributes": {"input": value}})
+ else:
+ facade.info("record-%s", index)
+ assert encode.call_count == index + 1
+ assert len(span.events) == 2
+ for event in span.events:
+ assert event.attributes is not None
+ assert event.attributes["input"] == '{"transform":[2.0,3.0,5.0,7.0,11.0,13.0]}'
+ # Records hold snapshots, not references to task-owned mutable containers.
+ value.clear()
+ for call in writer.submit.call_args_list:
+ assert call.args[0]["attributes"]["input"] == {"transform": [2, 3, 5, 7, 11, 13]}
+ assert writer.submit.call_count == 2
+ for exporter in exporters:
+ records = [item.log_record for item in exporter.get_finished_logs()]
+ assert [record.body for record in records] == ["record-0", "record-1"]
+ for record in records:
+ assert record.attributes is not None
+ assert record.attributes["input"] == '{"transform":[2.0,3.0,5.0,7.0,11.0,13.0]}'
+ finally:
+ tracer_provider.shutdown()
+ for provider in providers:
+ provider.shutdown()
+
+
+@pytest.mark.usefixtures("isolated_logging")
+@pytest.mark.parametrize("external_first", [False, True])
+def test_external_exports_and_console_do_not_replace_api_or_pipe(
+ monkeypatch: pytest.MonkeyPatch, external_first: bool
+) -> None:
+ read_fd, write_fd = os.pipe()
+ monkeypatch.setenv("TILEBOX_LOG_FD", str(write_fd))
+ api, external = InMemoryLogRecordExporter(), InMemoryLogRecordExporter()
+ first, second = StringIO(), StringIO()
+ try:
+ with patch.object(observability, "_otel_log_exporter", return_value=SimpleLogRecordProcessor(external)):
+ if external_first:
+ observability.configure_otel_logging(endpoint="https://external.example")
+ observability.configure_console_logging(stream=first)
+ with patch.object(observability, "_otel_log_exporter", return_value=SimpleLogRecordProcessor(api)):
+ observability.initialize_logging("https://api.tilebox.com", "test-key")
+ with patch.object(observability, "_otel_log_exporter", return_value=SimpleLogRecordProcessor(external)):
+ if not external_first:
+ observability.configure_otel_logging(endpoint="https://external.example")
+ observability.configure_console_logging(stream=first)
+ observability.configure_console_logging(stream=second, reconfigure=False)
+ observability.configure_log_level(logging.DEBUG)
+ assert internal_logger.level == logging.ERROR
+ task_logger.info("all outputs")
+ observability.configure_console_logging(enabled=False)
+ observability.initialize_logging("https://ignored.example", "ignored-key")
+ task_logger.debug("exports only")
+ assert first.getvalue().count("all outputs") == second.getvalue().count("all outputs") == 1
+ assert "exports only" not in first.getvalue() + second.getvalue()
+ for exporter in (api, external):
+ assert [item.log_record.body for item in exporter.get_finished_logs()] == ["all outputs", "exports only"]
+ assert observability._writer is not None
+ observability._writer.close()
+ records = [msgspec.json.decode(line) for line in os.read(read_fd, 65536).splitlines()]
+ assert [record["message"] for record in records] == ["all outputs", "exports only"]
+ finally:
+ if observability._writer is not None:
+ observability._writer.close()
+ os.close(read_fd)
+
+
+@pytest.mark.usefixtures("isolated_logging")
+@pytest.mark.parametrize("tilebox_debug", [False, True])
+def test_log_level_override_filters_tasks_and_diagnostics_independently(
+ caplog: pytest.LogCaptureFixture, tilebox_debug: bool
+) -> None:
+ caplog.set_level(logging.DEBUG)
+ caplog.set_level(logging.DEBUG, logger=internal_logger.name)
+ observability.configure_log_level(logging.ERROR, tilebox_debug=tilebox_debug)
+ StructuredLogger(task_logger).debug("task-debug")
+ logger.debug("sdk-debug")
+ StructuredLogger(task_logger).error("task-error")
+ assert [record.message for record in caplog.records] == (
+ ["sdk-debug", "task-error"] if tilebox_debug else ["task-error"]
+ )
+
+
+@pytest.mark.usefixtures("isolated_logging")
+def test_log_level_override_defaults_reset_both_levels() -> None:
+ observability.configure_log_level(logging.CRITICAL, tilebox_debug=True)
+ observability.configure_log_level()
+ assert task_logger.level == logging.INFO
+ assert internal_logger.level == logging.ERROR
+
+
+@pytest.mark.usefixtures("isolated_logging")
+def test_task_values_and_unserializable_attributes_reach_both_outputs(monkeypatch: pytest.MonkeyPatch) -> None:
+ @dataclass
+ class Input:
+ transform: Affine
+ path: Path
+ secret: str = field(default="hidden", metadata={"skip_serialization": True})
+
+ class Broken:
+ def __str__(self) -> str:
+ raise ValueError("broken string conversion")
+
+ cyclic: list[Any] = []
+ cyclic.append(cyclic)
+ read_fd, write_fd = os.pipe()
+ monkeypatch.setenv("TILEBOX_LOG_FD", str(write_fd))
+ exporter = InMemoryLogRecordExporter()
+ try:
+ with patch.object(observability, "_otel_log_exporter", return_value=SimpleLogRecordProcessor(exporter)):
+ observability.initialize_logging("https://api.tilebox.com", "test-key")
+ StructuredLogger(task_logger).info(
+ "input",
+ input=Input(Affine(2, 3, 5, 7, 11, 13), Path("image.tif")),
+ counts={date(2026, 9, 16): 3},
+ cyclic=cyclic,
+ unsupported=Broken(),
+ )
+ assert observability._writer is not None
+ observability._writer.close()
+ record = msgspec.json.decode(os.read(read_fd, 65536))
+ assert record["attributes"] == {
+ "input": {"transform": [2, 3, 5, 7, 11, 13], "path": "image.tif"},
+ "counts": {"2026-09-16": 3},
+ "cyclic": "[[...]]",
+ "unsupported": "",
+ }
+ attributes = exporter.get_finished_logs()[0].log_record.attributes
+ assert attributes is not None
+ assert isinstance(attributes["input"], str)
+ assert isinstance(attributes["counts"], str)
+ assert msgspec.json.decode(attributes["input"]) == record["attributes"]["input"]
+ assert msgspec.json.decode(attributes["counts"]) == record["attributes"]["counts"]
+ assert attributes["cyclic"] == "[[...]]"
+ assert attributes["unsupported"] == ""
+ finally:
+ if observability._writer is not None:
+ observability._writer.close()
+ os.close(read_fd)
+
+
+@pytest.mark.parametrize("without_otel", [False, True])
+def test_facade_works_without_otel_or_tracer(without_otel: bool) -> None:
+ env = {key: value for key, value in os.environ.items() if not key.startswith("TILEBOX_")}
+ script = """
+import io
+import logging
+import sys
+from tilebox.workflows.observability._logging import StructuredLogger
+assert not any(name.startswith('opentelemetry') for name in sys.modules)
+OUTPUT = io.StringIO()
+target = logging.Logger('standalone', logging.DEBUG)
+target.addHandler(logging.StreamHandler(OUTPUT))
+"""
+ if without_otel:
+ script += "sys.modules['opentelemetry.trace'] = None\n"
+ script += """
+StructuredLogger(target).info('plain message', task_id='still-allowed')
+assert OUTPUT.getvalue() == 'plain message\\n'
+assert 'opentelemetry.sdk' not in sys.modules
+"""
+ subprocess.run([sys.executable, "-c", script], env=env, check=True, capture_output=True, timeout=10) # noqa: S603 -- fixed test script
+
+
+@pytest.mark.parametrize("missing", ["TILEBOX_API_URL", "TILEBOX_API_KEY"])
+def test_incomplete_environment_skips_startup_logging(missing: str) -> None:
+ env = {key: value for key, value in os.environ.items() if not key.startswith("TILEBOX_")}
+ env.update({"TILEBOX_API_URL": "https://startup.example", "TILEBOX_API_KEY": "startup-key"})
+ env.pop(missing)
+ subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ """
+import sys
+import tilebox.workflows
+from tilebox.workflows.observability._logging import root_logger
+assert not root_logger.handlers
+assert not any(name.startswith('opentelemetry') for name in sys.modules)
+""",
+ ],
+ env=env,
+ check=True,
+ capture_output=True,
+ timeout=10,
+ )
+
+
+@pytest.mark.parametrize(
+ ("level", "debug", "expected"),
+ [("debug", "false", "10 40"), ("critical", "true", "50 10"), ("", "0", "20 40")],
+)
+def test_environment_sets_logger_thresholds(level: str, debug: str, expected: str) -> None:
+ env = {key: value for key, value in os.environ.items() if not key.startswith("TILEBOX_")}
+ env.update({"TILEBOX_LOG_LEVEL": level, "TILEBOX_DEBUG": debug})
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ """
+from tilebox.workflows.observability._logging import task_logger, internal_logger
+print(task_logger.level, internal_logger.level)
+""",
+ ],
+ env=env,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ assert result.stdout.strip() == expected
diff --git a/tilebox-workflows/tests/observability/test_tracing.py b/tilebox-workflows/tests/observability/test_tracing.py
index 1d3df10..f1d5f80 100644
--- a/tilebox-workflows/tests/observability/test_tracing.py
+++ b/tilebox-workflows/tests/observability/test_tracing.py
@@ -78,6 +78,25 @@ def test_workflow_tracers_copy_configured_span_processors_once(
]
+def test_adding_external_exports_after_client_preserves_api_export(
+ span_processors: list[RecordingSpanProcessor],
+) -> None:
+ tracer = tracing.WorkflowTracer(service=None, url="https://api.tilebox.com", token=None)
+ with tracer.span("before"):
+ pass
+ tracing.configure_otel_tracing(service="first", endpoint="https://first.example")
+ with tracer.span("after-first"):
+ pass
+ tracing.configure_otel_tracing(service="second", endpoint="https://second.example")
+ with tracer.span("after-second"):
+ pass
+ assert [processor.span_names for processor in span_processors] == [
+ ["before", "after-first", "after-second"],
+ ["after-first", "after-second"],
+ ["after-second"],
+ ]
+
+
def test_workflow_tracer_propagates_task_id_to_sub_spans(
span_processors: list[RecordingSpanProcessor],
) -> None:
diff --git a/tilebox-workflows/tests/runner/test_runtime_logging.py b/tilebox-workflows/tests/runner/test_runtime_logging.py
new file mode 100644
index 0000000..e8f0635
--- /dev/null
+++ b/tilebox-workflows/tests/runner/test_runtime_logging.py
@@ -0,0 +1,387 @@
+import json
+import logging
+import os
+import subprocess
+import sys
+import threading
+from io import StringIO
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter, SimpleLogRecordProcessor
+
+from tilebox.workflows import Client, Runner
+from tilebox.workflows.observability import _log_pipe as runtime_logging
+from tilebox.workflows.observability import logging as observability
+from tilebox.workflows.observability._logging import internal_logger, logger, root_logger, task_logger
+from tilebox.workflows.observability.logging import _configure_runtime_logging, configure_console_logging
+from tilebox.workflows.runner.worker_service import WorkerServiceServicer
+from tilebox.workflows.workflows.v1 import worker_pb2
+
+
+@pytest.fixture(autouse=True)
+def isolated_handlers(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
+ monkeypatch.setattr(root_logger, "handlers", [])
+ monkeypatch.setattr(observability, "_api_handler", None)
+ monkeypatch.setattr(observability, "_console_handlers", [])
+ monkeypatch.setattr(observability, "_console_configured", False)
+ monkeypatch.setattr(observability, "_writer", None)
+ monkeypatch.delenv("TILEBOX_LOG_FD", raising=False)
+ caplog.set_level(logging.INFO, logger=task_logger.name)
+ caplog.set_level(logging.ERROR, logger=internal_logger.name)
+
+
+def test_worker_initialization_fallback_is_idempotent() -> None:
+ exporter = InMemoryLogRecordExporter()
+ request = worker_pb2.InitializeRunnerRequest(
+ api_connection=worker_pb2.TileboxAPIConnection(url="https://legacy.example", token="legacy-key") # noqa: S106
+ )
+ with (
+ patch("tilebox.workflows.client.open_channel"),
+ patch("tilebox.workflows.client.WorkflowTracer"),
+ patch.object(observability, "_otel_log_exporter", return_value=SimpleLogRecordProcessor(exporter)) as factory,
+ ):
+ for _ in range(2):
+ service = WorkerServiceServicer(Runner(tasks=[]), lambda: None)
+ service.InitializeWorker(request, MagicMock())
+ factory.assert_called_once_with(endpoint="https://legacy.example", headers={"Authorization": "Bearer legacy-key"})
+ logger.error("legacy-runtime")
+ assert [item.log_record.body for item in exporter.get_finished_logs()] == ["legacy-runtime"]
+
+
+def test_direct_runner_initializes_on_connect_not_client_creation(capsys: pytest.CaptureFixture[str]) -> None:
+ exporter = InMemoryLogRecordExporter()
+ with (
+ patch("tilebox.workflows.client.open_channel"),
+ patch("tilebox.workflows.client.WorkflowTracer"),
+ patch("tilebox.workflows.client.TaskRunner"),
+ patch("tilebox.workflows.client._LeaseRenewer"),
+ patch.object(observability, "_otel_log_exporter", return_value=SimpleLogRecordProcessor(exporter)) as factory,
+ ):
+ client = Client(url="https://direct.example", token="direct-key") # noqa: S106
+ factory.assert_not_called()
+ assert observability._api_handler is None
+ with patch.object(client, "clusters"):
+ Runner(tasks=[]).connect_to(client)
+ client.runner()
+ factory.assert_called_once_with(endpoint="https://direct.example", headers={"Authorization": "Bearer direct-key"})
+ client._task_logger.info("direct-task")
+ logger.error("direct-internal")
+ expected = ["direct-task", "direct-internal"]
+ assert [item.log_record.body for item in exporter.get_finished_logs()] == expected
+ assert [line.split(": ", 2)[2] for line in capsys.readouterr().out.splitlines()] == expected
+
+
+def test_absent_fd_adds_one_console_handler(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ monkeypatch.delenv("TILEBOX_LOG_FD", raising=False)
+ handler = logging.NullHandler()
+ root_logger.addHandler(handler)
+ assert _configure_runtime_logging() is None
+ assert _configure_runtime_logging() is None
+ assert handler in root_logger.handlers
+ task_logger.info("console message")
+ assert capsys.readouterr().out.endswith(": INFO: console message\n")
+
+
+def test_existing_console_is_reused(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
+ monkeypatch.delenv("TILEBOX_LOG_FD", raising=False)
+ output = StringIO()
+ configure_console_logging(stream=output)
+ handlers = root_logger.handlers[:]
+ _configure_runtime_logging()
+ task_logger.info("configured console")
+ assert root_logger.handlers == handlers
+ assert output.getvalue().count("configured console") == 1
+ assert capsys.readouterr().out == ""
+
+
+def test_console_reconfiguration_preserves_file_handler(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = tmp_path / "workflow.log"
+ handler = logging.FileHandler(path)
+ root_logger.addHandler(handler)
+ try:
+ _configure_runtime_logging()
+ task_logger.info("automatic")
+ assert capsys.readouterr().out.count("automatic") == 1
+ output = StringIO()
+ configure_console_logging(stream=output)
+ _configure_runtime_logging()
+ task_logger.info("reconfigured")
+ assert capsys.readouterr().out == ""
+ assert output.getvalue().count("reconfigured") == 1
+ assert path.read_text().splitlines() == ["automatic", "reconfigured"]
+ finally:
+ root_logger.removeHandler(handler)
+ handler.close()
+
+
+@pytest.mark.parametrize("before_startup", [False, True])
+def test_console_opt_out_persists(before_startup: bool, capsys: pytest.CaptureFixture[str]) -> None:
+ if not before_startup:
+ _configure_runtime_logging()
+ configure_console_logging(enabled=False)
+ _configure_runtime_logging()
+ observability.get_logger("disabled").info("not visible")
+ assert capsys.readouterr().out == ""
+ assert not observability._console_handlers
+ output = StringIO()
+ configure_console_logging(stream=output)
+ task_logger.info("enabled again")
+ assert output.getvalue().count("enabled again") == 1
+
+
+@pytest.mark.parametrize(("debug", "level"), [("false", "debug"), ("true", "error")])
+def test_independent_pipe_levels(
+ monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, debug: str, level: str
+) -> None:
+ read_fd, write_fd = os.pipe()
+ monkeypatch.setenv("TILEBOX_LOG_FD", str(write_fd))
+ caplog.set_level(level.upper(), logger=task_logger.name)
+ caplog.set_level(logging.DEBUG if debug == "true" else logging.ERROR, logger=internal_logger.name)
+ writer = _configure_runtime_logging()
+ assert writer is not None
+ assert _configure_runtime_logging() is writer
+ workflow = task_logger
+ for severity in (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR):
+ logger.log(severity, f"internal-{severity}", task_id="preserved")
+ workflow.log(severity, f"workflow-{severity}")
+ writer.close()
+ records = [json.loads(line) for line in os.read(read_fd, 65536).decode().splitlines()]
+ os.close(read_fd)
+ expected_internal = (
+ {"internal-10", "internal-20", "internal-30", "internal-40"} if debug == "true" else {"internal-40"}
+ )
+ expected_workflow = (
+ {"workflow-10", "workflow-20", "workflow-30", "workflow-40"} if level == "debug" else {"workflow-40"}
+ )
+ assert {r["message"] for r in records} == expected_internal | expected_workflow
+ assert len(records) == len(expected_internal | expected_workflow)
+ assert all(r["attributes"]["task_id"] == "preserved" for r in records if r["message"].startswith("internal-"))
+
+
+def test_structured_pipe_is_ndjson_across_threads(
+ monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
+) -> None:
+ read_fd, write_fd = os.pipe()
+ monkeypatch.setenv("TILEBOX_LOG_FD", str(write_fd))
+ caplog.set_level(logging.WARNING, logger=task_logger.name)
+ writer = _configure_runtime_logging()
+ assert writer is not None
+ assert not os.get_inheritable(write_fd)
+
+ local_logger = task_logger
+ local_logger.info("filtered")
+ threads = [threading.Thread(target=local_logger.warning, args=(f"message-{index}",)) for index in range(20)]
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join()
+
+ def log_failure() -> None:
+ raise ValueError("first line\nsecond line")
+
+ try:
+ log_failure()
+ except ValueError:
+ local_logger.exception("failed", extra={"tilebox_structured_log_attributes": {"task": 1}})
+
+ writer.close()
+ data = os.read(read_fd, 1_000_000).decode()
+ os.close(read_fd)
+ records = [json.loads(line) for line in data.splitlines()]
+ assert {record["message"] for record in records} == {*(f"message-{index}" for index in range(20)), "failed"}
+ failure = next(record for record in records if record["message"] == "failed")
+ assert failure["level"] == "error"
+ assert "ValueError: first line\nsecond line" in failure["exception"]
+ assert failure["attributes"] == {"task": 1}
+
+
+def test_idle_writer_shutdown_wakes_blocking_get() -> None:
+ read_fd, write_fd = os.pipe()
+ waiting = threading.Event()
+ original_drain = runtime_logging._PipeWriter._drain
+
+ def observe_get(writer: runtime_logging._PipeWriter) -> None:
+ original_get = writer._records.get
+
+ def get() -> bytes | None:
+ waiting.set()
+ return original_get()
+
+ with patch.object(writer._records, "get", side_effect=get):
+ original_drain(writer)
+
+ with patch.object(runtime_logging._PipeWriter, "_drain", observe_get):
+ writer = runtime_logging._PipeWriter(write_fd)
+ try:
+ assert waiting.wait(timeout=2)
+ writer.close()
+ writer.close()
+ assert not writer._thread.is_alive()
+ finally:
+ writer.close()
+ os.close(read_fd)
+
+
+@pytest.mark.parametrize("count", [3, 256])
+def test_shutdown_drains_queued_records_even_when_full(count: int) -> None:
+ read_fd, write_fd = os.pipe()
+ release = threading.Event()
+ original_drain = runtime_logging._PipeWriter._drain
+
+ def delayed_drain(writer: runtime_logging._PipeWriter) -> None:
+ release.wait()
+ original_drain(writer)
+
+ with (
+ patch.object(runtime_logging._PipeWriter, "_drain", delayed_drain),
+ patch.object(runtime_logging.os, "write", wraps=os.write) as write,
+ ):
+ writer = runtime_logging._PipeWriter(write_fd)
+ try:
+ for index in range(count):
+ writer.submit({"i": index})
+ # Keep draining paused until close has attempted to enqueue its sentinel.
+ writer.close()
+ release.set()
+ writer.close()
+ assert not writer._thread.is_alive()
+ records = [json.loads(line) for line in os.read(read_fd, 65536).splitlines()]
+ assert records == [{"i": index} for index in range(count)]
+ assert write.call_count == 1
+ finally:
+ release.set()
+ writer.close()
+ os.close(read_fd)
+
+
+@pytest.mark.parametrize("level", ["info", "error"])
+def test_bootstrap_before_import(tmp_path: Path, level: str) -> None:
+ runtime_id = "66d615f3-7d53-4a31-bc94-94cbb9d9ffa2"
+ (tmp_path / "sample_runner.py").write_text(
+ "from tilebox.workflows import Runner\n"
+ "from tilebox.workflows.observability.logging import get_logger, _get_default_resource\n"
+ "print(_get_default_resource().attributes['service.instance.id'])\n"
+ "get_logger().info('import-info')\n"
+ "get_logger().error('import-error')\n"
+ "runner = Runner(tasks=[])\n"
+ )
+ read_fd, write_fd = os.pipe()
+ try:
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ """
+from unittest.mock import patch
+from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter
+exporter = InMemoryLogRecordExporter()
+with patch('opentelemetry.exporter.otlp.proto.http._log_exporter.OTLPLogExporter', return_value=exporter) as factory:
+ from tilebox.workflows.runner import __main__ as m
+ from tilebox.workflows.observability import logging as logs
+ from tilebox.workflows.observability._logging import task_logger, internal_logger
+ import os
+ assert task_logger.level == (20 if os.environ['TILEBOX_LOG_LEVEL'] == 'info' else 40)
+ assert internal_logger.level == 40
+ m.serve_runner = lambda _: None
+ m.main(['sample_runner:runner'])
+ logs.initialize_logging('https://ignored.example', 'ignored-key')
+ logs._api_handler.flush()
+ factory.assert_called_once()
+ assert [item.log_record.body for item in exporter.get_finished_logs()] == (
+ ['import-info', 'import-error'] if task_logger.level == 20 else ['import-error']
+ )
+""",
+ ],
+ env=os.environ
+ | {
+ "TILEBOX_LOG_FD": str(write_fd),
+ "TILEBOX_RUNTIME_ID": runtime_id,
+ "TILEBOX_API_URL": "https://startup.example",
+ "TILEBOX_API_KEY": "startup-key",
+ "TILEBOX_LOG_LEVEL": level,
+ "TILEBOX_DEBUG": "false",
+ "PYTHONPATH": str(tmp_path),
+ },
+ pass_fds=(write_fd,),
+ capture_output=True,
+ text=True,
+ timeout=10,
+ check=True,
+ )
+ records = [json.loads(line) for line in os.read(read_fd, 65536).decode().splitlines()]
+ finally:
+ os.close(write_fd)
+ os.close(read_fd)
+ assert result.stdout == runtime_id + "\n"
+ assert result.stderr == ""
+ assert [record["message"] for record in records] == (
+ ["import-info", "import-error"] if level == "info" else ["import-error"]
+ )
+
+
+def test_full_queue_skips_encoding_and_reports_drops() -> None:
+ read_fd, write_fd = os.pipe()
+ release = threading.Event()
+ original_drain = runtime_logging._PipeWriter._drain
+
+ def delayed_drain(writer: runtime_logging._PipeWriter) -> None:
+ release.wait()
+ original_drain(writer)
+
+ with patch.object(runtime_logging._PipeWriter, "_drain", delayed_drain):
+ writer = runtime_logging._PipeWriter(write_fd)
+ try:
+ for index in range(256):
+ writer.submit({"i": index})
+ with patch.object(runtime_logging.msgspec.json, "encode") as encode:
+ writer.submit({"discarded": 1})
+ writer.submit({"discarded": 2})
+ encode.assert_not_called()
+ assert writer._records.qsize() == 256
+ assert writer._records.get_nowait() == b'{"i":0}\n'
+ assert writer._records.get_nowait() == b'{"i":1}\n'
+ writer.submit({"resumed": True})
+ release.set()
+ writer.close()
+ assert not writer._thread.is_alive()
+ records = [json.loads(line) for line in os.read(read_fd, 65536).splitlines()]
+ assert records == [
+ *({"i": index} for index in range(2, 256)),
+ {"level": "warning", "message": "Dropped 2 local log records"},
+ {"resumed": True},
+ ]
+ finally:
+ release.set()
+ writer.close()
+ os.close(read_fd)
+
+
+@pytest.mark.parametrize("disconnected", [False, True])
+def test_failed_pipe_releases_queue_and_skips_future_encoding(*, disconnected: bool) -> None:
+ read_fd, write_fd = os.pipe()
+ if disconnected:
+ os.close(read_fd)
+ writer = runtime_logging._PipeWriter(write_fd)
+ try:
+ # Larger than the pipe buffer: a stalled reader forces a partial write.
+ writer.submit({"message": "x" * (512 * 1024)})
+ for index in range(8):
+ writer.submit({"i": index})
+ writer._thread.join(timeout=2)
+ assert not writer._thread.is_alive()
+ assert writer._records.empty()
+ with patch.object(runtime_logging.msgspec.json, "encode") as encode:
+ writer.submit({"after_failure": True})
+ encode.assert_not_called()
+ if not disconnected:
+ data = os.read(read_fd, 1024 * 1024)
+ assert data.startswith(b'{"message":"xxx')
+ assert b"\n" not in data # No later records appended to a truncated one.
+ finally:
+ writer.close()
+ if not disconnected:
+ os.close(read_fd)
diff --git a/tilebox-workflows/tests/runner/test_worker_concurrency.py b/tilebox-workflows/tests/runner/test_worker_concurrency.py
index e510d9e..ce7407a 100644
--- a/tilebox-workflows/tests/runner/test_worker_concurrency.py
+++ b/tilebox-workflows/tests/runner/test_worker_concurrency.py
@@ -16,6 +16,7 @@
from tilebox.workflows.cache import InMemoryCache, JobCache
from tilebox.workflows.data import ExecutionStats, Job, JobState, RunnerContext, TaskState
from tilebox.workflows.data import Task as TaskData
+from tilebox.workflows.observability._logging import StructuredLogger
from tilebox.workflows.observability.tracing import NoopWorkflowTracer
from tilebox.workflows.runner.executor import LazyStorageLocations
from tilebox.workflows.runner.worker_server import serve_runner
@@ -25,7 +26,11 @@
def test_worker_executes_tasks_concurrently_with_isolated_execution_state(
caplog: pytest.LogCaptureFixture,
+ monkeypatch: pytest.MonkeyPatch,
) -> None:
+ # Runtime identity is independent of the runner ID supplied by initialization.
+ monkeypatch.setenv("TILEBOX_RUNTIME_ID", str(uuid4()))
+
class SharedRunnerContext(RunnerContext):
instances: ClassVar[list["SharedRunnerContext"]] = []
@@ -58,8 +63,9 @@ async def execute(self, context: ExecutionContext) -> None:
runner = Runner(tasks=[ConcurrentTask], cache=cache, context=SharedRunnerContext)
fake_client = MagicMock()
fake_client._tracer = NoopWorkflowTracer()
- fake_client._task_logger = logging.getLogger("tilebox.workflows.tests.shared-worker")
- caplog.set_level(logging.INFO, logger=fake_client._task_logger.name)
+ fake_client._auth = {"url": "https://worker.example", "token": "worker-key"}
+ fake_client._task_logger = StructuredLogger(logging.getLogger("tilebox.workflows.tests.shared-worker"))
+ caplog.set_level(logging.INFO, logger="tilebox.workflows.tests.shared-worker")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as free_socket:
free_socket.bind(("127.0.0.1", 0))
@@ -67,7 +73,10 @@ async def execute(self, context: ExecutionContext) -> None:
server_thread = threading.Thread(target=serve_runner, args=(runner, address), daemon=True)
- with patch("tilebox.workflows.runner.worker_service.Client", return_value=fake_client):
+ with (
+ patch("tilebox.workflows.runner.worker_service.Client", return_value=fake_client),
+ patch("tilebox.workflows.runner.worker_service.initialize_logging") as initialize_logging,
+ ):
server_thread.start()
channel = grpc.insecure_channel(address)
grpc.channel_ready_future(channel).result(timeout=5)
@@ -76,6 +85,7 @@ async def execute(self, context: ExecutionContext) -> None:
worker_pb2.InitializeRunnerRequest(runner_id=must_uuid_to_uuid_message(uuid4())),
timeout=5,
)
+ initialize_logging.assert_called_once_with(url="https://worker.example", token="worker-key") # noqa: S106
job = _job()
tasks = [_task_message(ConcurrentTask(label), job) for label in ("first", "second")]
diff --git a/tilebox-workflows/tests/test_client.py b/tilebox-workflows/tests/test_client.py
index 325e3b3..29a0969 100644
--- a/tilebox-workflows/tests/test_client.py
+++ b/tilebox-workflows/tests/test_client.py
@@ -27,11 +27,9 @@ def test_client_url_environment(
monkeypatch.setenv("TILEBOX_API_KEY", "runner-key")
with (
patch("tilebox.workflows.client.open_channel") as open_channel_mock,
- patch("tilebox.workflows.client._create_tilebox_logger_provider") as logger_provider_mock,
patch("tilebox.workflows.client.WorkflowTracer") as tracer_mock,
):
client = Client() if explicit_url is None else Client(url=explicit_url)
open_channel_mock.assert_called_once_with(expected_url, "runner-key")
- logger_provider_mock.assert_called_once_with(service=None, url=expected_url, token="runner-key") # noqa: S106
tracer_mock.assert_called_once_with(service=None, url=expected_url, token="runner-key") # noqa: S106
assert client._auth == {"url": expected_url, "token": "runner-key"}
diff --git a/tilebox-workflows/tests/test_data.py b/tilebox-workflows/tests/test_data.py
index a6c5c76..e7e6e85 100644
--- a/tilebox-workflows/tests/test_data.py
+++ b/tilebox-workflows/tests/test_data.py
@@ -1,3 +1,8 @@
+from dataclasses import replace
+from itertools import product
+from uuid import UUID
+
+import pytest
from hypothesis import given
from tests.tasks_data import (
@@ -31,6 +36,7 @@
FilesystemNode,
Idling,
Job,
+ JobState,
ProgressIndicator,
QueryFilters,
ReleaseContent,
@@ -44,6 +50,7 @@
Workflow,
WorkflowRelease,
)
+from tilebox.workflows.formatting.job import JobWidget, RichDisplayJob
@given(task_identifiers())
@@ -76,6 +83,52 @@ def test_jobs_to_message_and_back(job: Job) -> None:
assert Job.from_message(job.to_message()) == job
+@pytest.mark.parametrize(
+ ("left_key", "right_key", "order"),
+ [
+ ((1, JobState.FAILED, "z", 9), (2, JobState.SUBMITTED, "a", 1), -1),
+ ((1, JobState.RUNNING, "z", 9), (1, JobState.COMPLETED, "a", 1), -1),
+ ((1, JobState.RUNNING, "z", 1), (1, JobState.RUNNING, "a", 9), 1),
+ ((1, JobState.RUNNING, "a", 2), (1, JobState.RUNNING, "a", 7), -1),
+ ],
+)
+@given(left=jobs(), right=jobs())
+def test_job_ordering(
+ left: Job,
+ right: Job,
+ left_key: tuple[int, JobState, str, int],
+ right_key: tuple[int, JobState, str, int],
+ order: int,
+) -> None:
+ left = replace(
+ left,
+ id=UUID(int=left_key[0]),
+ state=left_key[1],
+ name=left_key[2],
+ execution_stats=replace(left.execution_stats, total_tasks=left_key[3]),
+ )
+ right = replace(
+ right,
+ id=UUID(int=right_key[0]),
+ state=right_key[1],
+ name=right_key[2],
+ execution_stats=replace(right.execution_stats, total_tasks=right_key[3]),
+ )
+ # Exercise all four class combinations and both operand directions.
+ left_variants = [left, RichDisplayJob(**vars(left), _widget=JobWidget(None))]
+ right_variants = [right, RichDisplayJob(**vars(right), _widget=JobWidget(None))]
+ for first, second in product(left_variants, right_variants):
+ assert (first < second) == (order < 0)
+ assert (first <= second) == (order <= 0)
+ assert (first > second) == (order > 0)
+ assert (first >= second) == (order >= 0)
+ assert (second < first) == (order > 0)
+ assert (second <= first) == (order >= 0)
+ assert (second > first) == (order < 0)
+ assert (second >= first) == (order <= 0)
+ assert first != second # Ordering does not change full-value equality.
+
+
@given(clusters())
def test_cluster_repr(cluster: Cluster) -> None:
assert cluster.slug in repr(cluster)
diff --git a/tilebox-workflows/tests/test_task.py b/tilebox-workflows/tests/test_task.py
index fde971a..d92e6ce 100644
--- a/tilebox-workflows/tests/test_task.py
+++ b/tilebox-workflows/tests/test_task.py
@@ -1,6 +1,6 @@
import json
from collections.abc import Awaitable
-from dataclasses import dataclass
+from dataclasses import dataclass, field, fields, is_dataclass
from typing import Annotated
import pytest
@@ -20,6 +20,29 @@
)
+def test_task_base_is_an_empty_dataclass() -> None:
+ assert is_dataclass(Task)
+ assert fields(Task) == ()
+ assert serialize_task(Task()) == b""
+ assert deserialize_task(Task, b"") == Task()
+
+
+def test_task_field_specifiers() -> None:
+ class TaskWithDefaults(Task):
+ name: str
+ values: list[int] = field(default_factory=list)
+ runtime_value: int = field(init=False, default=7)
+
+ first = TaskWithDefaults(name="first")
+ second = TaskWithDefaults(name="second", values=[2])
+ first.values.append(1)
+ assert first.values == [1]
+ assert second.values == [2]
+ assert TaskWithDefaults(name="third").values == []
+ assert first.runtime_value == 7
+ assert [f.name for f in fields(TaskWithDefaults)] == ["name", "values", "runtime_value"]
+
+
def test_task_validation_simple_task() -> None:
class SimpleTask(Task):
pass
@@ -64,7 +87,7 @@ def test_task_validation_execute_awaitable_with_value_return_type() -> None:
with pytest.raises(TypeError, match="to not have a return value"):
class InvalidAwaitableTask(Task):
- def execute(self, context: ExecutionContext) -> Awaitable[int]:
+ def execute(self, context: ExecutionContext) -> Awaitable[int]: # ty: ignore[invalid-method-override]
_ = context
async def execute_async() -> int:
diff --git a/tilebox-workflows/tests/test_task_serialization.py b/tilebox-workflows/tests/test_task_serialization.py
index 03b2100..8cc2344 100644
--- a/tilebox-workflows/tests/test_task_serialization.py
+++ b/tilebox-workflows/tests/test_task_serialization.py
@@ -31,6 +31,7 @@
from tilebox.datasets.data.data_access import SpatialFilter
from tilebox.datasets.query.id_interval import IDInterval
from tilebox.datasets.query.time_interval import TimeInterval
+from tilebox.workflows._serialization import encode_log_value
from tilebox.workflows.task import Task, deserialize_task, serialize_task
@@ -84,6 +85,27 @@ def test_standard_types_round_trip() -> None:
)
assert deserialize_task(StandardTypesTask, serialize_task(task)) == task
+ logged = msgspec.json.decode(encode_log_value(task))
+ logged["values"].sort()
+ logged["frozen_values"].sort()
+ assert logged == {
+ "aware_datetime": "2024-01-02T03:04:05.006000+00:00",
+ "naive_datetime": "2024-02-03T04:05:06.007000",
+ "date_value": "2024-03-04",
+ "time_value": "05:06:07.008000",
+ "duration": "-P1DT86396.996S",
+ "identifier": str(task.identifier),
+ "decimal": "1234567890.123456789",
+ "enum": "value",
+ "binary": "AP9iaW5hcnk=",
+ "mutable_binary": "bXV0YWJsZQ==",
+ "path": "some/file.tif",
+ "pure_path": "another/file.tif",
+ "timezone": "Europe/Vienna",
+ "values": [1, 2, 3],
+ "frozen_values": ["a", "b"],
+ "nested": {"protobuf": "CgZuZXN0ZWQQKg==", "path": "nested.tif"},
+ }
class RequiredIntTask(Task):
diff --git a/tilebox-workflows/tilebox/workflows/__init__.py b/tilebox-workflows/tilebox/workflows/__init__.py
index a18af17..f22b612 100644
--- a/tilebox-workflows/tilebox/workflows/__init__.py
+++ b/tilebox-workflows/tilebox/workflows/__init__.py
@@ -1,9 +1,6 @@
import os
-import sys
from typing import TYPE_CHECKING, Any
-from loguru import logger
-
if TYPE_CHECKING:
from tilebox.workflows.client import Client
from tilebox.workflows.data import Job
@@ -48,19 +45,17 @@ def __dir__() -> list[str]:
return sorted(set(globals()) | set(__all__))
-def _init_logging(level: str = "INFO") -> None:
- logger.remove()
- logger.add(sys.stdout, level=level, format="{process}: {level}: {message}", catch=True)
+def _initialize_logging_from_environment() -> None:
+ # Stage 1: current CLIs provide credentials before importing workflow code, so
+ # even import-time logs reach the API. Ordinary SDK imports stay lightweight.
+ url = os.environ.get("TILEBOX_API_URL")
+ token = os.environ.get("TILEBOX_API_KEY")
+ if not url or not token:
+ return
+ from tilebox.workflows.observability.logging import initialize_logging # noqa: PLC0415
-def _is_debug() -> bool:
- try:
- return bool(int(os.environ.get("TILEBOX_DEBUG") or 0))
- except (TypeError, ValueError):
- return False
+ initialize_logging(url=url, token=token)
-if _is_debug():
- _init_logging("DEBUG")
-else:
- _init_logging()
+_initialize_logging_from_environment()
diff --git a/tilebox-workflows/tilebox/workflows/_codecs/odc.py b/tilebox-workflows/tilebox/workflows/_codecs/odc.py
index 76c6f70..bac7243 100644
--- a/tilebox-workflows/tilebox/workflows/_codecs/odc.py
+++ b/tilebox-workflows/tilebox/workflows/_codecs/odc.py
@@ -11,7 +11,7 @@
def _encode_crs(crs: CRS) -> str:
authority = crs.authority
- return f"{authority[0]}:{authority[1]}" if authority and all(authority) else crs.wkt
+ return f"{authority[0]}:{authority[1]}" if all(authority) else crs.wkt
def _encode_geometry(geometry: Geometry) -> dict[str, Any]:
diff --git a/tilebox-workflows/tilebox/workflows/_serialization.py b/tilebox-workflows/tilebox/workflows/_serialization.py
index ea482b9..c85405a 100644
--- a/tilebox-workflows/tilebox/workflows/_serialization.py
+++ b/tilebox-workflows/tilebox/workflows/_serialization.py
@@ -1,11 +1,11 @@
import typing
from base64 import b64decode, b64encode
-from dataclasses import fields, is_dataclass
+from dataclasses import Field, fields, is_dataclass
from datetime import datetime
from functools import lru_cache
from pathlib import PurePath
from types import NoneType, UnionType
-from typing import Any, get_args, get_origin
+from typing import Any, ClassVar, Protocol, get_args, get_origin
from zoneinfo import ZoneInfo
import msgspec
@@ -14,13 +14,61 @@
from tilebox.workflows._codec import registry
-def encode_json_field(value: Any, owner_type: type, field_name: str) -> bytes:
+class DataclassInstance(Protocol):
+ __dataclass_fields__: ClassVar[dict[str, Field[Any]]]
+
+
+def normalize_log_value(value: Any) -> Any:
+ """Convert a log value to JSON primitives, using task codecs and best-effort text fallbacks."""
+ if type(value) in (str, bool, int, float, NoneType):
+ return value
+ return _JSON_DECODER.decode(encode_log_value(value))
+
+
+def encode_log_value(value: Any) -> bytes:
+ """Encode task-compatible values for logs, falling back without failing task execution.
+
+ Unlike task submission, logging is best-effort: unsupported values, cycles, or
+ failing codecs become text. A failing __str__ becomes a type-name placeholder.
+ """
+ try:
+ return _JSON_ENCODER.encode(_prepare_log_value(value))
+ except Exception: # noqa: BLE001 -- logging must contain user codec/serialization failures
+ try:
+ fallback = str(value)
+ except Exception: # noqa: BLE001 -- even user __str__ can fail
+ fallback = f""
+ return msgspec.json.encode(fallback.encode("utf-8", errors="replace").decode("utf-8"))
+
+
+def _prepare_log_value(value: Any) -> Any:
+ # Runtime types replace task field annotations here. In particular, codecs for
+ # native containers (e.g. Affine) must run before msgspec sees a tuple/list.
+ codec = registry.find(type(value))
+ if codec is not None:
+ return _prepare_log_value(codec.encode(value))
+ if is_dataclass(value) and not isinstance(value, type):
+ return {
+ field.name: _prepare_log_value(getattr(value, field.name))
+ for field in fields(value)
+ if not field.metadata.get("skip_serialization", False)
+ }
+ if isinstance(value, dict):
+ return {str(key): _prepare_log_value(item) for key, item in value.items()}
+ if isinstance(value, list | tuple | set | frozenset):
+ return [_prepare_log_value(item) for item in value]
+ if isinstance(value, datetime):
+ return value.isoformat()
+ return value
+
+
+def encode_json_field(value: Any, owner_type: type[DataclassInstance], field_name: str) -> bytes:
field_type, requires_override = _encode_plan(owner_type)[field_name]
prepared = _prepare_encode(field_type, value) if requires_override else value
return _encode(prepared, value)
-def encode_json_fields(value: Any, included_fields: list[Any]) -> bytes:
+def encode_json_fields(value: DataclassInstance, included_fields: list[Any]) -> bytes:
plan = _encode_plan(type(value))
prepared = {
field.name: (
@@ -53,7 +101,7 @@ def _type_hints(field_type: type) -> dict[str, Any]:
@lru_cache
-def _encode_plan(field_type: type) -> dict[str, tuple[Any, bool]]:
+def _encode_plan(field_type: type[DataclassInstance]) -> dict[str, tuple[Any, bool]]:
type_hints = _type_hints(field_type)
return {
field.name: (
@@ -271,7 +319,7 @@ def _decode_override(field_type: Any, value: Any) -> Any: # noqa: C901, PLR0911
return msgspec.convert(value, type=field_type, dec_hook=_decode_hook, strict=True)
-def _decode_dataclass(field_type: type, value: Any) -> Any:
+def _decode_dataclass(field_type: type[DataclassInstance], value: Any) -> Any:
params = msgspec.convert(value, type=dict)
type_hints = typing.get_type_hints(field_type, include_extras=True)
known_fields = {field.name: field for field in fields(field_type)}
diff --git a/tilebox-workflows/tilebox/workflows/automations/cron.py b/tilebox-workflows/tilebox/workflows/automations/cron.py
index c466bba..63c865a 100644
--- a/tilebox-workflows/tilebox/workflows/automations/cron.py
+++ b/tilebox-workflows/tilebox/workflows/automations/cron.py
@@ -34,7 +34,7 @@ def _serialize(self) -> bytes:
return message.SerializeToString()
@classmethod
- def _deserialize(cls: "type[CronTask]", task_input: bytes, context: RunnerContext | None = None) -> Self: # noqa: ARG003
+ def _deserialize(cls, task_input: bytes, context: RunnerContext | None = None) -> Self: # noqa: ARG003
message = AutomationMessage()
message.ParseFromString(task_input)
diff --git a/tilebox-workflows/tilebox/workflows/automations/storage_event.py b/tilebox-workflows/tilebox/workflows/automations/storage_event.py
index b219fc6..c305a1c 100644
--- a/tilebox-workflows/tilebox/workflows/automations/storage_event.py
+++ b/tilebox-workflows/tilebox/workflows/automations/storage_event.py
@@ -42,7 +42,7 @@ def _serialize(self) -> bytes:
return message.SerializeToString()
@classmethod
- def _deserialize(cls: "type[StorageEventTask]", task_input: bytes, context: RunnerContext | None = None) -> Self:
+ def _deserialize(cls, task_input: bytes, context: RunnerContext | None = None) -> Self:
message = AutomationMessage()
message.ParseFromString(task_input)
diff --git a/tilebox-workflows/tilebox/workflows/cache.py b/tilebox-workflows/tilebox/workflows/cache.py
index d59a399..cdc9dd5 100644
--- a/tilebox-workflows/tilebox/workflows/cache.py
+++ b/tilebox-workflows/tilebox/workflows/cache.py
@@ -245,7 +245,7 @@ def __getitem__(self, key: str) -> bytes:
def __iter__(self) -> Iterator[str]:
if not self.root.is_dir():
# if the root directory doesn't exist or is not a directory, return an empty iterator
- return iter(())
+ return
yield from sorted([str(f.relative_to(self.root)) for f in self.root.iterdir() if f.is_file()])
diff --git a/tilebox-workflows/tilebox/workflows/client.py b/tilebox-workflows/tilebox/workflows/client.py
index 962d6f1..fb31834 100644
--- a/tilebox-workflows/tilebox/workflows/client.py
+++ b/tilebox-workflows/tilebox/workflows/client.py
@@ -1,6 +1,7 @@
import logging
import os
import warnings
+from typing import TypedDict
from uuid import UUID, uuid4
from _tilebox.grpc.channel import ConnectStubAdapter, Transport, connect_address, open_channel, parse_channel_info
@@ -14,11 +15,9 @@
from tilebox.workflows.jobs.client import JobClient
from tilebox.workflows.jobs.service import JobService
from tilebox.workflows.jobs.telemetry_service import TelemetryService
+from tilebox.workflows.observability._logging import StructuredLogger, internal_logger, task_logger
from tilebox.workflows.observability.logging import (
- OTELLoggingHandler,
- StructuredLogger,
- _create_tilebox_logger,
- _create_tilebox_logger_provider,
+ initialize_logging,
)
from tilebox.workflows.observability.tracing import WorkflowTracer
from tilebox.workflows.runner.executor import LazyStorageLocations
@@ -29,6 +28,11 @@
from tilebox.workflows.workflows.service import WorkflowService
+class _ClientAuth(TypedDict):
+ url: str
+ token: str | None
+
+
class Client:
def __init__(
self,
@@ -49,14 +53,14 @@ def __init__(
name: An optional name of the client, used as service.name for telemetry. If not set, defaults to
the service name provided by `tilebox.workflows.observability.tracing.configure_otel_tracing`,
or "tilebox-python" if no external tracer is configured.
- client_id: An optional stable id used to scope internal loggers. Defaults to a random id.
+ client_id: An optional client identifier. Logging is shared across the process.
transport: Network transport to use for API requests. Defaults to "grpc". Use "http1" to force
the Connect protocol over HTTP/1.1 for networks that do not support gRPC over HTTP/2 correctly.
"""
if url is None:
url = os.environ.get("TILEBOX_API_URL") or "https://api.tilebox.com"
token = _token_from_env(url, token)
- self._auth: dict[str, str] = {"token": token, "url": url}
+ self._auth: _ClientAuth = {"token": token, "url": url}
match transport:
case "grpc":
self._job_service = open_channel(url, token)
@@ -98,56 +102,21 @@ def __init__(
case _:
raise ValueError(f"Unsupported transport: {transport}")
- # configure logging and tracing
- self._client_id = client_id or uuid4() # a random uuid to scope loggers to this client instance
- self._logger_provider = _create_tilebox_logger_provider(service=name, url=url, token=token)
-
- # task logger is the logger available for users to emit logs from within a Task.execute method, via
- # context.logger
- self._task_logger = _create_tilebox_logger(self._client_id, scope="tasks")
- self._task_logger_handler = OTELLoggingHandler(level=logging.INFO, logger_provider=self._logger_provider)
- self._task_logger.addHandler(self._task_logger_handler)
-
- # runner logger is the logger used for logging internal events within a task runner, for example when a
- # Tilebox API call fails, or when unexpected errors occur. This logger is not exposed to users,
- # and is only used for logging internal events within the client and task runners.
- self._runner_logger = _create_tilebox_logger(self._client_id, scope="runner")
- self._runner_logger_handler = OTELLoggingHandler(level=logging.INFO, logger_provider=self._logger_provider)
- self._runner_logger.addHandler(self._runner_logger_handler)
-
+ self._client_id = client_id or uuid4()
+ self._name = name
+ self._task_logger = StructuredLogger(task_logger)
+ self._runner_logger = StructuredLogger(internal_logger)
self._tracer = WorkflowTracer(service=name, url=url, token=token)
- def configure_logging(self, level: int | logging.Logger, runner_level: int | None = None) -> None:
- """
- Configure the logger to use for logging of internal events within workflow clients.
-
- The logger will be used by all task runners created by this client.
-
- Calling this method multiple times will replace the existing logger. However, task runners
- that have already been created will not be affected by subsequent calls to this method.
-
- Args:
- logger: The logger to use for logging.
- """
- if not isinstance(level, int):
- warning_message = (
- "Configuring a logger instance directly on a client is deprecated and will be removed in a future "
- "version. If you want to export logs to an external system, configure the tilebox root logger "
- "instance, which you can get with `tilebox.workflows.observability.logging.get_logger()`."
- )
- warnings.warn(
- warning_message,
- DeprecationWarning,
- stacklevel=2,
- )
- # to preserve backwards compatibility with the old API where the first argument was a logger
- self._runner_logger = level
- else:
- # always adjust the level of the handler, not the loggers themselves, to make sure that other logger
- # handlers still receive the logs (for example, if the user configured the tilebox root logger to export
- # all logs at DEBUG level to a file)
- self._task_logger_handler.setLevel(level)
- self._runner_logger_handler.setLevel(runner_level or level)
+ def configure_logging(self, level: int | logging.Logger, runner_level: int | None = None) -> None: # noqa: ARG002
+ """Deprecated compatibility no-op. Runner logging is initialized automatically."""
+ warnings.warn(
+ "Client.configure_logging() is deprecated and no longer needed; logging is initialized automatically. "
+ "This call has no effect. Use tilebox.workflows.observability.logging.configure_log_level() "
+ "or TILEBOX_LOG_LEVEL / TILEBOX_DEBUG to change process-wide thresholds.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
def jobs(self) -> JobClient:
"""Get a client for the jobs service.
@@ -176,6 +145,9 @@ def runner(
Returns:
A task runner.
"""
+ # Stage 3: direct runners have no initialization RPC. Configure API + stdout
+ # logging here, before API calls or the lease-renewal process are started.
+ initialize_logging(**self._auth, service=self._name)
if cache is None:
cache = NoCache() # a no-op cache that will raise an error if it's used
@@ -192,8 +164,8 @@ def runner(
self._tracer,
_LeaseRenewer(**self._auth),
runner_context,
- task_logger=StructuredLogger(self._task_logger, {}),
- runner_logger=StructuredLogger(self._runner_logger, {}),
+ task_logger=self._task_logger,
+ runner_logger=self._runner_logger,
)
for task in tasks or []:
diff --git a/tilebox-workflows/tilebox/workflows/data.py b/tilebox-workflows/tilebox/workflows/data.py
index 261ff38..d809ff3 100644
--- a/tilebox-workflows/tilebox/workflows/data.py
+++ b/tilebox-workflows/tilebox/workflows/data.py
@@ -1,10 +1,10 @@
import re
import warnings
-from collections.abc import Callable
+from collections.abc import Callable, Mapping, MutableMapping
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
-from functools import lru_cache
+from functools import lru_cache, total_ordering
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast
from uuid import UUID
@@ -254,7 +254,8 @@ def to_message(self) -> core_pb2.ExecutionStats:
)
-@dataclass(order=True, frozen=True)
+@total_ordering
+@dataclass(frozen=True)
class Job:
id: UUID
name: str
@@ -264,6 +265,16 @@ class Job:
progress: list[ProgressIndicator]
execution_stats: ExecutionStats
+ def __lt__(self, other: object) -> bool:
+ if not isinstance(other, Job):
+ return NotImplemented
+ return (self.id, self.state.value, self.name, self.execution_stats.total_tasks) < (
+ other.id,
+ other.state.value,
+ other.name,
+ other.execution_stats.total_tasks,
+ )
+
@classmethod
def from_message(
cls, job: core_pb2.Job, **extra_kwargs: Any
@@ -1060,7 +1071,7 @@ class TriggeredStorageEvent:
@classmethod
def from_message(
- cls, event: automation_pb.TriggeredStorageEvent, locations: dict[UUID, StorageLocation]
+ cls, event: automation_pb.TriggeredStorageEvent, locations: Mapping[UUID, StorageLocation]
) -> "TriggeredStorageEvent":
"""Convert a TriggeredStorageEvent protobuf message to a TriggeredStorageEvent object."""
storage_location_id = uuid_message_to_uuid(event.storage_location_id)
@@ -1163,7 +1174,7 @@ def __init__(
tracer = NoopWorkflowTracer()
self.tracer = tracer
- self.storage_locations = {
+ self.storage_locations: MutableMapping[UUID, StorageLocation] = {
sl.id: sl._with_runner_context(self) # noqa: SLF001
for sl in storage_locations or []
}
diff --git a/tilebox-workflows/tilebox/workflows/formatting/job.py b/tilebox-workflows/tilebox/workflows/formatting/job.py
index cef0419..a840539 100644
--- a/tilebox-workflows/tilebox/workflows/formatting/job.py
+++ b/tilebox-workflows/tilebox/workflows/formatting/job.py
@@ -99,7 +99,7 @@ def _refresh_worker(self) -> None:
return
-@dataclass(order=True, frozen=True)
+@dataclass(frozen=True)
class RichDisplayJob(Job):
_widget: JobWidget = field(compare=False, repr=False)
diff --git a/tilebox-workflows/tilebox/workflows/jobs/client.py b/tilebox-workflows/tilebox/workflows/jobs/client.py
index e1c9bc7..f91b161 100644
--- a/tilebox-workflows/tilebox/workflows/jobs/client.py
+++ b/tilebox-workflows/tilebox/workflows/jobs/client.py
@@ -231,7 +231,7 @@ def visualize(self, job: JobIDLike, direction: str = "down", layout: str = "dagr
"""
return self._service.visualize(_to_uuid(job), direction, layout, sketchy)
- def query( # noqa: PLR0913, PLR0917
+ def query( # noqa: C901, PLR0913, PLR0917
self,
temporal_extent: "TimeIntervalLike | IDIntervalLike | None" = None,
automation_ids: UUID | list[UUID] | None = None,
@@ -288,13 +288,12 @@ def query( # noqa: PLR0913, PLR0917
start_exclusive=dataset_time_interval.start_exclusive,
end_inclusive=dataset_time_interval.end_inclusive,
)
- case IDInterval(_, _, _, _) | (UUID(), UUID()):
- id_interval = IDInterval.parse(temporal_extent)
+ case IDInterval() as interval:
+ id_interval = interval
+ case (UUID() as start_id, UUID() as end_id):
+ id_interval = IDInterval.parse((start_id, end_id))
case _:
- # ty doesn't narrow types on match statements yet, once it does we can remove this cast
- # because due to the match statement above we know that temporal_extent is a TimeIntervalLike
- time_interval_like: TimeIntervalLike = temporal_extent # ty: ignore[invalid-assignment]
- dataset_time_interval = TimeInterval.parse(time_interval_like)
+ dataset_time_interval = TimeInterval.parse(temporal_extent)
time_interval = TimeInterval(
start=dataset_time_interval.start,
end=dataset_time_interval.end,
diff --git a/tilebox-workflows/tilebox/workflows/jobs/telemetry_service.py b/tilebox-workflows/tilebox/workflows/jobs/telemetry_service.py
index e7cebd1..9dbd56e 100644
--- a/tilebox-workflows/tilebox/workflows/jobs/telemetry_service.py
+++ b/tilebox-workflows/tilebox/workflows/jobs/telemetry_service.py
@@ -1,4 +1,4 @@
-from typing import Any, cast
+from typing import Any
from uuid import UUID
from grpc import Channel
@@ -11,7 +11,6 @@
QueryJobSpansResponse,
uuid_to_uuid_message,
)
-from tilebox.workflows.workflows.v1 import telemetry_pb2
from tilebox.workflows.workflows.v1.telemetry_pb2 import (
LogQueryFilters,
PaginatedLogsData,
@@ -46,9 +45,7 @@ def query_job_logs(
job_id=uuid_to_uuid_message(job_id),
page=page.to_message() if page is not None else None,
task_id=uuid_to_uuid_message(task_id) if task_id is not None else None,
- filters=LogQueryFilters(
- severity_levels=[cast(telemetry_pb2.LogSeverityGroup, severity.value) for severity in severity_levels]
- )
+ filters=LogQueryFilters(severity_levels=[severity.value for severity in severity_levels])
if severity_levels
else None,
)
diff --git a/tilebox-workflows/tilebox/workflows/observability/_log_pipe.py b/tilebox-workflows/tilebox/workflows/observability/_log_pipe.py
new file mode 100644
index 0000000..1512ed8
--- /dev/null
+++ b/tilebox-workflows/tilebox/workflows/observability/_log_pipe.py
@@ -0,0 +1,124 @@
+"""Local structured logging for CLI-managed workflow runtimes."""
+
+import contextlib
+import logging
+import os
+import queue
+import select
+import threading
+import time
+import traceback
+from typing import Any
+
+import msgspec
+
+from tilebox.workflows.observability._logging import _record_attributes
+
+
+class _PipeWriter:
+ def __init__(self, fd: int) -> None:
+ self._fd = fd
+ self._closing = threading.Event()
+ self._submit_lock = threading.Lock()
+ self._dropped = 0
+ self._records: queue.Queue[bytes | None] = queue.Queue(maxsize=256)
+ self._thread = threading.Thread(target=self._run, name="tilebox-log-writer", daemon=True)
+ os.set_inheritable(fd, False)
+ os.set_blocking(fd, False)
+ self._thread.start()
+
+ def submit(self, record: dict[str, Any]) -> None:
+ with self._submit_lock:
+ if self._closing.is_set():
+ return
+ if self._records.full():
+ self._dropped += 1
+ return
+ data = msgspec.json.encode(record) + b"\n"
+ if len(data) > 1024 * 1024:
+ data = b'{"level":"warning","message":"Local log record exceeded 1 MiB; discarded"}\n'
+ try:
+ if self._dropped:
+ notice = {"level": "warning", "message": f"Dropped {self._dropped} local log records"}
+ self._records.put_nowait(msgspec.json.encode(notice) + b"\n")
+ self._dropped = 0
+ self._records.put_nowait(data)
+ except queue.Full:
+ self._dropped += 1
+
+ def close(self) -> None:
+ with self._submit_lock:
+ if not self._closing.is_set():
+ self._closing.set()
+ # Wake an idle writer. A full queue already keeps it awake until
+ # it drains, when the closing flag ends the loop instead.
+ with contextlib.suppress(queue.Full):
+ self._records.put_nowait(None)
+ self._thread.join(timeout=0.5)
+
+ def _run(self) -> None:
+ try:
+ self._drain()
+ finally:
+ with self._submit_lock:
+ self._closing.set()
+ # Disconnected or stalled readers must not retain queued payloads.
+ while not self._records.empty():
+ self._records.get_nowait()
+ with contextlib.suppress(OSError):
+ os.close(self._fd)
+
+ def _drain(self) -> None:
+ while not self._closing.is_set() or not self._records.empty():
+ item = self._records.get()
+ if item is None:
+ return
+ # Coalesce queued records without waiting for more. This avoids two
+ # syscalls per small message while keeping idle delivery immediate.
+ batch = [item]
+ size = len(item)
+ while size < 64 * 1024:
+ try:
+ item = self._records.get_nowait()
+ except queue.Empty:
+ break
+ if item is None:
+ break
+ batch.append(item)
+ size += len(item)
+ view = memoryview(b"".join(batch))
+ deadline = time.monotonic() + 0.25
+ while view and time.monotonic() < deadline:
+ try:
+ _, writable, _ = select.select([], [self._fd], [], 0.05)
+ if writable:
+ view = view[os.write(self._fd, view) :]
+ except (BrokenPipeError, OSError, ValueError): # noqa: PERF203 -- every write may break
+ return
+ if view:
+ # Never append another record after a partial timed-out write.
+ return
+
+
+class _StructuredHandler(logging.Handler):
+ def __init__(self, writer: _PipeWriter) -> None:
+ super().__init__(logging.NOTSET)
+ self._writer = writer
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ self._emit(record)
+ except Exception: # noqa: BLE001 -- a local logging failure must not fail a task
+ self.handleError(record)
+
+ def _emit(self, record: logging.LogRecord) -> None:
+ output: dict[str, Any] = {
+ "level": record.levelname.lower(),
+ "message": record.getMessage(),
+ }
+ if record.exc_info:
+ output["exception"] = "".join(traceback.format_exception(*record.exc_info)).rstrip("\n")
+ attributes = _record_attributes(record)
+ if attributes:
+ output["attributes"] = attributes
+ self._writer.submit(output)
diff --git a/tilebox-workflows/tilebox/workflows/observability/_logging.py b/tilebox-workflows/tilebox/workflows/observability/_logging.py
new file mode 100644
index 0000000..41cccef
--- /dev/null
+++ b/tilebox-workflows/tilebox/workflows/observability/_logging.py
@@ -0,0 +1,107 @@
+"""Lightweight structured logging; independent of exporter and runner initialization."""
+
+import logging
+import os
+from typing import Any
+
+_WORKFLOW_LOG_ATTRIBUTES = "tilebox_structured_log_attributes"
+_LOGGING_NAMESPACE = "tilebox.workflows"
+
+
+class _NormalizedAttributes(dict[str, Any]):
+ """JSON-compatible values already normalized for all handlers on a record."""
+
+
+def _normalize_attributes(attributes: dict[str, Any]) -> _NormalizedAttributes:
+ from tilebox.workflows._serialization import normalize_log_value # noqa: PLC0415 -- keep imports lightweight
+
+ return _NormalizedAttributes({key: normalize_log_value(value) for key, value in attributes.items()})
+
+
+def _record_attributes(record: logging.LogRecord) -> _NormalizedAttributes:
+ attributes = getattr(record, _WORKFLOW_LOG_ATTRIBUTES, None)
+ if not isinstance(attributes, _NormalizedAttributes):
+ # Plain stdlib records bypass the facade. Normalize on the first handler,
+ # then share the result without modifying the caller's original dictionary.
+ attributes = _normalize_attributes(attributes if isinstance(attributes, dict) else _current_span_attributes())
+ setattr(record, _WORKFLOW_LOG_ATTRIBUTES, attributes)
+ return attributes
+
+
+def internal_log_level() -> int:
+ enabled = os.environ.get("TILEBOX_DEBUG", "").strip().lower() in {"1", "true", "yes", "on"}
+ return logging.DEBUG if enabled else logging.ERROR
+
+
+def _current_span_attributes() -> dict[str, str]:
+ try:
+ from opentelemetry.trace import get_current_span # noqa: PLC0415
+ except ImportError:
+ return {}
+
+ span_context = get_current_span().get_span_context()
+ if not span_context.is_valid:
+ return {}
+ return {"trace_id": f"{span_context.trace_id:032x}", "span_id": f"{span_context.span_id:016x}"}
+
+
+class StructuredLogger:
+ """Structured records backed by stdlib logging, with optional current-span attributes."""
+
+ def __init__(self, logger: logging.Logger, attributes: dict[str, Any] | None = None) -> None:
+ self._logger = logger
+ self._attributes = attributes or {}
+
+ def bind(self, **attributes: Any) -> "StructuredLogger":
+ return StructuredLogger(self._logger, self._attributes | attributes)
+
+ def log(self, level: int, message: object, /, *args: Any, **attributes: Any) -> None:
+ self._log(level, message, args, attributes, exc_info=False)
+
+ def debug(self, message: object, /, *args: Any, **attributes: Any) -> None:
+ self._log(logging.DEBUG, message, args, attributes, exc_info=False)
+
+ def info(self, message: object, /, *args: Any, **attributes: Any) -> None:
+ self._log(logging.INFO, message, args, attributes, exc_info=False)
+
+ def warning(self, message: object, /, *args: Any, **attributes: Any) -> None:
+ self._log(logging.WARNING, message, args, attributes, exc_info=False)
+
+ def error(self, message: object, /, *args: Any, **attributes: Any) -> None:
+ self._log(logging.ERROR, message, args, attributes, exc_info=False)
+
+ def exception(self, message: object, /, *args: Any, **attributes: Any) -> None:
+ self._log(logging.ERROR, message, args, attributes, exc_info=True)
+
+ def critical(self, message: object, /, *args: Any, **attributes: Any) -> None:
+ self._log(logging.CRITICAL, message, args, attributes, exc_info=False)
+
+ def _log(
+ self, level: int, message: object, args: tuple[Any, ...], attributes: dict[str, Any], *, exc_info: bool
+ ) -> None:
+ if not self._logger.isEnabledFor(level):
+ return
+ attributes = _normalize_attributes({**self._attributes, **attributes, **_current_span_attributes()})
+ self._logger.log(
+ level,
+ message,
+ *args,
+ exc_info=exc_info,
+ extra={_WORKFLOW_LOG_ATTRIBUTES: attributes},
+ stacklevel=3,
+ )
+
+
+root_logger = logging.getLogger(_LOGGING_NAMESPACE)
+root_logger.setLevel(logging.DEBUG)
+# User task messages emitted through context.logger. Defaults to INFO; configure
+# with TILEBOX_LOG_LEVEL, `tilebox runner start --log-level debug`, or
+# observability.logging.configure_log_level(logging.DEBUG) in direct Python usage.
+task_logger = logging.getLogger(f"{_LOGGING_NAMESPACE}.tasks")
+task_logger.setLevel(getattr(logging, os.environ.get("TILEBOX_LOG_LEVEL", "INFO").strip().upper(), logging.INFO))
+# SDK/runner diagnostics. Defaults to ERROR; TILEBOX_DEBUG=true enables DEBUG
+# (also with the CLI: `TILEBOX_DEBUG=true tilebox runner start`). Direct Python
+# callers can override this with observability.logging.configure_log_level(..., tilebox_debug=True).
+internal_logger = logging.getLogger(f"{_LOGGING_NAMESPACE}.internal")
+internal_logger.setLevel(internal_log_level())
+logger = StructuredLogger(internal_logger)
diff --git a/tilebox-workflows/tilebox/workflows/observability/logging.py b/tilebox-workflows/tilebox/workflows/observability/logging.py
index 7d3e041..c5bc2f1 100644
--- a/tilebox-workflows/tilebox/workflows/observability/logging.py
+++ b/tilebox-workflows/tilebox/workflows/observability/logging.py
@@ -1,17 +1,19 @@
# allow the logging module name which shadows the builtin:
+import atexit
import contextlib
import logging
import os
import platform
import re
import sys
+import threading
import traceback
-from datetime import datetime, timedelta
-from functools import lru_cache
+from datetime import timedelta
from importlib.metadata import PackageNotFoundError, version
from typing import Any, ClassVar, TextIO
from uuid import UUID, uuid4
+import msgspec
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
DEFAULT_LOGS_EXPORT_PATH,
OTLPLogExporter,
@@ -32,12 +34,22 @@
Resource,
)
from opentelemetry.semconv.attributes import exception_attributes
-from opentelemetry.trace import get_current_span
from opentelemetry.util.types import _ExtendedAttributes
+from tilebox.workflows._serialization import normalize_log_value
+from tilebox.workflows.observability._log_pipe import _PipeWriter, _StructuredHandler
+from tilebox.workflows.observability._logging import (
+ StructuredLogger as StructuredLogger, # noqa: PLC0414 -- public compatibility alias
+)
+from tilebox.workflows.observability._logging import (
+ _record_attributes,
+ internal_logger,
+ root_logger,
+ task_logger,
+)
+
# prefix for stdlib loggers
_DEFAULT_SERVICE_NAME = "tilebox-python"
-_LOGGING_NAMESPACE = "tilebox.workflows"
_AXIOM_ENDPOINT = "https://api.axiom.co/v1/logs"
_AXIOM_LOGS_DATASET_ENV_VAR = "AXIOM_LOGS_DATASET"
@@ -46,10 +58,9 @@
_OTEL_LOGS_ENDPOINT_ENV_VAR = "OTEL_LOGS_ENDPOINT"
_OTEL_EXPORT_INTERVAL_ENV_VAR = "OTEL_EXPORT_INTERVAL"
-_WORKFLOW_LOG_ATTRIBUTES = "tilebox_structured_log_attributes"
-
-# process-unique identifier to distinguish different instances of the same service running on the same host
-_instance_id = str(uuid4())
+# Use the CLI's runtime identity, or generate one per process for direct runners.
+_instance_id = os.environ.get("TILEBOX_RUNTIME_ID") or str(uuid4())
+_instance_id = str(UUID(_instance_id))
def _get_default_resource(service: str | Resource | None = None) -> Resource:
@@ -81,60 +92,30 @@ def _get_default_resource(service: str | Resource | None = None) -> Resource:
)
-@lru_cache
-def _root_logger() -> logging.Logger:
- root_logger = logging.getLogger(_LOGGING_NAMESPACE)
- # our root logger needs DEBUG level, otherwise it would always automatically
- # discard all DEBUG messages and never forward them to any handler, even if they
- # have a DEBUG level set
- root_logger.setLevel(logging.DEBUG)
- return root_logger
-
-
-def _current_span_attributes() -> dict[str, str]:
- span_context = get_current_span().get_span_context()
- if not span_context.is_valid:
- return {}
-
- return {
- "trace_id": f"{span_context.trace_id:032x}",
- "span_id": f"{span_context.span_id:016x}",
- }
-
-
def _sanitize_otel_attribute_value(
value: Any,
) -> str | bool | int | float | bytes | list[str | bool | int | float | bytes]:
if isinstance(value, str | bool | int | float | bytes):
return value
- if isinstance(value, datetime):
- return value.isoformat()
-
- if isinstance(value, tuple | list):
- values = []
- for item in value:
- if isinstance(item, str | bool | int | float | bytes):
- values.append(item)
- else:
- values.append(_sanitize_otel_attribute_value(item))
- return values
-
- return str(value)
+ if isinstance(value, list):
+ return [
+ item if isinstance(item, str | bool | int | float) else msgspec.json.encode(item).decode() for item in value
+ ]
+ # OTEL span attributes cannot contain mappings; retain their JSON representation.
+ return msgspec.json.encode(value).decode()
-def _sanitize_otel_attributes(attributes: dict[str, Any]) -> _ExtendedAttributes:
+def _sanitize_otel_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
return {str(key): _sanitize_otel_attribute_value(value) for key, value in attributes.items()}
class OTELLoggingHandler(LoggingHandler):
def _get_attributes(self, record: logging.LogRecord) -> _ExtendedAttributes:
- attributes: dict[str, Any] = {}
- attributes.update(_current_span_attributes())
-
- workflow_attributes = getattr(record, _WORKFLOW_LOG_ATTRIBUTES, None)
- if isinstance(workflow_attributes, dict):
- attributes.update(workflow_attributes)
+ cached = getattr(record, "_tilebox_otel_attributes", None)
+ if cached is not None:
+ return cached
+ attributes = _sanitize_otel_attributes(_record_attributes(record))
# the default implementation returns attributes for the filepath, lineno and function of the log record
# we don't want that by default, so we override it to return an empty dict
@@ -143,93 +124,103 @@ def _get_attributes(self, record: logging.LogRecord) -> _ExtendedAttributes:
if exctype is not None:
attributes[exception_attributes.EXCEPTION_TYPE] = exctype.__name__
if value is not None and value.args:
- attributes[exception_attributes.EXCEPTION_MESSAGE] = value.args[0]
+ attributes[exception_attributes.EXCEPTION_MESSAGE] = _sanitize_otel_attribute_value(
+ normalize_log_value(value.args[0])
+ )
if tb is not None:
# https://github.com/open-telemetry/opentelemetry-specification/blob/9fa7c656b26647b27e485a6af7e38dc716eba98a/specification/trace/semantic_conventions/exceptions.md#stacktrace-representation
attributes[exception_attributes.EXCEPTION_STACKTRACE] = "".join(
traceback.format_exception(*record.exc_info)
)
- return _sanitize_otel_attributes(attributes)
-
-
-class StructuredLogger:
- """A small structured logging wrapper for logs emitted during task execution."""
-
- def __init__(self, logger: logging.Logger, attributes: dict[str, Any] | None = None) -> None:
- self._logger = logger
- self._attributes = attributes or {}
-
- def bind(self, **attributes: Any) -> "StructuredLogger":
- """Return a new logger that includes the given attributes in every log record."""
- return StructuredLogger(self._logger, self._attributes | attributes)
-
- def log(self, level: int, message: object, /, *args: Any, **attributes: Any) -> None:
- """Log a message with structured attributes."""
- self._log(level, message, args, attributes, exc_info=False)
-
- def debug(self, message: object, /, *args: Any, **attributes: Any) -> None:
- """Log a debug message with structured attributes."""
- self._log(logging.DEBUG, message, args, attributes, exc_info=False)
-
- def info(self, message: object, /, *args: Any, **attributes: Any) -> None:
- """Log an info message with structured attributes."""
- self._log(logging.INFO, message, args, attributes, exc_info=False)
-
- def warning(self, message: object, /, *args: Any, **attributes: Any) -> None:
- """Log a warning message with structured attributes."""
- self._log(logging.WARNING, message, args, attributes, exc_info=False)
-
- def error(self, message: object, /, *args: Any, **attributes: Any) -> None:
- """Log an error message with structured attributes."""
- self._log(logging.ERROR, message, args, attributes, exc_info=False)
-
- def exception(self, message: object, /, *args: Any, **attributes: Any) -> None:
- """Log an error message with structured attributes and the current exception information."""
- self._log(logging.ERROR, message, args, attributes, exc_info=True)
-
- def critical(self, message: object, /, *args: Any, **attributes: Any) -> None:
- """Log a critical message with structured attributes."""
- self._log(logging.CRITICAL, message, args, attributes, exc_info=False)
-
- def _log(
- self,
- level: int,
- message: object,
- args: tuple[Any, ...],
- attributes: dict[str, Any],
- *,
- exc_info: bool,
- ) -> None:
- if not self._logger.isEnabledFor(level):
- return
+ record._tilebox_otel_attributes = attributes # noqa: SLF001 -- shared across OTEL handlers for this record
+ return attributes
- workflow_attributes = self._attributes | attributes | _current_span_attributes()
- self._logger.log(
- level,
- message,
- *args,
- exc_info=exc_info,
- extra={_WORKFLOW_LOG_ATTRIBUTES: workflow_attributes},
- stacklevel=3,
- )
+_api_handler: OTELLoggingHandler | None = None
+_initialization_lock = threading.Lock()
+_writer: _PipeWriter | None = None
+_console_handlers: list[logging.Handler] = []
+_console_configured = False
-@lru_cache(maxsize=16) # reuse logger providers for the same credentials if possible
-def _create_tilebox_logger_provider(service: str | None, url: str, token: str | None) -> LoggerProvider:
- provider = LoggerProvider(resource=_get_default_resource(service))
- batch_exporter = _otel_log_exporter(
- endpoint=url,
- headers={"Authorization": f"Bearer {token}"} if token is not None else None,
- )
- provider.add_log_record_processor(batch_exporter)
- return provider
+def _remove_console_handlers() -> None:
+ for handler in _console_handlers:
+ root_logger.removeHandler(handler)
+ handler.close()
+ _console_handlers.clear()
-def _create_tilebox_logger(client_id: UUID, scope: str) -> logging.Logger:
- logger = logging.getLogger(f"{_LOGGING_NAMESPACE}.clients.{client_id}.{scope}")
- logger.setLevel(logging.DEBUG) # always debug, so that other handlers can still filter by that level
- logger.propagate = True
- return logger
+
+def _add_console_handler(level: int, stream: TextIO, formatter: logging.Formatter) -> None:
+ handler = logging.StreamHandler(stream)
+ handler.setLevel(level)
+ handler.setFormatter(formatter)
+ root_logger.addHandler(handler)
+ _console_handlers.append(handler)
+
+
+def _configure_runtime_logging() -> _PipeWriter | None:
+ """Install the required CLI pipe, or an optional default console for direct runners.
+
+ The CLI pipe is runtime-owned: public configuration can add outputs but cannot
+ disable or replace it. Console opt-out applies only to console handlers.
+ """
+ global _writer # noqa: PLW0603 -- process-owned CLI pipe
+ with _initialization_lock:
+ fd_value = os.environ.get("TILEBOX_LOG_FD")
+ if fd_value is not None:
+ if _writer is None:
+ _writer = _PipeWriter(int(fd_value))
+ root_logger.addHandler(_StructuredHandler(_writer))
+ atexit.register(_writer.close)
+ if not _console_configured:
+ _remove_console_handlers()
+ elif not _console_configured and not _console_handlers:
+ _add_console_handler(
+ logging.NOTSET, sys.stdout, logging.Formatter("%(process)d: %(levelname)s: %(message)s")
+ )
+ return _writer
+
+
+def configure_log_level(level: int = logging.INFO, *, tilebox_debug: bool = False) -> None:
+ """Override process-wide task and Tilebox diagnostic levels without changing outputs.
+
+ Startup uses TILEBOX_LOG_LEVEL (INFO by default) and TILEBOX_DEBUG (false by default),
+ normally supplied by the CLI. This override is useful for direct Python runners or
+ notebooks, for example to enable task DEBUG messages without changing environment
+ variables. Lowering a handler's threshold alone cannot recover messages already
+ filtered out by the logger.
+
+ Every call overrides both startup settings: omitting tilebox_debug disables internal
+ DEBUG logging even if TILEBOX_DEBUG was enabled. Existing API exports, the CLI pipe,
+ and console handlers are unchanged and retain their own output thresholds.
+
+ Args:
+ level: Task log threshold, including context.logger. Defaults to logging.INFO.
+ tilebox_debug: Enable internal Tilebox diagnostics at DEBUG when True; otherwise
+ use ERROR. Defaults to False, independently of the task log level.
+ """
+ task_logger.setLevel(level)
+ internal_logger.setLevel(logging.DEBUG if tilebox_debug else logging.ERROR)
+
+
+def initialize_logging(url: str, token: str | None, service: str | None = None) -> None:
+ """Initialize process-wide API and local logging once; the first destination wins.
+
+ Both loggers propagate to one API handler at NOTSET. Thresholds belong to the
+ loggers, so every accepted record is exported regardless of its source.
+ """
+ global _api_handler # noqa: PLW0603 -- process-wide initialization shared by all startup paths
+ _configure_runtime_logging()
+ with _initialization_lock:
+ if _api_handler is not None:
+ return
+
+ provider = LoggerProvider(resource=_get_default_resource(service))
+ processor = _otel_log_exporter(endpoint=url, headers={"Authorization": f"Bearer {token}"} if token else None)
+ provider.add_log_record_processor(processor)
+ handler = OTELLoggingHandler(level=logging.NOTSET, logger_provider=provider)
+ root_logger.addHandler(handler)
+ _api_handler = handler
def _otel_log_exporter(
@@ -275,6 +266,7 @@ def configure_otel_logging(
This will configure a logging handler that will send log messages to an OTLP compatible endpoint using the
open telemetry protocol for exporting logs. The logging handler will be attached to the root tilebox logger.
All loggers created using `get_logger()` will therefore inherit this handler configuration.
+ Each call adds an export; Tilebox's API export, the CLI pipe, and console outputs remain installed.
Args:
service: A string or a resource object to include in all traces. Used to identify the service being traced.
@@ -304,15 +296,6 @@ def configure_otel_logging(
provider.add_log_record_processor(batch_exporter)
handler = OTELLoggingHandler(level=level, logger_provider=provider)
- root_logger = _root_logger()
-
- # clean up the default handler if it exists
- handlers_to_remove_indices = [
- i for i, handler in enumerate(root_logger.handlers) if hasattr(handler, "_is_default")
- ]
- for i in reversed(handlers_to_remove_indices): # reversed to avoid index shifting after deletion
- root_logger.handlers.pop(i)
-
root_logger.addHandler(handler)
@@ -397,7 +380,7 @@ def format(self, record: logging.LogRecord) -> str:
def configure_console_logging(
- level: int = logging.INFO, stream: TextIO | None = None, reconfigure: bool = True
+ level: int = logging.INFO, stream: TextIO | None = None, reconfigure: bool = True, *, enabled: bool = True
) -> None:
"""
Configure logging to the console (stdout).
@@ -413,32 +396,16 @@ def configure_console_logging(
reconfigure: Only relevant if configure_console_logging is called multiple times. If True, any previously
configured console logging handlers will be removed. If False, the existing handlers will be kept. Useful
if you want to log to multiple consoles.
+ enabled: If False, remove Tilebox-managed console handlers and disable automatic console output, even if
+ called before runner startup. API exports, the CLI log pipe, and user-installed handlers are unaffected.
"""
- if stream is None:
- stream = sys.stdout
-
- handler = logging.StreamHandler(stream)
- handler.setLevel(level)
- handler.setFormatter(ColorfulConsoleFormatter())
-
- root_logger = _root_logger()
-
- # clean up previous handlers:
- # remove the default handler if it exists, and all other ConsoleHandlers if reconfigure is True
- handlers_to_remove_indices = [
- i
- for i, handler in enumerate(root_logger.handlers)
- if hasattr(handler, "_is_default")
- or (
- reconfigure
- and isinstance(handler, logging.StreamHandler)
- and isinstance(handler.formatter, ColorfulConsoleFormatter)
- )
- ]
- for i in reversed(handlers_to_remove_indices): # reversed to avoid index shifting after deletion
- root_logger.handlers.pop(i)
-
- root_logger.addHandler(handler)
+ global _console_configured # noqa: PLW0603 -- explicit process-wide console policy
+ with _initialization_lock:
+ if reconfigure or not enabled or not _console_configured:
+ _remove_console_handlers()
+ _console_configured = True
+ if enabled:
+ _add_console_handler(level, sys.stdout if stream is None else stream, ColorfulConsoleFormatter())
def get_logger(name: str | None = None, level: int = logging.NOTSET) -> logging.Logger:
@@ -456,8 +423,7 @@ def get_logger(name: str | None = None, level: int = logging.NOTSET) -> logging.
explicitly specifying a name.
level: The logging level to use for the logger. Only log messages with a level higher or equal to this will be
sent to the logger. Only log messages with a level higher or equal to this will be sent by the logger to
- configured handlers. Defaults to logging.NOTSET, which effectively means all messages will be forwarded
- to the handlers.
+ configured handlers. Defaults to logging.NOTSET, inheriting the task logger's threshold (INFO by default).
Returns:
A logger capable of logging messages that will be sent to the configured handlers.
@@ -465,18 +431,10 @@ def get_logger(name: str | None = None, level: int = logging.NOTSET) -> logging.
if name is None:
name = f"unnamed_logger_{uuid4()}"
- root_logger = _root_logger()
if not root_logger.hasHandlers():
- # no handlers are configured, so we add a standard console handler
- handler = logging.StreamHandler(sys.stdout)
- handler.setLevel(level)
- handler.setFormatter(ColorfulConsoleFormatter())
- # we set a special attribute, which allows as to remove this handler again as soon
- # as we configure an actual logging handler
- handler._is_default = True # ty: ignore[unresolved-attribute] # noqa: SLF001
- root_logger.addHandler(handler)
+ _configure_runtime_logging()
- logger = logging.getLogger(f"{_LOGGING_NAMESPACE}.{name}")
+ logger = logging.getLogger(f"{task_logger.name}.{name}")
logger.setLevel(level)
return logger
diff --git a/tilebox-workflows/tilebox/workflows/observability/tracing.py b/tilebox-workflows/tilebox/workflows/observability/tracing.py
index a7da1f2..0a11510 100644
--- a/tilebox-workflows/tilebox/workflows/observability/tracing.py
+++ b/tilebox-workflows/tilebox/workflows/observability/tracing.py
@@ -18,10 +18,12 @@
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.util.types import Attributes
-from tilebox.workflows.observability.logging import (
- _LOGGING_NAMESPACE,
- _WORKFLOW_LOG_ATTRIBUTES,
+from tilebox.workflows.observability._logging import (
_current_span_attributes,
+ _record_attributes,
+ root_logger,
+)
+from tilebox.workflows.observability.logging import (
_get_default_resource,
_parse_duration,
_sanitize_otel_attributes,
@@ -70,7 +72,8 @@ def _copy_configured_span_processors(source: TracerProvider, destination: Tracer
class Job(Protocol):
- trace_parent: str
+ @property
+ def trace_parent(self) -> str: ...
class WorkflowTracer:
@@ -185,10 +188,7 @@ def emit(self, record: logging.LogRecord) -> None:
created_time = datetime.fromtimestamp(record.created, tz=timezone.utc)
# add the log message as a span event
- workflow_attributes = getattr(record, _WORKFLOW_LOG_ATTRIBUTES, {})
- if not isinstance(workflow_attributes, dict):
- workflow_attributes = {}
- workflow_attributes = _current_span_attributes() | workflow_attributes
+ workflow_attributes = _current_span_attributes() | _record_attributes(record)
attributes = cast(
Attributes,
@@ -206,8 +206,6 @@ def emit(self, record: logging.LogRecord) -> None:
def _ensure_span_event_logging_handler() -> None:
- root_logger = logging.getLogger(_LOGGING_NAMESPACE)
-
has_span_event_handler = False # in case this is called multiple times, still only one handler
for handler in root_logger.handlers:
if isinstance(handler, SpanEventLoggingHandler):
@@ -229,6 +227,7 @@ def configure_otel_tracing(
This will configure a global opentelemetry tracer provider that can be used to instantiate tracers that will
send traces and spans to a specified endpoint using the open telemetry protocol for exporting traces and spans.
+ Each call adds an export, including for existing workflow tracers; Tilebox's API export remains installed.
Additionally, this will also configure a logging handler that will add log messages to active spans as span events.
diff --git a/tilebox-workflows/tilebox/workflows/runner/__main__.py b/tilebox-workflows/tilebox/workflows/runner/__main__.py
index 7f4910c..5ab0e82 100644
--- a/tilebox-workflows/tilebox/workflows/runner/__main__.py
+++ b/tilebox-workflows/tilebox/workflows/runner/__main__.py
@@ -1,20 +1,15 @@
import argparse
import importlib
-import os
-import sys
from collections.abc import Sequence
from time import perf_counter
from typing import Any
-from loguru import logger
-
+from tilebox.workflows.observability._logging import logger
from tilebox.workflows.runner.runner import Runner
from tilebox.workflows.runner.worker_server import serve_runner
def main(argv: Sequence[str] | None = None) -> int:
- _configure_logging()
-
parser = argparse.ArgumentParser(
prog="python -m tilebox.workflows.runner",
description="Start a Tilebox workflow worker runtime.",
@@ -26,23 +21,9 @@ def main(argv: Sequence[str] | None = None) -> int:
runner = _import_runner(args.runner)
logger.debug(f"Imported runner {args.runner!r}; starting worker server")
serve_runner(runner)
- logger.debug("Worker server stopped")
return 0
-def _configure_logging() -> None:
- level = "DEBUG" if _is_debug_enabled() else "INFO"
- logger.remove()
- logger.add(sys.stderr, level=level, format="{process}: {level}: {message}", catch=True)
-
-
-def _is_debug_enabled() -> bool:
- value = os.environ.get("TILEBOX_DEBUG")
- if value is None:
- return False
- return value.strip().lower() in {"", "1", "true", "yes", "on"}
-
-
def _import_runner(import_path: str) -> Runner:
module_name, separator, object_path = import_path.partition(":")
if not module_name or not separator or not object_path:
diff --git a/tilebox-workflows/tilebox/workflows/runner/executor.py b/tilebox-workflows/tilebox/workflows/runner/executor.py
index 627d65b..7ef8a7e 100644
--- a/tilebox-workflows/tilebox/workflows/runner/executor.py
+++ b/tilebox-workflows/tilebox/workflows/runner/executor.py
@@ -23,7 +23,7 @@
StorageLocation,
Task,
)
-from tilebox.workflows.observability.logging import StructuredLogger
+from tilebox.workflows.observability._logging import StructuredLogger
from tilebox.workflows.observability.tracing import NoopWorkflowTracer, WorkflowTracer, start_job_span
from tilebox.workflows.runner.runner import Runner
from tilebox.workflows.task import (
diff --git a/tilebox-workflows/tilebox/workflows/runner/task_runner.py b/tilebox-workflows/tilebox/workflows/runner/task_runner.py
index 6f5894c..62fbc09 100644
--- a/tilebox-workflows/tilebox/workflows/runner/task_runner.py
+++ b/tilebox-workflows/tilebox/workflows/runner/task_runner.py
@@ -6,7 +6,8 @@
from datetime import timedelta
from multiprocessing import get_context
from multiprocessing.context import SpawnProcess
-from queue import Empty, Queue
+from multiprocessing.queues import Queue
+from queue import Empty
from threading import Event
from time import sleep
from types import FrameType, TracebackType
@@ -18,7 +19,6 @@
except ImportError: # Self is only available in Python 3.11+
from typing_extensions import Self
-from loguru import logger
from tenacity import retry, retry_if_exception_type, stop_when_event_set, wait_random_exponential
from tenacity.stop import stop_base
@@ -26,7 +26,8 @@
from _tilebox.grpc.error import InternalServerError
from tilebox.workflows.cache import JobCache
from tilebox.workflows.data import ComputedTask, FailedTask, Idling, NextTaskToRun, Task, TaskLease
-from tilebox.workflows.observability.logging import StructuredLogger
+from tilebox.workflows.observability._logging import StructuredLogger, logger
+from tilebox.workflows.observability.logging import initialize_logging
from tilebox.workflows.observability.tracing import WorkflowTracer
from tilebox.workflows.runner.executor import ExecutionContext, TaskExecutor
from tilebox.workflows.runner.runner import Runner
@@ -80,8 +81,11 @@ def _retry_backoff(func: Callable[..., WrappedFnReturnT], stop: stop_base) -> Ca
def lease_renewer(
- url: str, token: str | None, new_leases: Queue[tuple[UUID, TaskLease]], done_tasks: Queue[UUID]
+ url: str, token: str | None, new_leases: "Queue[tuple[UUID, TaskLease]]", done_tasks: "Queue[UUID]"
) -> None:
+ # The direct runner's spawned lease-renewal process needs its own stage-3 setup;
+ # it cannot inherit the parent's logging handlers or exporter thread.
+ initialize_logging(url=url, token=token)
channel = open_channel(url, token)
service = TaskService(channel)
@@ -95,7 +99,7 @@ def _extend_lease_while_task_is_running(
service: TaskService,
task_id: UUID,
task_lease: TaskLease,
- done_tasks: Queue[UUID],
+ done_tasks: "Queue[UUID]",
) -> UUID | None:
while True:
try:
diff --git a/tilebox-workflows/tilebox/workflows/runner/worker_server.py b/tilebox-workflows/tilebox/workflows/runner/worker_server.py
index 2d54959..4cb86b8 100644
--- a/tilebox-workflows/tilebox/workflows/runner/worker_server.py
+++ b/tilebox-workflows/tilebox/workflows/runner/worker_server.py
@@ -4,8 +4,8 @@
from pathlib import Path
import grpc
-from loguru import logger
+from tilebox.workflows.observability._logging import logger
from tilebox.workflows.runner.runner import Runner
from tilebox.workflows.runner.worker_service import WorkerServiceServicer
from tilebox.workflows.workflows.v1 import worker_pb2_grpc
diff --git a/tilebox-workflows/tilebox/workflows/runner/worker_service.py b/tilebox-workflows/tilebox/workflows/runner/worker_service.py
index b5cad6b..502b230 100644
--- a/tilebox-workflows/tilebox/workflows/runner/worker_service.py
+++ b/tilebox-workflows/tilebox/workflows/runner/worker_service.py
@@ -2,13 +2,13 @@
import grpc
from google.protobuf.empty_pb2 import Empty
-from loguru import logger
from tilebox.datasets.uuid import uuid_message_to_uuid
from tilebox.workflows.cache import NoCache
from tilebox.workflows.client import Client
from tilebox.workflows.data import Cluster, ComputedTask, FailedTask, Task
-from tilebox.workflows.observability.logging import StructuredLogger
+from tilebox.workflows.observability._logging import logger
+from tilebox.workflows.observability.logging import initialize_logging
from tilebox.workflows.runner.executor import LazyStorageLocations, TaskExecutor
from tilebox.workflows.runner.runner import Runner
from tilebox.workflows.task import RunnerContext
@@ -34,19 +34,25 @@ def ListRegisteredTasks(self, request: Empty, context: grpc.ServicerContext) ->
def InitializeWorker( # noqa: N802
self,
request: worker_pb2.InitializeRunnerRequest,
- context: grpc.ServicerContext, # noqa: ARG002
+ context: grpc.ServicerContext,
) -> worker_pb2.InitializeRunnerResponse:
logger.debug("InitializeWorker RPC called")
runner_id = uuid_message_to_uuid(request.runner_id)
+ if self._executor is not None:
+ context.abort(grpc.StatusCode.FAILED_PRECONDITION, "Worker is already initialized")
cluster = Cluster.from_message(request.cluster) if request.HasField("cluster") else None
api_connection = request.api_connection if request.HasField("api_connection") else None
- api_url = api_connection.url if api_connection and api_connection.url else "https://api.tilebox.com"
+ api_url = api_connection.url if api_connection and api_connection.url else None
api_token = api_connection.token if api_connection and api_connection.token else None
client = Client(url=api_url, token=api_token, client_id=runner_id)
tracer = client._tracer # noqa: SLF001
- task_logger = StructuredLogger(client._task_logger, {}) # noqa: SLF001
+ task_logger = client._task_logger # noqa: SLF001
+
+ # Stage 2: older CLIs supply API credentials in InitializeWorker rather than
+ # startup environment variables. This is a no-op if stage 1 already ran.
+ initialize_logging(**client._auth) # noqa: SLF001
context_type = self._runner.context or RunnerContext
runner_context = context_type(tracer)
diff --git a/tilebox-workflows/tilebox/workflows/task.py b/tilebox-workflows/tilebox/workflows/task.py
index cc84e2f..3ad9734 100644
--- a/tilebox-workflows/tilebox/workflows/task.py
+++ b/tilebox-workflows/tilebox/workflows/task.py
@@ -4,7 +4,7 @@
from collections import defaultdict
from collections.abc import Awaitable, Sequence
from contextlib import suppress
-from dataclasses import dataclass, fields, is_dataclass
+from dataclasses import Field, dataclass, field, fields, is_dataclass
from types import NoneType, UnionType
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast, get_args, get_origin
from uuid import UUID
@@ -17,7 +17,7 @@
if TYPE_CHECKING:
from tilebox.workflows.cache import JobCache
- from tilebox.workflows.observability.logging import StructuredLogger
+ from tilebox.workflows.observability._logging import StructuredLogger
from tilebox.workflows.observability.tracing import WorkflowTracer
else:
StructuredLogger = Any
@@ -95,9 +95,9 @@ class _ABCTaskify(ABCMeta, _Taskify): # the order here is actually relevant: AB
# This is a neat typing feature: If dataclass_transform is applied to a class, dataclass-like semantics will be
# assumed for any class that directly or indirectly derives from the decorated class or uses the decorated class
-# as a metaclass. Attributes on the decorated class and its base classes are not considered to be fields.
-# See https://peps.python.org/pep-0681/
-@dataclass_transform()
+# as a metaclass. See https://peps.python.org/pep-0681/
+@dataclass_transform(field_specifiers=(Field, field))
+@dataclass
class Task(metaclass=_ABCTaskify):
"""A Tilebox workflows task.
@@ -488,10 +488,10 @@ def serialize_task(task: Task) -> bytes:
return encode_json_fields(task, task_fields)
-_T = TypeVar("_T", bound=Task)
+_TaskT = TypeVar("_TaskT", bound=Task)
-def deserialize_task(task_cls: type[_T], task_input: bytes) -> _T:
+def deserialize_task(task_cls: type[_TaskT], task_input: bytes) -> _TaskT:
"""Deserialize the input of a task from a buffer of bytes.
The task_cls is expected to be a dataclass, containing an arbitrary number of fields.
diff --git a/uv.lock b/uv.lock
index ca66092..5ebbb65 100644
--- a/uv.lock
+++ b/uv.lock
@@ -168,30 +168,30 @@ wheels = [
[[package]]
name = "boto3"
-version = "1.43.93"
+version = "1.43.95"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c2/37/7a09d8320685b3b8c8a014e392e7595025d00965c46c5f410797905fab7a/boto3-1.43.93.tar.gz", hash = "sha256:196bfc8b4c9cd5505f9f7b963e30956db3a00fd47e20dd0ee3574a243c1fb212", size = 112752, upload-time = "2026-09-11T19:23:03.484Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/ed/9d4d4e7d4b874c16f7b3a886efe3e45801e0f9ebb60058adf1bb69cb2073/boto3-1.43.95.tar.gz", hash = "sha256:9d71f299111e1f4e8c28f573a1b7c0555fe40d2147fe9bef852a02bd57cbde60", size = 112666, upload-time = "2026-09-15T19:23:49.395Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ea/68/f8f661b9e68daba4f775bfa1e750732ec52fc89b32d55f300aef530e1d62/boto3-1.43.93-py3-none-any.whl", hash = "sha256:3c948fe231490d446bf90bf3322d1452632107329d3683b37d88b7399bf481a0", size = 140022, upload-time = "2026-09-11T19:23:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/66/6e/9c6fb58b1cdddf13b3e9edc94e0c6e08fff456355f6e097684d6117aa8d4/boto3-1.43.95-py3-none-any.whl", hash = "sha256:c906921c4f9ab41e9587af6586f072c8f2be6979e8bde2ec26aa443bb1745019", size = 140026, upload-time = "2026-09-15T19:23:47.563Z" },
]
[[package]]
name = "boto3-stubs"
-version = "1.43.93"
+version = "1.43.95"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore-stubs" },
{ name = "types-s3transfer" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/38/9b/a4f6ec6883c025ed59c7102662a5faf89b1f07f2fd0b5266f3c8dec9c92d/boto3_stubs-1.43.93.tar.gz", hash = "sha256:7b74d3af10337280cf6adc266b2f733ce1b9d9248b5ef12ad570d84edefab84d", size = 104616, upload-time = "2026-09-11T19:44:43.58Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/f5/3ebb7e372597702625c75983a87cfa2dc47b5af5544ebc03e85935dd6869/boto3_stubs-1.43.95.tar.gz", hash = "sha256:6ee97832709eb61e02877a4e575ebd1c770fad3e75165979e69fde06212e3be1", size = 104593, upload-time = "2026-09-15T22:15:09.147Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b1/16/c4f9fdcfd3e93e0c3ad5308663723e5c33d11584915e321200c08eeaaf87/boto3_stubs-1.43.93-py3-none-any.whl", hash = "sha256:1d9162d5994438382cee2b49ddf22b75970199008844a08e6def9a4c4cc9d2da", size = 71511, upload-time = "2026-09-11T19:44:38.326Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/48/037970a4da4dc95667eb916d1a5add44acedf1e1e7f1e504cdfd16db4ea5/boto3_stubs-1.43.95-py3-none-any.whl", hash = "sha256:3fefb3943818ad9c1575201a400340b2b6ec3f776ca1ebf52304f68a933391eb", size = 71511, upload-time = "2026-09-15T22:15:00.192Z" },
]
[package.optional-dependencies]
@@ -207,16 +207,16 @@ essential = [
[[package]]
name = "botocore"
-version = "1.43.93"
+version = "1.43.95"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c8/a0/2ce10897323d67dd85de6190fdee159013a75741d1dc48b74d4815ec0592/botocore-1.43.93.tar.gz", hash = "sha256:82da355d18a7f784347b00444be33942834651f31b6c5ffef49999cd47364c5e", size = 16103276, upload-time = "2026-09-11T19:22:58.933Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/2c/0031468b521eefed325a3da2d6576e068aab95837af0c96effea6438851c/botocore-1.43.95.tar.gz", hash = "sha256:779588da32bd48a7bb0c097da4bcb747260e86d2bfc507369dca56f1450e0722", size = 16106887, upload-time = "2026-09-15T19:23:44.23Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/7e/f858c401f32d980f924c8f8328b62fab463313834a9ad2326f44613b97ec/botocore-1.43.93-py3-none-any.whl", hash = "sha256:3ca57bb5d26d88b554a74de708a5c991f45306436c91aacca931252d1d4d54ff", size = 15794156, upload-time = "2026-09-11T19:22:55.964Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/77/7ce7c937b31903f1402e478fdc97f98e727656fcea3cb54e74f1ca55793e/botocore-1.43.95-py3-none-any.whl", hash = "sha256:0fda26d16c7c7bf7082c421390a817b696f98a43d3da7c43dbbb093f76662876", size = 15800616, upload-time = "2026-09-15T19:23:41.228Z" },
]
[[package]]
@@ -1543,19 +1543,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl", hash = "sha256:40ac1e9955acf116c4d995d9bfa082d86ad9ec6d91c4f134827cf5e0a5eb75e0", size = 217292, upload-time = "2026-08-18T08:52:15.47Z" },
]
-[[package]]
-name = "loguru"
-version = "0.7.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "win32-setctime", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
-]
-
[[package]]
name = "lz4"
version = "4.4.5"
@@ -2570,55 +2557,55 @@ wheels = [
[[package]]
name = "protobuf-py"
-version = "0.4.0"
+version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf-py-ext", marker = "(platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/af/7d/1f26b99a7e1854986664715b540387b9d77eb94fcd15ca1df904b794e39e/protobuf_py-0.4.0.tar.gz", hash = "sha256:9a3e424e89c75121c8c66e37349dd568893c26b4917e53ca558c68c2ee641474", size = 160766, upload-time = "2026-09-02T04:47:35.946Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/df/744b304857f9727dad5938e37c88b834c7874581df4bdd36546e0718c5ba/protobuf_py-0.5.0.tar.gz", hash = "sha256:a7be567fbcade37f7ead5c7ebdcda7d5d833afb0e68a569a7a80837f2f5defc1", size = 160914, upload-time = "2026-09-15T06:53:37.924Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4e/f5/37bfd2be0ea3e09b3b187f626dde69307d7884a5db739b64877c6c8a0d3f/protobuf_py-0.4.0-py3-none-any.whl", hash = "sha256:ee29a7cdf0b821c53e536dbf4526d473a11cc0eacc4391cfe188a84b0266d517", size = 213808, upload-time = "2026-09-02T04:46:47.316Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/4d/f574b8fece5fbce107bdaa2d4f81743e810275d584fda83456475ce3a836/protobuf_py-0.5.0-py3-none-any.whl", hash = "sha256:1582e21c920a9c6932d5b9398aa55635315ecf7e05ab6eb09dcd3f9aba8ff7ac", size = 214220, upload-time = "2026-09-15T06:52:52.734Z" },
]
[[package]]
name = "protobuf-py-ext"
-version = "0.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1d/a6/985e38ad6fc276db9e8f248a7097486d675a1253a8874ee6b1877612aedf/protobuf_py_ext-0.4.0.tar.gz", hash = "sha256:a0d48a68c992bacb7b20bffba8300c66608db55d1146cd5df4841d7ff7b8448d", size = 57623, upload-time = "2026-09-02T04:47:37.044Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/55/89/b89c219e3ae3d414dcc319d1d753c386e4883a76f7aab3c8338d160fd903/protobuf_py_ext-0.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d979a0be80b51591693a5f19d60f85e1a79df40e1d0f43f54f398ab87356e8dc", size = 474885, upload-time = "2026-09-02T04:46:48.942Z" },
- { url = "https://files.pythonhosted.org/packages/77/fe/67eb7d8904ead61c9f11fa704cb42d3bc476cd7b36469cf1308a7e8053d4/protobuf_py_ext-0.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af0ca59dbc740d95d6c8edd5a8a3808dce3cd6e882697679792b3749e8ecda98", size = 481705, upload-time = "2026-09-02T04:46:50.526Z" },
- { url = "https://files.pythonhosted.org/packages/24/63/cf0bfbe779d2628e9ac75aa0ac58416a3783550bb242050d341e26f06654/protobuf_py_ext-0.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cb3835c002a5161a4eb9b536ea81b18c3bd253a33de5e418440a22d1b8f19d0", size = 499632, upload-time = "2026-09-02T04:46:51.849Z" },
- { url = "https://files.pythonhosted.org/packages/27/37/bc30c34c32ec7e96d72ae4b61773cda584a6f691387c26a310c5cc1193c2/protobuf_py_ext-0.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85e0411603729a8228f58bdae9afa9f122fa4e6234558cd436c85b33188ab785", size = 660739, upload-time = "2026-09-02T04:46:53.019Z" },
- { url = "https://files.pythonhosted.org/packages/60/5b/fcc151c8606d616f881fdde089ccf0b8b026acfcd20db7d5005036aee093/protobuf_py_ext-0.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:65b34bc9d23945c48ec493d5cd593738cf32d108e31dec72246f87318ba27236", size = 712788, upload-time = "2026-09-02T04:46:54.441Z" },
- { url = "https://files.pythonhosted.org/packages/13/56/f12541528bbe51c042613decd0480f56d1a309db10aed4125904f0850142/protobuf_py_ext-0.4.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:5a4f895b5bd5bfb14b824de53a93915ac9590a4e5d800677c5d04070b62eaedb", size = 467217, upload-time = "2026-09-02T04:46:56.181Z" },
- { url = "https://files.pythonhosted.org/packages/a6/d0/8ed5553d32591b7aee8fef528e3c4930582fc86928d65f751043f91e8fd3/protobuf_py_ext-0.4.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48602ecd589676bab9cacf955a56d151e738433dd76076930738321697379a05", size = 473938, upload-time = "2026-09-02T04:46:57.658Z" },
- { url = "https://files.pythonhosted.org/packages/8c/ad/b7af347ba5354febeb6fcd855efee10bc75dfea0e98aa4e7d23e2a7f3d77/protobuf_py_ext-0.4.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a218f61033c845f09330d15eb0b7fee15f377cfefa36498b4b492323ce23100", size = 497755, upload-time = "2026-09-02T04:46:58.898Z" },
- { url = "https://files.pythonhosted.org/packages/2f/76/718dc054e375631afdd94479b0d62590972bb882539db9610d3934ce40e1/protobuf_py_ext-0.4.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bf79dafcc54150be3fd5c51173c8987b8f40a605c6423b08c2a39856f5d0260b", size = 652593, upload-time = "2026-09-02T04:47:00.253Z" },
- { url = "https://files.pythonhosted.org/packages/89/a0/37f876fbe6b3cbeb3017eb1b294306344f9836154d2bbc7a98f41cb49d82/protobuf_py_ext-0.4.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0a98fd3439d4f0e02996f97062ebec1ba83ae91ddc8fa2ec59a2534d7f83bbd2", size = 710836, upload-time = "2026-09-02T04:47:01.579Z" },
- { url = "https://files.pythonhosted.org/packages/c0/e8/a6507fe92c318a5e9e9f3448a380f0f3994a20cd04f87441350dc12cb510/protobuf_py_ext-0.4.0-cp311-abi3-win_amd64.whl", hash = "sha256:eac0bc24ed9bddba96f3b85bb9bb76153087310e9a9a6d0d3a93cb98f2f2d859", size = 435204, upload-time = "2026-09-02T04:47:03.193Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c6/038b2eac0109cdc5a192d58da1d1c490183c8d0999fa27d8d16b2615fa20/protobuf_py_ext-0.4.0-cp311-abi3-win_arm64.whl", hash = "sha256:d3bb9995c162f567af8737d5ea3912e8ea3dae3574c0a3beeace887bbb48d6ad", size = 416299, upload-time = "2026-09-02T04:47:04.504Z" },
- { url = "https://files.pythonhosted.org/packages/25/31/36995b4ccc8b0ed9811011f5f0ffdf31b0020b541a2fc09f595b53a7f141/protobuf_py_ext-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c824324286af4fe9d2d1f95177455c62114370d6940e39c04d7b0d62d3bd2196", size = 467473, upload-time = "2026-09-02T04:47:05.81Z" },
- { url = "https://files.pythonhosted.org/packages/b3/d2/7f7a1e9c5e167f542d84332f8e4ce92f965bedfdbf2e76d16dc32732d32a/protobuf_py_ext-0.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab8f442d25ca3ec06e32a2ad652132bdcee254286171837c81a1058a33ab9d2d", size = 473757, upload-time = "2026-09-02T04:47:07.258Z" },
- { url = "https://files.pythonhosted.org/packages/f5/c9/4a9a08b9da0049a0a7e8465c9289c3b209faf0ab8c72212b79204963d866/protobuf_py_ext-0.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87f8b05c9d8dfe55ce823dce5264a5a67967adcda0ed9ab924ec9afde855c676", size = 494009, upload-time = "2026-09-02T04:47:08.562Z" },
- { url = "https://files.pythonhosted.org/packages/b8/ed/da03094575f3be017fcaaa7ced7b844a3054781e1c36f84529f95a6ea48a/protobuf_py_ext-0.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:46de28bd4e0f359f7359da03ebbb591ac128149e7f5691e18e1c681e88893ae9", size = 652901, upload-time = "2026-09-02T04:47:10.148Z" },
- { url = "https://files.pythonhosted.org/packages/4f/6e/385a8f2e64b0cbf9110390e0817760d42f7ed439af825a106fc4860a6331/protobuf_py_ext-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c3a318895c8420460c28069f0825daf43d6383a221f90cdbf941839121918f", size = 707344, upload-time = "2026-09-02T04:47:11.519Z" },
- { url = "https://files.pythonhosted.org/packages/64/b4/95edd98f53dc46e873aa7f33bc0062c22e30e6f8d7832f4588584c28bb28/protobuf_py_ext-0.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3adb0f5858b75f17dd3ab73b35bf27e31807075365135c5937160cc7655ea548", size = 467802, upload-time = "2026-09-02T04:47:12.887Z" },
- { url = "https://files.pythonhosted.org/packages/8d/d0/6eac391eea2f011c699ad5e1af9e39c4cec3b1011d92225e5aaa96f55853/protobuf_py_ext-0.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95292102616cbb2b25ff88e31c2b65a78c5dc65885d0593bd38edfb64ce67576", size = 473940, upload-time = "2026-09-02T04:47:14.425Z" },
- { url = "https://files.pythonhosted.org/packages/29/bc/1ef4bf41ea8a533a63a583f8045dab3001fb705a7820920430df58d4c723/protobuf_py_ext-0.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c38b1b0a45d6380f63bbd69c396a66db41a21badb689d7fadb5a01c71c62c3e", size = 494785, upload-time = "2026-09-02T04:47:15.944Z" },
- { url = "https://files.pythonhosted.org/packages/11/7e/78d4524e1238407651572be09046c871fae2ed793fa8e8eeb877e6b69449/protobuf_py_ext-0.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e6085a3686b83f0866430528d0a592794b91a36cc732dfd3579dcbf2810e034c", size = 653258, upload-time = "2026-09-02T04:47:17.354Z" },
- { url = "https://files.pythonhosted.org/packages/41/01/dec9f956efa60262df30dbf46cf4b52cd96d6eff7e68254311675db1bc25/protobuf_py_ext-0.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db19ebf918d6fd9df30724afda5d8f2a0a9d2967ba22d0cf2779a9cd33cebcd7", size = 707951, upload-time = "2026-09-02T04:47:18.931Z" },
- { url = "https://files.pythonhosted.org/packages/40/3e/8aafe5b5f3ad717b6750d486dfc1672dd88a869a90697781b641acfdb0f0/protobuf_py_ext-0.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bb331f9e3e596eac0b2acf81d8fb686135f82061ef0b4b094ef0e6c31b853cfe", size = 466202, upload-time = "2026-09-02T04:47:20.378Z" },
- { url = "https://files.pythonhosted.org/packages/44/df/f025478f7d3eb63ea17e8c19e507373cebb7fac16b088373cee6cb531ce3/protobuf_py_ext-0.4.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2045ba1ca3fbef83054c1c1a1670ecc0b1406b7d9797a403d4e3255ca779eafd", size = 472806, upload-time = "2026-09-02T04:47:21.662Z" },
- { url = "https://files.pythonhosted.org/packages/bf/58/68d16bd5ffd6a2fd6d951baf5a1944fe8d5f35e7df69cdb8264c77ebd69c/protobuf_py_ext-0.4.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b2541b3baf9cc03a50a8a3ecf57263dd7b4174eacf77e680680f7b2948e95d2", size = 493245, upload-time = "2026-09-02T04:47:23.042Z" },
- { url = "https://files.pythonhosted.org/packages/b5/f4/fea80c65b5bc4779383bea5fc3de147af36246b398e1bcb680fed36584a4/protobuf_py_ext-0.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b0a97fff9f85db236d9f6fb2e24216ccebbf38cad8e048063109ccf5bcf8622f", size = 651735, upload-time = "2026-09-02T04:47:24.665Z" },
- { url = "https://files.pythonhosted.org/packages/8e/15/40ca2e38f78be869301142373f3d5a3cff6756f78bfc8ab4095246f6bdc7/protobuf_py_ext-0.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:34f9450a71f6093f1b781212db3b580dd4b594459ab00212144b03fdc36c5c06", size = 706910, upload-time = "2026-09-02T04:47:26.262Z" },
- { url = "https://files.pythonhosted.org/packages/04/ed/bca385d73a8eae6802026f4f587b4b6e94378774c7ded6de552afb5293e8/protobuf_py_ext-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4d4a2253f7c1052eb19cb0b9a55633925ba191570b1a9de0f0b151540c99b1a5", size = 454194, upload-time = "2026-09-02T04:47:27.643Z" },
- { url = "https://files.pythonhosted.org/packages/db/88/f2fc3f6084382ab187420f9051913d0848896fd0bf39de7245415de5bdff/protobuf_py_ext-0.4.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59ec454d17e1f89d0a9d10af023ece073b7731687ba7bfebb338f1c4730edbb9", size = 463604, upload-time = "2026-09-02T04:47:28.924Z" },
- { url = "https://files.pythonhosted.org/packages/f6/21/5063d75b91d44f2454cba74470a69d85e990a85fe2cd71f415126519bcb5/protobuf_py_ext-0.4.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9ff4ac903cbd8aba13c86a6576c3aed2b3aa7a45d57f53f6c5fbde642e193d6", size = 484374, upload-time = "2026-09-02T04:47:30.349Z" },
- { url = "https://files.pythonhosted.org/packages/aa/7b/5562d51cd504a18c09d07e19f2e06ad0ba50f97be1cd9cd92f804131648e/protobuf_py_ext-0.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b2f7e69b5c67df1d1b9ab3fa8fd67622b87de337ed4aeb180f39e30ac611e0e5", size = 642973, upload-time = "2026-09-02T04:47:31.893Z" },
- { url = "https://files.pythonhosted.org/packages/c8/02/37ec65335ce5600b2bc3e36d3be38b85556cc6a50cfdcbdd94c4d84ac17c/protobuf_py_ext-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62b5bf33a5b1aca6f3137f268c2f4e068e6cd96bbabd3e66cee74f9a54488602", size = 698130, upload-time = "2026-09-02T04:47:33.38Z" },
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/00/e5742687f394e55b96a8f69ae6c3ef1ea518bdb85970660ef582cc8da89d/protobuf_py_ext-0.5.0.tar.gz", hash = "sha256:99c3e47e145e6ccc758ac473b967240e4cd5d4ddbb12352da2fea44bb09b75a6", size = 57832, upload-time = "2026-09-15T06:53:38.88Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/c9/0f563b73a9cb9ccbc0d7acc6d4fae9483b03f41c561938b38535654aaafa/protobuf_py_ext-0.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b55c8a6eb29126f27b41ad55a155ab1415fefab52a58f275fde5bc28f72faab", size = 477060, upload-time = "2026-09-15T06:52:54.425Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/8e/62e63c3ed8a80cb571c94928909a393ca4b75a447e8f7ab19bb01a853119/protobuf_py_ext-0.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03fb237f18e460aed840d34c06d7ce8e8c2041d2a6fc86b265cb590ccf6f7f9c", size = 485256, upload-time = "2026-09-15T06:52:56.005Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/5a/52c3c7919ac6158d00aa3d6d4d45ebbe2189bdbb345313ebef0f836237a8/protobuf_py_ext-0.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3082739cb388a62a5d2dfd60f58f1519d81ec8495686b6bc824117525ce8a3", size = 504943, upload-time = "2026-09-15T06:52:57.205Z" },
+ { url = "https://files.pythonhosted.org/packages/97/89/fa3a9ef9bcd8ec068deae473b49847afd2d335e69c50e208c18fc67fb8ac/protobuf_py_ext-0.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1d916cb4ede997a9557e7637da0ffaf4ae3d49fd4c0e57ab53a82af1a6bb9b94", size = 663602, upload-time = "2026-09-15T06:52:58.475Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/8b/b318dc57de9f3917d4781b3682c3d0acac61d0cea0e3ab5226465986586e/protobuf_py_ext-0.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7cfb1d1efaaff67b19da7444928061c68eb59b954427551f4689a5f8148c6b9d", size = 718050, upload-time = "2026-09-15T06:52:59.931Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/a5/08ee1549f93e2a15d43af841181d9b9f54049a6d799f0647019ecbd18f73/protobuf_py_ext-0.5.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:c263aa5594a5be05749d2fc8398685ff97435cbe0ea98076cc1dc114c69d6994", size = 469453, upload-time = "2026-09-15T06:53:01.222Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/de/37275a9c1abf9470760ac12db908065b1e3c1eae5b16ac0f32cd5c0dce78/protobuf_py_ext-0.5.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65801a66e68bd00b5ab48cfd277ebc5ff974381e0e9e1812f7c77efbcacad1a9", size = 478158, upload-time = "2026-09-15T06:53:02.601Z" },
+ { url = "https://files.pythonhosted.org/packages/21/9a/acbe04fdd223de6364a6e6252a316358a74f5f4c8e0ca13b145e15b3ac88/protobuf_py_ext-0.5.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f0b868704714af24981b68095774edd42930490a2e09678a9e1d88b892a1100", size = 503450, upload-time = "2026-09-15T06:53:03.81Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/42/f4ee62c87fb0488bd1bcc88452665201bf2ae3d2491618dce14755fc152f/protobuf_py_ext-0.5.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2fe9c58940372c24d31d51d446dde4cfb7f7d9ad36ce4c07f59d44373126145f", size = 657275, upload-time = "2026-09-15T06:53:05.041Z" },
+ { url = "https://files.pythonhosted.org/packages/79/db/79e3a0c975457cff14ca2579c60209946fe625b649e876fe285ca2754d9b/protobuf_py_ext-0.5.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:22143055e96570a08b5920c3637640450fbecadaeda416af4b3836937616ecf1", size = 716043, upload-time = "2026-09-15T06:53:06.36Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/3b/d2bc8047f643db12f04b2e680a7f9dc5433e96e3b07840148e8eebfc5a66/protobuf_py_ext-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:b59e4daffcbfc0499a91779a546907a509c7058fdf9784d16e727a1dde729938", size = 440050, upload-time = "2026-09-15T06:53:07.706Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/0f/e49bd745cdc5f8b2d171817d87035d64be0b75326db7c0c1e644778fa882/protobuf_py_ext-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:8e1077f106a60cac056cc7591ca8ba70dcb34e65139a992bfa76bc2c5f85ed50", size = 419729, upload-time = "2026-09-15T06:53:08.918Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/49/5a0ea1bb76d5d7be41ccdfc58422e4cd172d856646dd8903cd6034791e48/protobuf_py_ext-0.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7c584e99362c3004ac4f584108bc38cdfb8ee08da84fd57c8eff9abbe8eb0bd", size = 471070, upload-time = "2026-09-15T06:53:10.059Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/c8/2f656bcab07519d7d03901aaea8a572ca0decdbdc0aea44a88c4496f0b9e/protobuf_py_ext-0.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e39a4b9289de2f73d67a1be2c3f750f98186ed66b9a844f2a47ad4122c2e6f6a", size = 478317, upload-time = "2026-09-15T06:53:11.434Z" },
+ { url = "https://files.pythonhosted.org/packages/05/8a/b137405f95761606d384d35dac2c9a1b8fccc0aba4a3588472d8bf434a82/protobuf_py_ext-0.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d4b241a72759cbe26759db2a13deab059510d7609a781d125bba22dbbfaf2cf", size = 499921, upload-time = "2026-09-15T06:53:12.699Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/64/e5e97d95922f81834b189666c6cfe74c071c7929cfb20f59ab77352e8f88/protobuf_py_ext-0.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b6add28bbdf0b724ccc10a9c8a80549ab0b2af50b808e533277506bdb3c04e6", size = 656768, upload-time = "2026-09-15T06:53:13.934Z" },
+ { url = "https://files.pythonhosted.org/packages/22/38/0ababc6093654bac7430cf1f5be2159d5442f676e1853d3e8f417861a3f7/protobuf_py_ext-0.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b8c155384c8949c0cccc4a3c14b53ee85b5dbf022c4bc0bd9486764c8250e03", size = 712993, upload-time = "2026-09-15T06:53:15.347Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/3d/2438ca1835d81a7073ae7af18629f00fbed3bd9cd02b7ad5803a709a8f2f/protobuf_py_ext-0.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5ebd954fa15f2636ba58577c9bb037865bcdaf66e68d8f0587ccfec814d451d2", size = 471346, upload-time = "2026-09-15T06:53:16.567Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/3f/088cb289aa8cf520827b4fb99092d3204f1a2918ae730d45029c08a8553d/protobuf_py_ext-0.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cea81147867a9b29697240b586d7c945398a5629351e070ff740d9a5b9eb148", size = 479009, upload-time = "2026-09-15T06:53:17.865Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a3/e5e50258d7f7e006cc5cd4c428981ae0d69cacec59093eb2a865d97c2b16/protobuf_py_ext-0.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12015dd711f70e7c8ac091376781b1924aeaaed9e9bee39fdf9130cb45b137e9", size = 500623, upload-time = "2026-09-15T06:53:19.218Z" },
+ { url = "https://files.pythonhosted.org/packages/12/72/974f9df89b5b2ea095374a590f4b85aeaf80f4fa5f504012b1cf93c63565/protobuf_py_ext-0.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e1fef6a711ebcb9a413fe10d342b278fa30ceb68b2bf7bfcf0a55523f091dfb4", size = 657155, upload-time = "2026-09-15T06:53:20.631Z" },
+ { url = "https://files.pythonhosted.org/packages/02/27/c1709e454a22f589b35578d77fd68db707e1109f896ec2245af295612431/protobuf_py_ext-0.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5f11aa121af8f6bede87d8717abbcc0c5ad9b68902863489de416481df31e72", size = 713679, upload-time = "2026-09-15T06:53:21.966Z" },
+ { url = "https://files.pythonhosted.org/packages/de/8f/9759b7890488ea54b88d751ef4fe6dfac8c230e5f54b1230e1819c643928/protobuf_py_ext-0.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9684428ad5ea608f50144ef9ee6d918f4ce15a7222093b38eac4a17fb51efd1f", size = 469469, upload-time = "2026-09-15T06:53:23.185Z" },
+ { url = "https://files.pythonhosted.org/packages/92/0f/6edef5e307e2e73653aa8c252d123076bc14d098d8a603e5cbcbbd9afba6/protobuf_py_ext-0.5.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee68dce9041a1fbec9648ef06dba465a033e69cbc89a27be01b4f5c17285c03e", size = 477129, upload-time = "2026-09-15T06:53:24.455Z" },
+ { url = "https://files.pythonhosted.org/packages/df/8f/2ac90881b7cea63d3ba86f98479c3025996d37480638d16b2f6a3d662e40/protobuf_py_ext-0.5.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b90b12abf51a0ba4aeeed2743f3633760a6707788726baae70d53bfe41559f6", size = 499183, upload-time = "2026-09-15T06:53:25.688Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/f4/10769d23e8c664a92ab9e7274669e6542c0cdaa86e9a02f1281640bade4f/protobuf_py_ext-0.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:75ee44f40633c07741320ad72f9c8736be5021ba24f96844337bba009b74a10d", size = 655283, upload-time = "2026-09-15T06:53:26.926Z" },
+ { url = "https://files.pythonhosted.org/packages/38/40/10681c188198c00fd48799c40827e7fafd6deb19dc59d2b48791af9650d1/protobuf_py_ext-0.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f54a76acaf78255a97e9a33798d6efedec0d5fdaf38d393501be6b368e54d6e7", size = 712563, upload-time = "2026-09-15T06:53:28.322Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/25/c590e1994ceec4aa9bc0b71fa0c4063265f3168e87a9088ff01038c58c24/protobuf_py_ext-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae8a17933f066da2e1d105fe19cec425a63c0065349ad128045f6ad5eec5ee0a", size = 458067, upload-time = "2026-09-15T06:53:29.572Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/fd/a8dc89c1269055cd10f93135baf387227a4f915443a45ec1d7d7083d4bcc/protobuf_py_ext-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60be9e43b00e4c5ba65dc290e24cd2e4a2473e553b96e81feb22be24c991dcf4", size = 466789, upload-time = "2026-09-15T06:53:30.908Z" },
+ { url = "https://files.pythonhosted.org/packages/15/18/e90306c613c61ef3e7011ff6606c7b7fcf97ff0dc176b2c98b58e8545e19/protobuf_py_ext-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e82945cde40662610ebbc1e8960d4cfd22df900a3aabdc0f6ba9f80d23b6ae9", size = 489446, upload-time = "2026-09-15T06:53:32.343Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/9e/a5dbbf257dff0fe4f141080f8427f793018f44f78b81fdbb88aacee796b8/protobuf_py_ext-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c1bd5a6137b6f1295d8a3bcb9711cbf4e959743611325b8b1254a2d51e64d7a", size = 646164, upload-time = "2026-09-15T06:53:33.688Z" },
+ { url = "https://files.pythonhosted.org/packages/40/65/6046d7641a26ffdb6bb9e0463d0596c341ec156e40c0571b396ad786d4a2/protobuf_py_ext-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0fe8f84fae44c6bfd3ee977ab35893d77d30c169e6502f83769d2e0ee2ec7591", size = 703098, upload-time = "2026-09-15T06:53:35.337Z" },
]
[[package]]
@@ -2947,11 +2934,11 @@ wheels = [
[[package]]
name = "pyproject-hooks"
-version = "1.2.0"
+version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz", hash = "sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43", size = 21013, upload-time = "2026-09-16T08:58:03.999Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
+ { url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl", hash = "sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b", size = 10724, upload-time = "2026-09-16T08:58:02.96Z" },
]
[[package]]
@@ -3404,7 +3391,6 @@ name = "tilebox-datasets"
source = { editable = "tilebox-datasets" }
dependencies = [
{ name = "cftime" },
- { name = "loguru" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
@@ -3431,7 +3417,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cftime", specifier = ">=1.6.4" },
- { name = "loguru", specifier = ">=0.7" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pandas", specifier = ">=2.1" },
{ name = "promise", specifier = ">=2.3" },
@@ -3528,10 +3513,10 @@ dev = [
{ name = "cython", specifier = ">=3.0.11" },
{ name = "junitparser", specifier = ">=3.2.0" },
{ name = "pip", specifier = ">=24.2" },
- { name = "prek", specifier = ">=0.2.27" },
+ { name = "prek" },
{ name = "pyarrow", specifier = ">=17.0.0" },
- { name = "ruff", specifier = ">=0.14.10" },
- { name = "ty", specifier = "==0.0.14" },
+ { name = "ruff" },
+ { name = "ty" },
{ name = "types-protobuf", specifier = ">=6.30" },
]
@@ -3738,26 +3723,27 @@ wheels = [
[[package]]
name = "ty"
-version = "0.0.14"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" },
- { url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" },
- { url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" },
- { url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" },
- { url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" },
- { url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" },
- { url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" },
- { url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" },
- { url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" },
- { url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" },
- { url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" },
- { url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" },
- { url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" },
- { url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" },
- { url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" },
- { url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" },
+version = "0.0.81"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/b7/c9d736f48585f5a711ea47bb97a353d3771834f89481d747ea9687b74fa9/ty-0.0.81.tar.gz", hash = "sha256:ef721aa649bf41d665ba86e1ea726fd3feab6800e2c4887a062a704baf304ca8", size = 7225871, upload-time = "2026-09-15T02:05:29.536Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/70/649a5ec8fd6cc9dfa731c905523b19e677ad36dd9dcc4fcadfed8235507f/ty-0.0.81-py3-none-linux_armv6l.whl", hash = "sha256:8e9e3dd6edb1462633ddba2bf337d1c05023a5a13c2f315e445565c7ad43d247", size = 13679646, upload-time = "2026-09-15T02:04:51.436Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/d0/3769b760918c3dde1bbaeee87422cd442fc550baadb5c8621e479b363282/ty-0.0.81-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:649c350873e4b3e937d856512ee57b8a345d80079cbead73a5308c2cdafe594c", size = 13211367, upload-time = "2026-09-15T02:04:54.18Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/5f/b8fe3ab7eee87bbf1f5ff1b65c55526a3f31a9604630bc9e7d84199bbac5/ty-0.0.81-py3-none-macosx_11_0_arm64.whl", hash = "sha256:480c78706ba78239f901d68cf1ba53e98b84aa51e19b9241daa0fb62fdecd2e2", size = 13084312, upload-time = "2026-09-15T02:04:56.133Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/d8/ce22278014f14ae8205d2f9622696d39c623a0c9e80056c258bb89bb8322/ty-0.0.81-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cdbfa4b634b1d21542eedc11e424dedcc4871c2128d0d46b55b0089d3220492", size = 13127965, upload-time = "2026-09-15T02:04:58.306Z" },
+ { url = "https://files.pythonhosted.org/packages/82/d0/4e75c61f6a241a0804f25117af4f91a5aaa4a48408b57f8d4c34d8a3d91e/ty-0.0.81-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f7a34743a21669d4f3d5fda98c36189cb4d9dceee1415a8a42d7426d55995db", size = 13432523, upload-time = "2026-09-15T02:05:00.471Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/02/e0b6b783418515c8ba40c13d5e333da9e0576a7a69582fc2b5831bb7d913/ty-0.0.81-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:742eab849947bc10b4c09810d37bd743afe9502e114b7bcfd6389f0ae3f425aa", size = 14211082, upload-time = "2026-09-15T02:05:02.455Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/af/85cb68d13ff617f2b213212d674e1266b933f2d051b63a1e5116e8ca0bfc/ty-0.0.81-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b4cef395eee7007449aa3fc3fc8dc6bc58fe7a8956d56db078569061741bf52", size = 14692204, upload-time = "2026-09-15T02:05:04.705Z" },
+ { url = "https://files.pythonhosted.org/packages/24/c6/791891f3fa8fec91e9ec3e8c9d6774ab903297defe847a3e5d7e047360ad/ty-0.0.81-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80cf8c34973a1da2ec2a043efcba8f4c0a984d45210957a8ddd2c16f2b8f8e85", size = 14371010, upload-time = "2026-09-15T02:05:06.63Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/b2/c9fba5b4ed7f63f26e83432fa2062ca04606b61dce7bbacc1238232af649/ty-0.0.81-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f37a8f66c444eea29f17adfe0664f42062520773f18031fd4913f28eb56d2966", size = 13758537, upload-time = "2026-09-15T02:05:08.722Z" },
+ { url = "https://files.pythonhosted.org/packages/77/e6/09c41e96bbcbe53dd18c9a1a6dbd2e456b0d03b94440a4bd9bab55a4e19a/ty-0.0.81-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e8134cc03e84a27f409877b9b62053fd4aaac9c3ce0ce0fdace8b5ed58d78e04", size = 14280789, upload-time = "2026-09-15T02:05:10.719Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d1/87eb8e01fb17602cde5911cbadb9b11141a04f728dbda0a2a00243f337ca/ty-0.0.81-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb6f269c5f14a07047e05cbf3d06dd9a64a048050f736afcf744a180a272f034", size = 13169638, upload-time = "2026-09-15T02:05:12.806Z" },
+ { url = "https://files.pythonhosted.org/packages/94/3c/137ceb792858f408c48f20a066d12ee76108e5f28a13242720879c024172/ty-0.0.81-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:38355a5e6294ebd3cee616ed581d051963d61b293a1b3351c248747f73a45b74", size = 13449856, upload-time = "2026-09-15T02:05:15.505Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0a/4a9b31fadfc80a2141f3862316eb31944843e01bc1f6533794291cce767e/ty-0.0.81-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59a35e128a9831423aee19db804aa8a1358ee3ce615b3fc0dec20d40bfd0d733", size = 13675303, upload-time = "2026-09-15T02:05:17.537Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/16/dbcd2c10ab80b015a227110a4ec75314468b6720d800261098521df4eee9/ty-0.0.81-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f0f4219fbc068ea3414fa804c6bd57ef4229a11884ab9ca71cd8308072520aad", size = 14050298, upload-time = "2026-09-15T02:05:20.424Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ba/a70ced0c0c078ff09a3a80224d045c4267d07811dbaf125c749af5f72606/ty-0.0.81-py3-none-win32.whl", hash = "sha256:82ff952e64ba4c4da1c0c89723e326c768ae4f9ff20624dee04a51c3f4e4b96c", size = 12866650, upload-time = "2026-09-15T02:05:22.824Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/51/4b79453df93dfb9edecb1bd6cb04e772277d8c7260693165358a033b6ed1/ty-0.0.81-py3-none-win_amd64.whl", hash = "sha256:ef82788744da0b7f2a598e4e9776f9c5998df4eef7e2750ae3a5537e7c734b69", size = 13572976, upload-time = "2026-09-15T02:05:24.907Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/56/09594d55a0c49543eeac149c0dbc80b9b04409cc26ca48f1c1a9595c0916/ty-0.0.81-py3-none-win_arm64.whl", hash = "sha256:bdb1563990c5d3abfe5a09e7512733f302773ede6a5d3e4e5c6ad46393d8df4d", size = 13364408, upload-time = "2026-09-15T02:05:27.247Z" },
]
[[package]]
@@ -3798,11 +3784,11 @@ wheels = [
[[package]]
name = "urllib3"
-version = "2.7.0"
+version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]
@@ -3858,15 +3844,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl", hash = "sha256:a31a8774885b96fe825462f5d6496166f0c7cae111195b6465c801d230eb5a4e", size = 2225148, upload-time = "2026-08-18T08:52:53.736Z" },
]
-[[package]]
-name = "win32-setctime"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
-]
-
[[package]]
name = "wrapt"
version = "2.4.1"