Skip to content

Commit c290886

Browse files
committed
Add asset state store commands to airflowctl
1 parent 1f529f3 commit c290886

7 files changed

Lines changed: 221 additions & 69 deletions

File tree

airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ def date_param():
5656
"assets list",
5757
"assets get 1",
5858
"assets create-event --asset-id=1",
59+
"assets set-state-store 1 test_key test_value",
60+
"assets get-state-store 1 test_key",
61+
"assets list-state-store 1",
62+
"assets delete-state-store 1 test_key",
63+
"assets clear-state-store 1",
5964
# Backfill commands
6065
"backfill list example_bash_operator",
6166
# Config commands

airflow-ctl/docs/images/command_hashes.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
main:2c358f5fc894541cab11854dcd67658a
2-
assets:f225579fbecb3d919d152b4f1ba83185
2+
assets:949f2c4ff70e32f46c905931e82dcb56
33
auth:d79e9c7d00c432bdbcbc2a86e2e32053
44
backfill:74c8737b0a62a86ed3605fa9e6165874
55
config:a3d936cb15fe3b547bf6c82cf93d923f

airflow-ctl/docs/images/output_assets.svg

Lines changed: 99 additions & 67 deletions
Loading

airflow-ctl/src/airflowctl/api/operations.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
AssetCollectionResponse,
3333
AssetEventResponse,
3434
AssetResponse,
35+
AssetStateStoreBody,
36+
AssetStateStoreCollectionResponse,
37+
AssetStateStoreResponse,
3538
BackfillCollectionResponse,
3639
BackfillPostBody,
3740
BackfillResponse,
@@ -326,6 +329,39 @@ def delete_queued_event(self, dag_id: str, asset_id: str) -> str | ServerRespons
326329
self.client.delete(f"dags/{dag_id}/assets/{asset_id}/queuedEvents")
327330
return asset_id
328331

332+
def list_state_store(self, asset_id: str) -> AssetStateStoreCollectionResponse | ServerResponseError:
333+
"""List all state store entries for an asset."""
334+
return super().execute_list(
335+
path=f"/assets/{asset_id}/state-store", data_model=AssetStateStoreCollectionResponse
336+
)
337+
338+
def get_state_store(self, asset_id: str, key: str) -> AssetStateStoreResponse | ServerResponseError:
339+
"""Get a single asset state store entry."""
340+
self.response = self.client.get(f"assets/{asset_id}/state-store/{key}")
341+
return AssetStateStoreResponse.model_validate_json(self.response.content)
342+
343+
def set_state_store(self, asset_id: str, key: str, value: str) -> str | ServerResponseError:
344+
"""Set an asset state store value. Creates or overwrites the key."""
345+
try:
346+
parsed_value = json.loads(value)
347+
except (ValueError, TypeError):
348+
parsed_value = value
349+
self.client.put(
350+
f"assets/{asset_id}/state-store/{key}",
351+
json=AssetStateStoreBody(value=parsed_value).model_dump(mode="json"),
352+
)
353+
return key
354+
355+
def delete_state_store(self, asset_id: str, key: str) -> str | ServerResponseError:
356+
"""Delete a single asset state store key."""
357+
self.client.delete(f"assets/{asset_id}/state-store/{key}")
358+
return key
359+
360+
def clear_state_store(self, asset_id: str) -> str | ServerResponseError:
361+
"""Delete all state store keys for an asset."""
362+
self.client.delete(f"assets/{asset_id}/state-store")
363+
return asset_id
364+
329365

330366
class BackfillOperations(BaseOperations):
331367
"""Backfill operations."""

airflow-ctl/src/airflowctl/ctl/cli_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ def __init__(self, file_path: str | Path | None = None):
510510
"trigger",
511511
"add",
512512
"edit",
513+
"set",
513514
"clear",
514515
]
515516
# Datamodels whose generated bool flags follow the datamodel field defaults instead of

airflow-ctl/src/airflowctl/ctl/help_texts.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ assets:
2828
delete-queued-events: "Delete all queued events for a given asset"
2929
delete-dag-queued-events: "Delete all queued asset events for a given Dag"
3030
delete-queued-event: "Delete a specific queued asset event for a given Dag and asset"
31+
list-state-store: "List all state store entries for an asset"
32+
get-state-store: "Retrieve a single asset state store entry by its ID and key"
33+
set-state-store: "Set an asset state store value. Creates or overwrites the key."
34+
delete-state-store: "Delete a single asset state store key by its ID and key"
35+
clear-state-store: "Delete all state store keys for an asset by its ID"
3136

