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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.66.0] - 2026-10-14

### Added

- `tilebox-datasets`: Support STAC classification bitfields on assets and bands, including inheritance,
shared metadata lifting, and protobuf round trips.
- `tilebox-workflows`: Add workflow summaries and optional presentation metadata.
- `tilebox-storage`: Read Planetary Computer assets with automatic, lazily fetched Azure SAS credentials.
Reuse container-scoped credentials across assets and refresh them on demand without changing asset URLs.
- `tilebox-storage`: Support Azure `abfs` and `abfss` asset locations alongside `az`, `azure`, and Azure HTTPS locations.

### Changed

- Update generated protobuf code to the latest API.
- `tilebox-workflows`: Change workflow creation to `create(name, summary, description="")`, with a required summary.
Change updates to `update(workflow_or_slug, name=None, summary=None, description=None)`.
The positional order is now name, summary, description. For summary and description updates,
`None` leaves the value unchanged and `""` clears it.
- `tilebox-storage`: Require explicitly configured credential providers through `Client(s3_credentials=...)`
for S3-authenticated asset locations.
Asset locations without authentication metadata use unsigned cloud requests, including Azure.
- `tilebox-storage`: Reuse asset stores by backend, namespace, effective options, and credential identity,
rather than authentication descriptions or other unused metadata.
- `tilebox-storage`: Require obstore 0.11.1 or newer and add Tenacity for bounded asynchronous signing retries.

### Fixed

- `tilebox-storage`: Preserve HTTP query parameters in request URLs instead of encoding them into object keys.
- `tilebox-storage`: Filter unsupported storage, authentication, and signing-flow combinations before checking
for ambiguous asset access. Reject authentication schemes that are incompatible with the selected backend.

## [0.65.0] - 2026-10-09

