Skip to content

Commit 11b7d4d

Browse files
Dataset field annotations for roles, queryables and json path mapping (#44)
* Update protos * Dataset field annotations for roles, queryables and json path mapping
1 parent b59f06f commit 11b7d4d

38 files changed

Lines changed: 1655 additions & 41 deletions

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [0.56.0] - 2026-07-16
1111

12+
### Added
13+
14+
- `tilebox-datasets`: Added dataset field annotations for source JSON pointers, queryable fields, JSON Schema references,
15+
and semantic roles, including STAC mappings for well-known dataset fields.
16+
1217
### Changed
1318

1419
- `tilebox-workflows`: Reduced import-time overhead for release runners by lazily loading optional heavy dependencies
@@ -412,7 +417,8 @@ the first client that does not cache data (since it's already on the local file
412417
- Released under the [MIT](https://opensource.org/license/mit) license.
413418
- Released packages: `tilebox-datasets`, `tilebox-workflows`, `tilebox-storage`, `tilebox-grpc`
414419

415-
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.55.1...HEAD
420+
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.56.0...HEAD
421+
[0.56.0]: https://github.com/tilebox/tilebox-python/compare/v0.55.1...v0.56.0
416422
[0.55.1]: https://github.com/tilebox/tilebox-python/compare/v0.55.0...v0.55.1
417423
[0.55.0]: https://github.com/tilebox/tilebox-python/compare/v0.54.0...v0.55.0
418424
[0.54.0]: https://github.com/tilebox/tilebox-python/compare/v0.53.0...v0.54.0

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ exclude = [
6969
"*/.venv/*",
7070
# it's auto generated, don't lint it
7171
"*/datasets/v1/*",
72+
"*/datasets/stac/v1/*",
7273
"*/workflows/v1/*",
7374
"*/tilebox/v1/*",
74-
"*/buf/validate/*",
7575
]
7676

7777
[tool.ruff.lint]
@@ -85,7 +85,7 @@ ignore = [
8585
"DTZ", # flake8-datetimez: utc datetimes are useful
8686
"DJ", # flake8-django: not needed
8787
"EM", # flake8-errmsg: str directly in Exception constructor is accetable
88-
"TCH", # flake8-type-checking: type checking blocks are weird
88+
"TC", # flake8-type-checking: type checking blocks are weird
8989
# specific rules
9090
"ANN401", # any-type: allow Any in *args and **kwargs
9191
"S101", # assert: allow usage of assert

tilebox-datasets/tests/data/datasets.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
Field,
3232
FieldAnnotation,
3333
FieldDict,
34+
FieldRole,
3435
ListDatasetsResponse,
3536
)
3637
from tilebox.datasets.message_pool import register_once
@@ -41,7 +42,11 @@ def field_annotations(draw: DrawFn) -> FieldAnnotation:
4142
"""A hypothesis strategy for generating random field annotations"""
4243
description = draw(text(alphabet=string.ascii_letters, min_size=3, max_size=25))
4344
example_value = draw(text(alphabet=string.ascii_letters + string.digits + "-_", min_size=1, max_size=10))
44-
return FieldAnnotation(description, example_value)
45+
source_json_pointer = draw(text(alphabet=string.ascii_letters + string.digits + "/_-", max_size=25) | none())
46+
queryable = draw(booleans())
47+
json_schema_ref = draw(text(alphabet=string.ascii_letters + string.digits + "#/_-", max_size=25) | none())
48+
roles = draw(lists(sampled_from(FieldRole), unique=True))
49+
return FieldAnnotation(description, example_value, source_json_pointer, queryable, json_schema_ref, roles)
4550

4651

4752
@composite
@@ -73,12 +78,22 @@ def field_dicts(draw: DrawFn) -> FieldDict:
7378
)
7479
)
7580
annotation = draw(field_annotations())
81+
roles = draw(
82+
one_of(
83+
lists(sampled_from(FieldRole), unique=True),
84+
lists(just("primary_title"), unique=True),
85+
)
86+
)
7687

7788
return {
7889
"name": name,
7990
"type": field_type,
8091
"description": annotation.description,
8192
"example_value": annotation.example_value,
93+
"source_json_pointer": annotation.source_json_pointer,
94+
"queryable": annotation.queryable,
95+
"json_schema_ref": annotation.json_schema_ref,
96+
"roles": roles,
8297
}
8398

8499

@@ -104,8 +119,7 @@ def fields(draw: DrawFn) -> Field:
104119
descriptor = FieldDescriptorProto(name=name, type=field_type, type_name=type_name, label=label)
105120

106121
annotation = draw(field_annotations())
107-
queryable = draw(booleans())
108-
return Field(descriptor, annotation, queryable)
122+
return Field(descriptor, annotation)
109123

110124

111125
@composite

tilebox-datasets/tests/data/test_datasets.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from unittest.mock import MagicMock, patch
2+
from uuid import uuid4
3+
4+
import pytest
15
from hypothesis import given
26

37
from tests.data.datasets import (
@@ -14,26 +18,47 @@
1418
AnnotatedType,
1519
Dataset,
1620
DatasetGroup,
21+
DatasetKind,
1722
DatasetType,
1823
Field,
1924
FieldAnnotation,
2025
FieldDict,
26+
FieldRole,
2127
ListDatasetsResponse,
2228
)
29+
from tilebox.datasets.service import TileboxDatasetService
2330

2431

2532
@given(field_annotations())
2633
def test_field_annotations_to_message_and_back(annotation: FieldAnnotation) -> None:
2734
assert FieldAnnotation.from_message(annotation.to_message()) == annotation
2835

2936

37+
@pytest.mark.parametrize("attribute", ["source_json_pointer", "json_schema_ref"])
38+
def test_optional_field_annotation_presence(attribute: str) -> None:
39+
absent = FieldAnnotation("description", "example").to_message()
40+
assert not absent.HasField(attribute)
41+
42+
if attribute == "source_json_pointer":
43+
present = FieldAnnotation("description", "example", source_json_pointer="").to_message()
44+
else:
45+
present = FieldAnnotation("description", "example", json_schema_ref="").to_message()
46+
assert present.HasField(attribute)
47+
48+
3049
@given(field_dicts())
3150
def test_field_from_dict(field_dict: FieldDict) -> None:
3251
field = Field.from_dict(field_dict)
3352
assert field.descriptor.name == field_dict["name"]
3453
assert field.descriptor.type is not None
3554
assert field.annotation.description == field_dict.get("description", "")
3655
assert field.annotation.example_value == field_dict.get("example_value", "")
56+
assert field.annotation.source_json_pointer == field_dict.get("source_json_pointer")
57+
assert field.annotation.queryable == field_dict.get("queryable", False)
58+
assert field.annotation.json_schema_ref == field_dict.get("json_schema_ref")
59+
assert field.annotation.roles == [
60+
FieldRole[role.upper()] if isinstance(role, str) else role for role in field_dict.get("roles", [])
61+
]
3762

3863

3964
@given(fields())
@@ -64,3 +89,44 @@ def test_dataset_groups_to_message_and_back(group: DatasetGroup) -> None:
6489
@given(list_datasets_responses())
6590
def test_list_datasets_responses_to_message_and_back(response: ListDatasetsResponse) -> None:
6691
assert ListDatasetsResponse.from_message(response.to_message()) == response
92+
93+
94+
@pytest.mark.parametrize("operation", ["create", "update"])
95+
def test_create_and_update_dataset_include_field_annotations(operation: str) -> None:
96+
dataset_service = MagicMock()
97+
service = TileboxDatasetService(dataset_service, MagicMock(), MagicMock(), MagicMock())
98+
custom_fields: list[FieldDict] = [
99+
{
100+
"name": "title",
101+
"type": str,
102+
"source_json_pointer": "/properties/title",
103+
"queryable": True,
104+
"json_schema_ref": "https://example.com/schema.json#/title",
105+
"roles": [FieldRole.PRIMARY_TITLE],
106+
}
107+
]
108+
109+
with patch.object(Dataset, "from_message", side_effect=lambda message: message):
110+
if operation == "create":
111+
service.create_dataset(DatasetKind.SPATIOTEMPORAL, "example", "Example", custom_fields).get()
112+
request = dataset_service.CreateDataset.call_args.args[0]
113+
else:
114+
service.update_dataset(DatasetKind.SPATIOTEMPORAL, uuid4(), "Example", custom_fields).get()
115+
request = dataset_service.UpdateDataset.call_args.args[0]
116+
117+
annotations = {field.descriptor.name: field.annotation for field in request.type.fields}
118+
assert annotations["time"].source_json_pointer == "/properties/datetime"
119+
assert annotations["time"].json_schema_ref.endswith("datetime.json#/properties/datetime")
120+
assert annotations["id"].source_json_pointer == "/properties/tilebox_id"
121+
assert not annotations["id"].HasField("json_schema_ref")
122+
assert annotations["ingestion_time"].source_json_pointer == "/properties/created"
123+
assert annotations["ingestion_time"].json_schema_ref.endswith("datetime.json#/properties/created")
124+
assert annotations["geometry"].source_json_pointer == "/geometry"
125+
assert annotations["geometry"].json_schema_ref == "https://geojson.org/schema/Geometry.json"
126+
assert all(
127+
not annotation.queryable and not annotation.roles for name, annotation in annotations.items() if name != "title"
128+
)
129+
assert annotations["title"].source_json_pointer == "/properties/title"
130+
assert annotations["title"].queryable
131+
assert annotations["title"].json_schema_ref == "https://example.com/schema.json#/title"
132+
assert list(annotations["title"].roles) == [FieldRole.PRIMARY_TITLE.value]

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

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from dataclasses import dataclass
2+
from dataclasses import field as dataclass_field
23
from datetime import datetime, timedelta
34
from enum import Enum
4-
from typing import TypedDict, get_args, get_origin
5+
from typing import Literal, TypeAlias, TypedDict, get_args, get_origin
56
from uuid import UUID
67

78
import numpy as np
@@ -24,17 +25,56 @@ class DatasetKind(Enum):
2425
_dataset_kind_int_to_enum = {kind.value: kind for kind in DatasetKind}
2526

2627

28+
class FieldRole(Enum):
29+
PRIMARY_TITLE = dataset_type_pb2.FIELD_ROLE_PRIMARY_TITLE
30+
"""The primary human-readable title or name used when displaying a datapoint."""
31+
32+
33+
_field_roles_from_string = {role.name.lower(): role for role in FieldRole}
34+
_field_role_int_to_enum = {role.value: role for role in FieldRole}
35+
FieldRolesLike: TypeAlias = list[FieldRole] | list[Literal["primary_title"]]
36+
37+
2738
@dataclass(frozen=True)
2839
class FieldAnnotation:
2940
description: str
41+
"""A human-readable description of the field."""
3042
example_value: str
43+
"""An example value illustrating the field's expected contents."""
44+
source_json_pointer: str | None = None
45+
"""An RFC 6901 JSON Pointer locating the field value in the source document."""
46+
queryable: bool = False
47+
"""Whether the field is available for use in server-side filter expressions."""
48+
json_schema_ref: str | None = None
49+
"""A JSON Schema reference for the field when it corresponds to a well-known schema."""
50+
roles: list[FieldRole] = dataclass_field(default_factory=list)
51+
"""Semantic display roles fulfilled by the field."""
3152

3253
@classmethod
3354
def from_message(cls, annotation: dataset_type_pb2.FieldAnnotation) -> "FieldAnnotation":
34-
return cls(description=annotation.description, example_value=annotation.example_value)
55+
return cls(
56+
description=annotation.description,
57+
example_value=annotation.example_value,
58+
source_json_pointer=(
59+
annotation.source_json_pointer if annotation.HasField("source_json_pointer") else None
60+
),
61+
queryable=annotation.queryable,
62+
json_schema_ref=annotation.json_schema_ref if annotation.HasField("json_schema_ref") else None,
63+
roles=[_field_role_int_to_enum[role] for role in annotation.roles],
64+
)
3565

3666
def to_message(self) -> dataset_type_pb2.FieldAnnotation:
37-
return dataset_type_pb2.FieldAnnotation(description=self.description, example_value=self.example_value)
67+
annotation = dataset_type_pb2.FieldAnnotation(
68+
description=self.description,
69+
example_value=self.example_value,
70+
queryable=self.queryable,
71+
roles=[role.value for role in self.roles],
72+
)
73+
if self.source_json_pointer is not None:
74+
annotation.source_json_pointer = self.source_json_pointer
75+
if self.json_schema_ref is not None:
76+
annotation.json_schema_ref = self.json_schema_ref
77+
return annotation
3878

3979

4080
class FieldDict(TypedDict):
@@ -63,6 +103,10 @@ class FieldDict(TypedDict):
63103
]
64104
description: NotRequired[str]
65105
example_value: NotRequired[str]
106+
source_json_pointer: NotRequired[str | None]
107+
queryable: NotRequired[bool]
108+
json_schema_ref: NotRequired[str | None]
109+
roles: NotRequired[FieldRolesLike]
66110