3237
backfill:
3338
create: "Create a backfill job for a given Dag ID and date range"

airflow-ctl/tests/airflow_ctl/api/test_operations.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
AssetCollectionResponse,
3737
AssetEventResponse,
3838
AssetResponse,
39+
AssetStateStoreCollectionResponse,
40+
AssetStateStoreResponse,
3941
BackfillCollectionResponse,
4042
BackfillPostBody,
4143
BackfillResponse,
@@ -321,7 +323,11 @@ class TestAssetsOperations:
321323
queued_events=[asset_queued_event_response],
322324
total_entries=1,
323325
)
324-
326+
asset_state_store_response = AssetStateStoreResponse(
327+
key="my_key",
328+
value={"my_val": 0}, # type: ignore[arg-type]
329+
updated_at=datetime.datetime(2025, 1, 1, 0, 0, 0),
330+
)
325331
dag_run_response = DAGRunResponse(
326332
dag_display_name=dag_id,
327333
dag_run_id=dag_id,
@@ -513,6 +519,73 @@ def handle_request(request: httpx.Request) -> httpx.Response:
513519
response = client.assets.delete_queued_event(dag_id=self.dag_id, asset_id=self.asset_id)
514520
assert response == self.asset_id
515521

522+
def test_list_state_store(self):
523+
collection_response = AssetStateStoreCollectionResponse(
524+
asset_state_store=[self.asset_state_store_response],
525+
total_entries=1,
526+
)
527+
528+
def handle_request(request: httpx.Request) -> httpx.Response:
529+
assert request.url.path == f"/api/v2/assets/{self.asset_id}/state-store"
530+
return httpx.Response(200, json=json.loads(collection_response.model_dump_json()))
531+
532+
client = make_api_client(transport=httpx.MockTransport(handle_request))
533+
response = client.assets.list_state_store(self.asset_id)
534+
assert response == collection_response
535+
536+
def test_get_state_store(self):
537+
key = self.asset_state_store_response.key
538+
539+
def handle_request(request: httpx.Request) -> httpx.Response:
540+
assert request.url.path == f"/api/v2/assets/{self.asset_id}/state-store/{key}"
541+
return httpx.Response(200, json=json.loads(self.asset_state_store_response.model_dump_json()))
542+
543+
client = make_api_client(transport=httpx.MockTransport(handle_request))
544+
response = client.assets.get_state_store(self.asset_id, key)
545+
assert response == self.asset_state_store_response
546+
547+
@pytest.mark.parametrize(
548+
("value", "expected"),
549+
[
550+
('{"index": 0}', {"index": 0}),
551+
("hello", "hello"),
552+
],
553+
)
554+
def test_set_state_store(self, value, expected):
555+
key = self.asset_state_store_response.key
556+
557+
def handle_request(request: httpx.Request) -> httpx.Response:
558+
assert request.method == "PUT"
559+
assert request.url.path == f"/api/v2/assets/{self.asset_id}/state-store/{key}"
560+
assert json.loads(request.content) == {"value": expected}
561+
return httpx.Response(204)
562+
563+
client = make_api_client(transport=httpx.MockTransport(handle_request))
564+
response = client.assets.set_state_store(self.asset_id, key, value)
565+
assert response == key
566+
567+
def test_delete_state_store(self):
568+
key = self.asset_state_store_response.key
569+
570+
def handle_request(request: httpx.Request) -> httpx.Response:
571+
assert request.method == "DELETE"
572+
assert request.url.path == f"/api/v2/assets/{self.asset_id}/state-store/{key}"
573+
return httpx.Response(204)
574+
575+
client = make_api_client(transport=httpx.MockTransport(handle_request))
576+
response = client.assets.delete_state_store(self.asset_id, key)
577+
assert response == key
578+
579+
def test_clear_state_store(self):
580+
def handle_request(request: httpx.Request) -> httpx.Response:
581+
assert request.method == "DELETE"
582+
assert request.url.path == f"/api/v2/assets/{self.asset_id}/state-store"
583+
return httpx.Response(204)
584+
585+
client = make_api_client(transport=httpx.MockTransport(handle_request))
586+
response = client.assets.clear_state_store(self.asset_id)
587+
assert response == self.asset_id
588+
516589

517590
class TestBackfillOperations:
518591
backfill_id: NonNegativeInt = 1

0 commit comments

Comments
 (0)