### Added
Expand Down
57 changes: 57 additions & 0 deletions tilebox-datasets/tests/test_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
MediaType,
)
from tilebox.datasets.datasets.stac.v1.asset_metadata_pb import (
ClassificationBitField,
ClassificationClass,
EOCommonName,
EOProperties,
RasterProperties,
Expand All @@ -28,6 +30,7 @@
)
from tilebox.datasets.datasets.stac.v1.asset_pb import AssetLocation as ProtoAssetLocation
from tilebox.datasets.datasets.stac.v1.asset_pb import Band as ProtoBand
from tilebox.datasets.datasets.stac.v1.asset_pb2 import Assets as AssetsWire
from tilebox.datasets.datasets.stac.v1.authentication_pb import (
Authentication,
AuthenticationFlow,
Expand Down Expand Up @@ -318,6 +321,60 @@ def test_band_compilation_lifts_metadata_and_interns_profiles() -> None:
assert decoded["red-b"] == assets["red-b"]


@pytest.mark.parametrize("placement", ["asset", "inherited", "shared", "override", "distinct"])
def test_classification_bitfields_round_trip(placement: str) -> None:
cloud = ClassificationBitField(
offset=0,
length=2,
name="cloud",
description="Cloud confidence",
roles=["cloud", "quality"],
classes=[ClassificationClass(value=0, name="clear"), ClassificationClass(value=3, name="high")],
)
snow = ClassificationBitField(
offset=5,
length=1,
name="snow",
classes=[ClassificationClass(value=0, name="absent"), ClassificationClass(value=1, name="present")],
)
defaults = (cloud, snow)
asset_bitfields = defaults if placement in {"asset", "inherited", "override"} else ()
bands = {
"asset": (),
"inherited": (Band(name="qa"), Band(name="qa2")),
"shared": (Band(name="qa", bitfields=defaults), Band(name="qa2", bitfields=defaults)),
"override": (Band(name="qa"), Band(name="qa2", bitfields=(snow,))),
"distinct": (Band(name="qa", bitfields=(cloud,)), Band(name="qa", bitfields=(snow,))),
}[placement]
expected_bands = {
"asset": (),
"inherited": (defaults, defaults),
"shared": (defaults, defaults),
"override": (defaults, (snow,)),
"distinct": ((cloud,), (snow,)),
}[placement]
expected_asset = () if placement == "distinct" else defaults
authored = AssetCollection.from_assets(
[Asset(key="qa", primary=AssetLocation("https://example.com/qa.tif"), bitfields=asset_bitfields, bands=bands)]
)
assert tuple(band.bitfields for band in authored["qa"].bands) == expected_bands

compiled = authored.to_fields()["assets"]
assert tuple(compiled.assets[0].bitfields) == expected_asset
sparse_bitfields = [compiled.band_profiles[index].bitfields for index in compiled.assets[0].band_profile_indices]
assert sparse_bitfields == [list(bits) if bits != expected_asset else [] for bits in expected_bands]

wire = AssetsWire.FromString(compiled.to_binary())
if placement != "distinct":
assert wire.assets[0].bitfields[0].HasField("offset")
assert wire.assets[0].bitfields[0].offset == 0
assert wire.assets[0].bitfields[0].classes[0].HasField("value")
decoded = AssetCollection.from_datapoint(_scalar(assets=Assets.from_binary(wire.SerializeToString())))
assert decoded["qa"].bitfields == expected_asset
assert tuple(band.bitfields for band in decoded["qa"].bands) == expected_bands
assert decoded == authored


def test_sar_metadata_is_lifted_field_by_field_and_round_trips() -> None:
def sar_properties(resolution_range: float | None = None) -> SARProperties:
return SARProperties(
Expand Down
12 changes: 12 additions & 0 deletions tilebox-datasets/tilebox/datasets/assets/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from protobuf import Message

from tilebox.datasets.datasets.stac.v1.asset_metadata_pb import (
ClassificationBitField,
ClassificationClass,
EOCommonName,
EOProperties,
Expand Down Expand Up @@ -170,6 +171,7 @@ class Band:
raster: Generated Raster extension metadata.
classes: Generated Classification extension classes.
sar: Generated Synthetic-Aperture Radar metadata.
bitfields: Generated Classification extension bitfields, or empty to inherit them.
"""

name: str | None = None
Expand All @@ -181,6 +183,7 @@ class Band:
raster: RasterProperties | None = None
classes: tuple[ClassificationClass, ...] = ()
sar: SARProperties | None = None
bitfields: tuple[ClassificationBitField, ...] = ()


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -231,6 +234,7 @@ class Asset:
sar: Generated Synthetic-Aperture Radar metadata.
satellite: Generated Satellite extension metadata.
product: Generated Product extension metadata.
bitfields: Generated Classification extension bitfields.
"""

key: str
Expand All @@ -255,6 +259,7 @@ class Asset:
sar: SARProperties | None = None
satellite: SatelliteProperties | None = None
product: ProductProperties | None = None
bitfields: tuple[ClassificationBitField, ...] = ()


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -711,6 +716,7 @@ def _compile_assets(
sar=asset.sar,
satellite=asset.satellite,
product=asset.product,
bitfields=list(asset.bitfields),
)
)
band_profiles = sorted(unsorted_band_profiles.values(), key=_band_profile_sort_key)
Expand Down Expand Up @@ -801,6 +807,7 @@ def _compile_band_profile(band: Band, asset: Asset) -> ProtoBand:
raster=_sparse_raster(band.raster, asset.raster),
classes=[] if _semantic_equal(band.classes, asset.classes) else list(band.classes),
sar=_sparse_sar(band.sar, asset.sar),
bitfields=[] if _semantic_equal(band.bitfields, asset.bitfields) else list(band.bitfields),
)


Expand All @@ -816,6 +823,7 @@ def _effective_band(band: Band, asset: Asset) -> Band:
raster=_inherit_raster(band.raster, asset.raster),
classes=band.classes or asset.classes,
sar=_inherit_sar(band.sar, asset.sar),
bitfields=band.bitfields or asset.bitfields,
)


