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

Filter by extension

Filter by extension


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

## [Unreleased]

## [0.55.0] - 2026-07-01

### Added

- `tilebox-workflows`: Added workflow management client methods for creating, listing, finding, updating, and deleting
workflows.
- `tilebox-workflows`: Added workflow release deployment client methods for unpublishing, deploying, and undeploying
releases.
- `tilebox-workflows`: Added `ClusterClient.update()` and optional cluster slugs for `ClusterClient.create()`
- `tilebox-workflows`: Cluster slug filters for `JobClient.query()`.

## [0.54.0] - 2026-06-17

### Added
Expand Down Expand Up @@ -383,7 +394,8 @@ the first client that does not cache data (since it's already on the local file
- Released under the [MIT](https://opensource.org/license/mit) license.
- Released packages: `tilebox-datasets`, `tilebox-workflows`, `tilebox-storage`, `tilebox-grpc`

[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.54.0...HEAD
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.55.0...HEAD
[0.55.0]: https://github.com/tilebox/tilebox-python/compare/v0.54.0...v0.55.0
[0.54.0]: https://github.com/tilebox/tilebox-python/compare/v0.53.0...v0.54.0
[0.53.0]: https://github.com/tilebox/tilebox-python/compare/v0.52.0...v0.53.0
[0.52.0]: https://github.com/tilebox/tilebox-python/compare/v0.51.0...v0.52.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
npdtypes.BoolDType: False,
npdtypes.StrDType: "",
npdtypes.ObjectDType: None,
npdtypes.DateTime64DType: np.datetime64("NaT"),
npdtypes.TimeDelta64DType: np.timedelta64("NaT"),
npdtypes.DateTime64DType: np.datetime64("NaT", "ns"),
npdtypes.TimeDelta64DType: np.timedelta64("NaT", "ns"),
}


Expand Down
5 changes: 1 addition & 4 deletions tilebox-storage/tests/test_providers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import re
from typing import cast

import pytest
import responses
from niquests import AsyncSession
from niquests.cookies import RequestsCookieJar

from tilebox.storage.providers import _asf_login

Expand All @@ -18,12 +16,11 @@ async def test_asf_login() -> None:
responses.add(responses.GET, ASF_LOGIN_URL, headers={"Set-Cookie": "logged_in=yes"})

client = await _asf_login(("username", "password"))
cookies = cast(RequestsCookieJar, client.cookies)

assert isinstance(client, AsyncSession)
assert "asf_search" in str(client.headers["Client-Id"])
assert client.auth == ("username", "password")
assert cookies["logged_in"] == "yes"
assert client.cookies["logged_in"] == "yes"

await client.close()

Expand Down
39 changes: 37 additions & 2 deletions tilebox-workflows/tests/clusters/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
GetClusterRequest,
ListClustersRequest,
ListClustersResponse,
UpdateClusterRequest,
)
from tilebox.workflows.workflows.v1.workflows_pb2_grpc import WorkflowsServiceStub

Expand All @@ -30,7 +31,19 @@ def __init__(self) -> None:
self.clusters: dict[str, ClusterMessage] = {}

def CreateCluster(self, req: CreateClusterRequest) -> ClusterMessage: # noqa: N802
cluster = ClusterMessage(slug=str(uuid4()), display_name=req.name)
cluster = ClusterMessage(slug=req.slug or str(uuid4()), display_name=req.name, description=req.description)
self.clusters[cluster.slug] = cluster
return cluster

def UpdateCluster(self, req: UpdateClusterRequest) -> ClusterMessage: # noqa: N802
if req.cluster_slug not in self.clusters:
raise NotFoundError(f"Cluster {req.cluster_slug} not found")

cluster = ClusterMessage.FromString(self.clusters[req.cluster_slug].SerializeToString())
if req.HasField("name"):
cluster.display_name = req.name
if req.HasField("description"):
cluster.description = req.description
self.clusters[cluster.slug] = cluster
return cluster

Expand All @@ -50,6 +63,18 @@ def ListClusters(self, req: ListClustersRequest) -> ListClustersResponse: # noq
return ListClustersResponse(clusters=list(self.clusters.values()))


def test_create_cluster_can_use_custom_slug() -> None:
service = ClusterService(MagicMock())
service.service = MockClusterService()
cluster_client = ClusterClient(service)

cluster = cluster_client.create("Test Cluster", description="Test description", slug="test-cluster")

assert cluster.slug == "test-cluster"
assert cluster.display_name == "Test Cluster"
assert cluster.description == "Test description"


class ClusterCRUDOperations(RuleBasedStateMachine):
"""
A state machine that tests the CRUD operations of the Clusters client.
Expand All @@ -75,7 +100,17 @@ def __init__(self) -> None:
@rule(target=inserted_clusters, cluster=clusters())
def create_cluster(self, cluster: Cluster) -> Cluster:
self.count_clusters += 1
return self.cluster_client.create(cluster.display_name)
created = self.cluster_client.create(cluster.display_name, cluster.description)
assert created.description == cluster.description
return created

@rule(target=inserted_clusters, cluster=consumes(inserted_clusters), updated=clusters())
def update_cluster(self, cluster: Cluster, updated: Cluster) -> Cluster:
got = self.cluster_client.update(cluster.slug, name=updated.display_name, description=updated.description)
assert got.slug == cluster.slug
assert got.display_name == updated.display_name
assert got.description == (updated.description if updated.description is not None else cluster.description)
return got

@rule(cluster=inserted_clusters)
def get_cluster(self, cluster: Cluster) -> None:
Expand Down
35 changes: 34 additions & 1 deletion tilebox-workflows/tests/jobs/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unittest.mock import MagicMock
from uuid import UUID, uuid4

import pytest
from hypothesis.stateful import Bundle, RuleBasedStateMachine, consumes, rule
from tests.tasks_data import jobs

Expand Down Expand Up @@ -141,6 +142,7 @@ class MockJobService(JobServiceStub):

def __init__(self) -> None:
self.jobs: dict[UUID, JobMessage] = {}
self.query_requests: list[QueryJobsRequest] = []

def SubmitJob(self, req: SubmitJobRequest) -> JobMessage: # noqa: N802
job_id = uuid4()
Expand Down Expand Up @@ -193,10 +195,41 @@ def VisualizeJob(self, req: VisualizeJobRequest) -> Diagram: # noqa: N802
return Diagram(svg=b"<svg><text>Job queued</text></svg>")

def QueryJobs(self, req: QueryJobsRequest) -> QueryJobsResponse: # noqa: N802
_ = req
self.query_requests.append(req)
return QueryJobsResponse(jobs=list(self.jobs.values()))


def test_query_filters_by_clusters() -> None:
service = JobService(MagicMock())
mock_service = MockJobService()
service.service = mock_service
job_client = JobClient(service, MagicMock(), NoopWorkflowTracer())

job_client.query((uuid4(), uuid4()), clusters=["cluster-a", "cluster-b"])

assert list(mock_service.query_requests[-1].filters.cluster_slugs) == ["cluster-a", "cluster-b"]


def test_query_empty_cluster_list_applies_no_cluster_filter() -> None:
service = JobService(MagicMock())
mock_service = MockJobService()
service.service = mock_service
job_client = JobClient(service, MagicMock(), NoopWorkflowTracer())

job_client.query((uuid4(), uuid4()), clusters=[])

assert list(mock_service.query_requests[-1].filters.cluster_slugs) == []


def test_query_rejects_empty_cluster_slug() -> None:
service = JobService(MagicMock())
service.service = MockJobService()
job_client = JobClient(service, MagicMock(), NoopWorkflowTracer())

with pytest.raises(ValueError, match="explicit cluster slugs"):
job_client.query((uuid4(), uuid4()), clusters="")


class JobOperations(RuleBasedStateMachine):
"""
A state machine that tests the various job operations of the Jobs client.
Expand Down
69 changes: 67 additions & 2 deletions tilebox-workflows/tests/tasks_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,19 @@
from tilebox.datasets.query.id_interval import IDInterval
from tilebox.datasets.query.time_interval import TimeInterval
from tilebox.workflows.data import (
Artifact,
AutomationPrototype,
Cluster,
ComputedTask,
CronTrigger,
ExecutionStats,
FilesystemNode,
Idling,
Job,
JobState,
ProgressIndicator,
QueryFilters,
ReleaseContent,
SingleTaskSubmission,
StorageEventTrigger,
StorageLocation,
Expand All @@ -48,6 +51,8 @@
TaskState,
TaskSubmissionGroup,
TaskSubmissions,
Workflow,
WorkflowRelease,
)


Expand All @@ -63,7 +68,65 @@ def clusters(draw: DrawFn) -> Cluster:
slug = draw(alphanumerical_text(min_size=4, max_size=20))
display_name = draw(alphanumerical_text())
deletable = draw(booleans())
return Cluster(slug, display_name, deletable)
description = draw(alphanumerical_text() | none())
return Cluster(slug, display_name, deletable, description)


@composite
def artifacts(draw: DrawFn) -> Artifact:
"""A hypothesis strategy for generating random workflow release artifacts"""
artifact_id = draw(uuids(version=4))
digest = draw(text(alphabet="abcdef0123456789", min_size=64, max_size=64))
return Artifact(artifact_id, digest)


@composite
def release_filesystem_nodes(draw: DrawFn) -> FilesystemNode:
"""A hypothesis strategy for generating random workflow release filesystem nodes"""
path = draw(alphanumerical_text())
directory = draw(booleans())
children = []
if directory:
children = [
FilesystemNode(draw(alphanumerical_text())) for _ in range(draw(integers(min_value=0, max_value=3)))
]
return FilesystemNode(path, directory, children)


@composite
def release_contents(draw: DrawFn) -> ReleaseContent:
"""A hypothesis strategy for generating random workflow release contents"""
fingerprint = draw(text(alphabet="abcdef0123456789", min_size=64, max_size=64))
return ReleaseContent(
fingerprint=fingerprint,
tasks=draw(lists(task_identifiers(), min_size=1, max_size=3)),
files=draw(lists(release_filesystem_nodes(), min_size=0, max_size=3)),
runner_object_path=draw(alphanumerical_text()),
command_override=draw(lists(alphanumerical_text(), min_size=0, max_size=3)),
)


@composite
def workflow_releases(draw: DrawFn) -> WorkflowRelease:
"""A hypothesis strategy for generating random workflow releases"""
return WorkflowRelease(
id=draw(uuids(version=4)),
artifact=draw(artifacts() | none()),
content=draw(release_contents() | none()),
created_at=draw(datetimes(timezones=just(timezone.utc)) | none()),
clusters=draw(lists(clusters(), min_size=0, max_size=3)),
)


@composite
def workflows(draw: DrawFn) -> Workflow:
"""A hypothesis strategy for generating random workflows"""
return Workflow(
slug=draw(alphanumerical_text(min_size=4, max_size=20)),
name=draw(alphanumerical_text()),
description=draw(alphanumerical_text() | none()) or "",
releases=draw(lists(workflow_releases(), min_size=0, max_size=3)),
)


@composite
Expand Down Expand Up @@ -354,4 +417,6 @@ def query_filters(draw: DrawFn) -> QueryFilters:
if task_states is not None:
task_states = list(set(task_states)) # de-duplicate

return QueryFilters(time_interval, id_interval, automation_ids, job_states, name, task_states)
cluster_slugs = draw(lists(alphanumerical_text(min_size=4, max_size=20), min_size=0, max_size=3, unique=True))

return QueryFilters(time_interval, id_interval, automation_ids, job_states, name, task_states, cluster_slugs)
35 changes: 35 additions & 0 deletions tilebox-workflows/tests/test_data.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from hypothesis import given

from tests.tasks_data import (
artifacts,
automations,
clusters,
computed_tasks,
Expand All @@ -9,30 +10,39 @@
jobs,
progress_indicators,
query_filters,
release_contents,
release_filesystem_nodes,
single_task_submissions,
storage_locations,
task_identifiers,
task_leases,
task_submission_groups,
task_submissions,
tasks,
workflow_releases,
workflows,
)
from tilebox.workflows.data import (
Artifact,
AutomationPrototype,
Cluster,
ComputedTask,
ExecutionStats,
FilesystemNode,
Idling,
Job,
ProgressIndicator,
QueryFilters,
ReleaseContent,
SingleTaskSubmission,
StorageLocation,
Task,
TaskIdentifier,
TaskLease,
TaskSubmissionGroup,
TaskSubmissions,
Workflow,
WorkflowRelease,
)


Expand Down Expand Up @@ -77,6 +87,31 @@ def test_clusters_to_message_and_back(cluster: Cluster) -> None:
assert Cluster.from_message(cluster.to_message()) == cluster


@given(artifacts())
def test_artifacts_to_message_and_back(artifact: Artifact) -> None:
assert Artifact.from_message(artifact.to_message()) == artifact


@given(release_filesystem_nodes())
def test_release_filesystem_nodes_to_message_and_back(node: FilesystemNode) -> None:
assert FilesystemNode.from_message(node.to_message()) == node


@given(release_contents())
def test_release_contents_to_message_and_back(content: ReleaseContent) -> None:
assert ReleaseContent.from_message(content.to_message()) == content


@given(workflow_releases())
def test_workflow_releases_to_message_and_back(release: WorkflowRelease) -> None:
assert WorkflowRelease.from_message(release.to_message()) == release


@given(workflows())
def test_workflows_to_message_and_back(workflow: Workflow) -> None:
assert Workflow.from_message(workflow.to_message()) == workflow


@given(task_submissions())
def test_task_submissions_to_message_and_back(sub_task: TaskSubmissions) -> None:
assert TaskSubmissions.from_message(sub_task.to_message()) == sub_task
Expand Down
Loading
Loading