67111

68112
_TYPE_INFO: dict[type, tuple[FieldDescriptorProto.Type.ValueType, str | None]] = {
@@ -83,14 +127,12 @@ class FieldDict(TypedDict):
83127
class Field:
84128
descriptor: FieldDescriptorProto
85129
annotation: FieldAnnotation
86-
queryable: bool
87130

88131
@classmethod
89132
def from_message(cls, field: dataset_type_pb2.Field) -> "Field":
90133
return cls(
91134
descriptor=field.descriptor,
92135
annotation=FieldAnnotation.from_message(field.annotation),
93-
queryable=field.annotation.queryable,
94136
)
95137

96138
@classmethod
@@ -116,16 +158,19 @@ def from_dict(cls, field: FieldDict) -> "Field":
116158
annotation=FieldAnnotation(
117159
description=field.get("description", ""),
118160
example_value=field.get("example_value", ""),
161+
source_json_pointer=field.get("source_json_pointer"),
162+
queryable=field.get("queryable", False),
163+
json_schema_ref=field.get("json_schema_ref"),
164+
roles=[
165+
_field_roles_from_string[role] if isinstance(role, str) else role for role in field.get("roles", [])
166+
],
119167
),
120-
queryable=False,
121168
)
122169

123170
def to_message(self) -> dataset_type_pb2.Field:
124-
annotation = self.annotation.to_message()
125-
annotation.queryable = self.queryable
126171
return dataset_type_pb2.Field(
127172
descriptor=self.descriptor,
128-
annotation=annotation,
173+
annotation=self.annotation.to_message(),
129174
)
130175

131176

tilebox-datasets/tilebox/datasets/datasets/__init__.py

Whitespace-only changes.

tilebox-datasets/tilebox/datasets/datasets/stac/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)