Expand All @@ -835,6 +843,7 @@ def _normalize_asset(asset: Asset) -> Asset:
common_nodata = _common(band.nodata for band in bands)
common_unit = _common(band.unit for band in bands)
common_classes = _common(band.classes for band in bands)
common_bitfields = _common(band.bitfields for band in bands)
return replace(
asset,
roles=roles,
Expand All @@ -845,6 +854,7 @@ def _normalize_asset(asset: Asset) -> Asset:
raster=_lift_raster(bands, asset.raster),
classes=asset.classes if common_classes is _NO_COMMON else common_classes,
sar=_lift_sar(bands, asset.sar),
bitfields=asset.bitfields if common_bitfields is _NO_COMMON else common_bitfields,
)


Expand Down Expand Up @@ -1154,6 +1164,7 @@ def _resolve_band(source: ProtoBand, asset: Asset) -> Band:
raster=_inherit_raster(source.raster, asset.raster),
classes=tuple(source.classes or asset.classes),
sar=_inherit_sar(source.sar, asset.sar),
bitfields=tuple(source.bitfields or asset.bitfields),
)


Expand Down Expand Up @@ -1224,6 +1235,7 @@ def _resolve_assets(
sar=source.sar,
satellite=source.satellite,
product=source.product,
bitfields=tuple(source.bitfields),
)
bands = []
for index in source.band_profile_indices:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def __init__(

class ClassificationClass(Message[_ClassificationClassFields]):
"""
ClassificationClass contains all Classification 2.0 class fields. Bit-field definitions are intentionally deferred.
ClassificationClass contains all Classification 2.0 class fields.

```proto
message datasets.stac.v1.ClassificationClass
Expand Down Expand Up @@ -189,6 +189,67 @@ def __init__(
percentage: float
count: int

_ClassificationBitFieldFields: TypeAlias = Literal["offset", "length", "classes", "name", "description", "roles"]

class ClassificationBitField(Message[_ClassificationBitFieldFields]):
"""
ClassificationBitField describes classes encoded in a contiguous range of bits, following Classification 2.0.
Offset zero denotes the least significant bit; class values apply after extracting this bit range.

```proto
message datasets.stac.v1.ClassificationBitField
```

Attributes:
offset:
```proto
optional uint32 offset = 1;
```
length:
```proto
optional uint32 length = 2;
```
classes:
```proto
repeated datasets.stac.v1.ClassificationClass classes = 3;
```
name:
```proto
optional string name = 4;
```
description:
```proto
optional string description = 5;
```
roles:
```proto
repeated string roles = 6;
```
"""

__slots__ = ("offset", "length", "classes", "name", "description", "roles")

if TYPE_CHECKING:

def __init__(
self,
*,
offset: int | None = None,
length: int | None = None,
classes: list[ClassificationClass] | None = None,
name: str | None = None,
description: str | None = None,
roles: list[str] | None = None,
) -> None:
pass

offset: int
length: int
classes: list[ClassificationClass]
name: str
description: str
roles: list[str]

_ProjectionFields: TypeAlias = Literal["code", "bbox", "shape", "transform"]

class Projection(Message[_ProjectionFields]):
Expand Down Expand Up @@ -471,12 +532,13 @@ class RasterSampling(Enum):


_DESC = file_desc(
b'\n%datasets/stac/v1/asset_metadata.proto\x12\x10datasets.stac.v1"\xef\x01\n\x0cEOProperties\x12?\n\x0bcommon_name\x18\x01 \x01(\x0e2\x1e.datasets.stac.v1.EOCommonNameR\ncommonName\x122\n\x11center_wavelength\x18\x02 \x01(\x01R\x10centerWavelengthB\x05\xaa\x01\x02\x08\x01\x124\n\x13full_width_half_max\x18\x03 \x01(\x01R\x10fullWidthHalfMaxB\x05\xaa\x01\x02\x08\x01\x124\n\x12solar_illumination\x18\x04 \x01(\x01R\x11solarIlluminationB\x05\xaa\x01\x02\x08\x01"\xc2\x01\n\x10RasterProperties\x12<\n\x08sampling\x18\x01 \x01(\x0e2 .datasets.stac.v1.RasterSamplingR\x08sampling\x12\x1b\n\x05scale\x18\x02 \x01(\x01R\x05scaleB\x05\xaa\x01\x02\x08\x01\x12\x1d\n\x06offset\x18\x03 \x01(\x01R\x06offsetB\x05\xaa\x01\x02\x08\x01\x124\n\x12spatial_resolution\x18\x04 \x01(\x01R\x11spatialResolutionB\x05\xaa\x01\x02\x08\x01"\x9c\x02\n\x13ClassificationClass\x12\x1b\n\x05value\x18\x01 \x01(\x03R\x05valueB\x05\xaa\x01\x02\x08\x01\x12\'\n\x0bdescription\x18\x02 \x01(\tR\x0bdescriptionB\x05\xaa\x01\x02\x08\x01\x12\x19\n\x04name\x18\x03 \x01(\tR\x04nameB\x05\xaa\x01\x02\x08\x01\x12\x1b\n\x05title\x18\x04 \x01(\tR\x05titleB\x05\xaa\x01\x02\x08\x01\x12$\n\ncolor_hint\x18\x05 \x01(\tR\tcolorHintB\x05\xaa\x01\x02\x08\x01\x12\x1d\n\x06nodata\x18\x06 \x01(\x08R\x06nodataB\x05\xaa\x01\x02\x08\x01\x12%\n\npercentage\x18\x07 \x01(\x01R\npercentageB\x05\xaa\x01\x02\x08\x01\x12\x1b\n\x05count\x18\x08 \x01(\x04R\x05countB\x05\xaa\x01\x02\x08\x01"o\n\nProjection\x12\x19\n\x04code\x18\x01 \x01(\tR\x04codeB\x05\xaa\x01\x02\x08\x01\x12\x12\n\x04bbox\x18\x02 \x03(\x01R\x04bbox\x12\x14\n\x05shape\x18\x03 \x03(\x03R\x05shape\x12\x1c\n\ttransform\x18\x04 \x03(\x01R\ttransform"W\n\x04View\x12.\n\x0fincidence_angle\x18\x01 \x01(\x01R\x0eincidenceAngleB\x05\xaa\x01\x02\x08\x01\x12\x1f\n\x07azimuth\x18\x02 \x01(\x01R\x07azimuthB\x05\xaa\x01\x02\x08\x01"j\n\x04File\x12!\n\x08checksum\x18\x01 \x01(\x0cR\x08checksumB\x05\xaa\x01\x02\x08\x01\x12\x19\n\x04size\x18\x02 \x01(\x04R\x04sizeB\x05\xaa\x01\x02\x08\x01\x12$\n\nlocal_path\x18\x03 \x01(\tR\tlocalPathB\x05\xaa\x01\x02\x08\x01*\xc9\x04\n\x0cEOCommonName\x12\x1e\n\x1aEO_COMMON_NAME_UNSPECIFIED\x10\x00\x12\x16\n\x12EO_COMMON_NAME_PAN\x10\x01\x12\x1a\n\x16EO_COMMON_NAME_COASTAL\x10\x02\x12\x17\n\x13EO_COMMON_NAME_BLUE\x10\x03\x12\x18\n\x14EO_COMMON_NAME_GREEN\x10\x04\x12\x1a\n\x16EO_COMMON_NAME_GREEN05\x10\x05\x12\x19\n\x15EO_COMMON_NAME_YELLOW\x10\x06\x12\x16\n\x12EO_COMMON_NAME_RED\x10\x07\x12\x1a\n\x16EO_COMMON_NAME_REDEDGE\x10\x08\x12\x1d\n\x19EO_COMMON_NAME_REDEDGE071\x10\t\x12\x1d\n\x19EO_COMMON_NAME_REDEDGE075\x10\n\x12\x1d\n\x19EO_COMMON_NAME_REDEDGE078\x10\x0b\x12\x16\n\x12EO_COMMON_NAME_NIR\x10\x0c\x12\x18\n\x14EO_COMMON_NAME_NIR08\x10\r\x12\x18\n\x14EO_COMMON_NAME_NIR09\x10\x0e\x12\x19\n\x15EO_COMMON_NAME_CIRRUS\x10\x0f\x12\x19\n\x15EO_COMMON_NAME_SWIR16\x10\x10\x12\x19\n\x15EO_COMMON_NAME_SWIR22\x10\x11\x12\x17\n\x13EO_COMMON_NAME_LWIR\x10\x12\x12\x19\n\x15EO_COMMON_NAME_LWIR11\x10\x13\x12\x19\n\x15EO_COMMON_NAME_LWIR12\x10\x14*f\n\x0eRasterSampling\x12\x1f\n\x1bRASTER_SAMPLING_UNSPECIFIED\x10\x00\x12\x18\n\x14RASTER_SAMPLING_AREA\x10\x01\x12\x19\n\x15RASTER_SAMPLING_POINT\x10\x02B\x05\x92\x03\x02\x08\x02b\x08editionsp\xe8\x07',
b'\n%datasets/stac/v1/asset_metadata.proto\x12\x10datasets.stac.v1"\xef\x01\n\x0cEOProperties\x12?\n\x0bcommon_name\x18\x01 \x01(\x0e2\x1e.datasets.stac.v1.EOCommonNameR\ncommonName\x122\n\x11center_wavelength\x18\x02 \x01(\x01R\x10centerWavelengthB\x05\xaa\x01\x02\x08\x01\x124\n\x13full_width_half_max\x18\x03 \x01(\x01R\x10fullWidthHalfMaxB\x05\xaa\x01\x02\x08\x01\x124\n\x12solar_illumination\x18\x04 \x01(\x01R\x11solarIlluminationB\x05\xaa\x01\x02\x08\x01"\xc2\x01\n\x10RasterProperties\x12<\n\x08sampling\x18\x01 \x01(\x0e2 .datasets.stac.v1.RasterSamplingR\x08sampling\x12\x1b\n\x05scale\x18\x02 \x01(\x01R\x05scaleB\x05\xaa\x01\x02\x08\x01\x12\x1d\n\x06offset\x18\x03 \x01(\x01R\x06offsetB\x05\xaa\x01\x02\x08\x01\x124\n\x12spatial_resolution\x18\x04 \x01(\x01R\x11spatialResolutionB\x05\xaa\x01\x02\x08\x01"\x9c\x02\n\x13ClassificationClass\x12\x1b\n\x05value\x18\x01 \x01(\x03R\x05valueB\x05\xaa\x01\x02\x08\x01\x12\'\n\x0bdescription\x18\x02 \x01(\tR\x0bdescriptionB\x05\xaa\x01\x02\x08\x01\x12\x19\n\x04name\x18\x03 \x01(\tR\x04nameB\x05\xaa\x01\x02\x08\x01\x12\x1b\n\x05title\x18\x04 \x01(\tR\x05titleB\x05\xaa\x01\x02\x08\x01\x12$\n\ncolor_hint\x18\x05 \x01(\tR\tcolorHintB\x05\xaa\x01\x02\x08\x01\x12\x1d\n\x06nodata\x18\x06 \x01(\x08R\x06nodataB\x05\xaa\x01\x02\x08\x01\x12%\n\npercentage\x18\x07 \x01(\x01R\npercentageB\x05\xaa\x01\x02\x08\x01\x12\x1b\n\x05count\x18\x08 \x01(\x04R\x05countB\x05\xaa\x01\x02\x08\x01"\xf1\x01\n\x16ClassificationBitField\x12\x1d\n\x06offset\x18\x01 \x01(\rR\x06offsetB\x05\xaa\x01\x02\x08\x01\x12\x1d\n\x06length\x18\x02 \x01(\rR\x06lengthB\x05\xaa\x01\x02\x08\x01\x12?\n\x07classes\x18\x03 \x03(\x0b2%.datasets.stac.v1.ClassificationClassR\x07classes\x12\x19\n\x04name\x18\x04 \x01(\tR\x04nameB\x05\xaa\x01\x02\x08\x01\x12\'\n\x0bdescription\x18\x05 \x01(\tR\x0bdescriptionB\x05\xaa\x01\x02\x08\x01\x12\x14\n\x05roles\x18\x06 \x03(\tR\x05roles"o\n\nProjection\x12\x19\n\x04code\x18\x01 \x01(\tR\x04codeB\x05\xaa\x01\x02\x08\x01\x12\x12\n\x04bbox\x18\x02 \x03(\x01R\x04bbox\x12\x14\n\x05shape\x18\x03 \x03(\x03R\x05shape\x12\x1c\n\ttransform\x18\x04 \x03(\x01R\ttransform"W\n\x04View\x12.\n\x0fincidence_angle\x18\x01 \x01(\x01R\x0eincidenceAngleB\x05\xaa\x01\x02\x08\x01\x12\x1f\n\x07azimuth\x18\x02 \x01(\x01R\x07azimuthB\x05\xaa\x01\x02\x08\x01"j\n\x04File\x12!\n\x08checksum\x18\x01 \x01(\x0cR\x08checksumB\x05\xaa\x01\x02\x08\x01\x12\x19\n\x04size\x18\x02 \x01(\x04R\x04sizeB\x05\xaa\x01\x02\x08\x01\x12$\n\nlocal_path\x18\x03 \x01(\tR\tlocalPathB\x05\xaa\x01\x02\x08\x01*\xc9\x04\n\x0cEOCommonName\x12\x1e\n\x1aEO_COMMON_NAME_UNSPECIFIED\x10\x00\x12\x16\n\x12EO_COMMON_NAME_PAN\x10\x01\x12\x1a\n\x16EO_COMMON_NAME_COASTAL\x10\x02\x12\x17\n\x13EO_COMMON_NAME_BLUE\x10\x03\x12\x18\n\x14EO_COMMON_NAME_GREEN\x10\x04\x12\x1a\n\x16EO_COMMON_NAME_GREEN05\x10\x05\x12\x19\n\x15EO_COMMON_NAME_YELLOW\x10\x06\x12\x16\n\x12EO_COMMON_NAME_RED\x10\x07\x12\x1a\n\x16EO_COMMON_NAME_REDEDGE\x10\x08\x12\x1d\n\x19EO_COMMON_NAME_REDEDGE071\x10\t\x12\x1d\n\x19EO_COMMON_NAME_REDEDGE075\x10\n\x12\x1d\n\x19EO_COMMON_NAME_REDEDGE078\x10\x0b\x12\x16\n\x12EO_COMMON_NAME_NIR\x10\x0c\x12\x18\n\x14EO_COMMON_NAME_NIR08\x10\r\x12\x18\n\x14EO_COMMON_NAME_NIR09\x10\x0e\x12\x19\n\x15EO_COMMON_NAME_CIRRUS\x10\x0f\x12\x19\n\x15EO_COMMON_NAME_SWIR16\x10\x10\x12\x19\n\x15EO_COMMON_NAME_SWIR22\x10\x11\x12\x17\n\x13EO_COMMON_NAME_LWIR\x10\x12\x12\x19\n\x15EO_COMMON_NAME_LWIR11\x10\x13\x12\x19\n\x15EO_COMMON_NAME_LWIR12\x10\x14*f\n\x0eRasterSampling\x12\x1f\n\x1bRASTER_SAMPLING_UNSPECIFIED\x10\x00\x12\x18\n\x14RASTER_SAMPLING_AREA\x10\x01\x12\x19\n\x15RASTER_SAMPLING_POINT\x10\x02B\x05\x92\x03\x02\x08\x02b\x08editionsp\xe8\x07',
[],
{
"EOProperties": EOProperties,
"RasterProperties": RasterProperties,
"ClassificationClass": ClassificationClass,
"ClassificationBitField": ClassificationBitField,
"Projection": Projection,
"View": View,
"File": File,
Expand Down
Loading
Loading