| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| from __future__ import annotations | |
| import json | |
| import pickle | |
| from datetime import UTC, datetime | |
| from typing import TYPE_CHECKING | |
| from unittest import mock | |
| import httpx2 | |
| import pytest | |
| import time_machine | |
| import truststore | |
| import uuid6 | |
| from task_sdk import make_client, make_client_w_dry_run, make_client_w_responses | |
| from uuid6 import uuid7 | |
| from airflow.sdk import timezone | |
| from airflow.sdk.api.client import Client, RemoteValidationError, ServerResponseError | |
| from airflow.sdk.api.datamodels._generated import ( | |
| AssetEventsResponse, | |
| AssetResponse, | |
| AssetStateStoreResponse, | |
| ConnectionResponse, | |
| DagResponse, | |
| DagRunState, | |
| DagRunStateResponse, | |
| HITLDetailRequest, | |
| HITLDetailResponse, | |
| HITLUser, | |
| TaskStateStoreResponse, | |
| TerminalTIState, | |
| VariableResponse, | |
| XComResponse, | |
| ) | |
| from airflow.sdk.exceptions import ErrorType, TaskAlreadyRunningError | |
| from airflow.sdk.execution_time.comms import ( | |
| AssetsByAliasResult, | |
| DeferTask, | |
| ErrorResponse, | |
| OKResponse, | |
| PreviousDagRunResult, | |
| PreviousTIResult, | |
| RescheduleTask, | |
| TaskRescheduleStartDate, | |
| ) | |
| if TYPE_CHECKING: | |
| from time_machine import TimeMachineFixture | |
| @pytest.fixture | |
| def ca_file(tmp_path): | |
| from datetime import timedelta | |
| from cryptography import x509 | |
| from cryptography.hazmat.primitives import hashes, serialization | |
| from cryptography.hazmat.primitives.asymmetric import ec | |
| from cryptography.x509.oid import NameOID | |
| key = ec.generate_private_key(ec.SECP256R1()) | |
| name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test-ca")]) | |
| now = datetime.now(UTC) | |
| cert = ( | |
| x509.CertificateBuilder() | |
| .subject_name(name) | |
| .issuer_name(name) | |
| .public_key(key.public_key()) | |
| .serial_number(x509.random_serial_number()) | |
| .not_valid_before(now) | |
| .not_valid_after(now + timedelta(days=1)) | |
| .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) | |
| .sign(key, hashes.SHA256()) | |
| ) | |
| path = tmp_path / "ca.pem" | |
| path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) | |
| return str(path) | |
| class TestClient: | |
| @pytest.mark.parametrize( | |
| ("path", "json_response"), | |
| [ | |
| ( | |
| "/task-instances/1/run", | |
| { | |
| "dag_run": { | |
| "dag_id": "test_dag", | |
| "run_id": "test_run", | |
| "logical_date": "2021-01-01T00:00:00Z", | |
| "start_date": "2021-01-01T00:00:00Z", | |
| "run_type": "manual", | |
| "run_after": "2021-01-01T00:00:00Z", | |
| "consumed_asset_events": [], | |
| }, | |
| "max_tries": 0, | |
| "should_retry": False, | |
| }, | |
| ), | |
| ], | |
| ) | |
| def test_dry_run(self, path, json_response): | |
| client = make_client_w_dry_run() | |
| assert client.base_url == "dry-run://server" | |
| resp = client.get(path) | |
| assert resp.status_code == 200 | |
| assert resp.json() == json_response | |
| @mock.patch("airflow.sdk.api.client.API_SSL_CERT_PATH", "/capath/does/not/exist/") | |
| def test_add_capath(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(status_code=200) | |
| with pytest.raises(FileNotFoundError) as err: | |
| make_client(httpx2.MockTransport(handle_request)) | |
| assert isinstance(err.value, FileNotFoundError) | |
| @mock.patch("airflow.sdk.api.client.API_SSL_CA_FILE_PATH", "/capath/does/not/exist/") | |
| def test_ssl_with_cafile(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(status_code=200) | |
| with pytest.raises(FileNotFoundError) as err: | |
| make_client(httpx2.MockTransport(handle_request)) | |
| assert isinstance(err.value, FileNotFoundError) | |
| @mock.patch("airflow.sdk.api.client.API_CLIENT_USE_PUBLIC_CERTS", True) | |
| def test_use_public_certs(self, ca_file): | |
| ctx = Client._get_ssl_context_cached(ca_file, None) | |
| assert isinstance(ctx, truststore.SSLContext) | |
| @mock.patch("airflow.sdk.api.client.API_TIMEOUT", 60.0) | |
| def test_timeout_configuration(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(status_code=200) | |
| client = make_client(httpx2.MockTransport(handle_request)) | |
| assert client.timeout == httpx2.Timeout(60.0) | |
| def test_timeout_can_be_overridden(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(status_code=200) | |
| client = Client( | |
| base_url="test://server", token="", transport=httpx2.MockTransport(handle_request), timeout=120.0 | |
| ) | |
| assert client.timeout == httpx2.Timeout(120.0) | |
| def test_error_parsing(self): | |
| responses = [ | |
| httpx2.Response(422, json={"detail": [{"loc": ["#0"], "msg": "err", "type": "required"}]}) | |
| ] | |
| client = make_client_w_responses(responses) | |
| with pytest.raises(ServerResponseError) as err: | |
| client.get("http://error") | |
| assert isinstance(err.value, ServerResponseError) | |
| assert isinstance(err.value.detail, list) | |
| assert err.value.detail == [ | |
| RemoteValidationError(loc=["#0"], msg="err", type="required"), | |
| ] | |
| def test_error_parsing_plain_text(self): | |
| responses = [httpx2.Response(422, content=b"Internal Server Error")] | |
| client = make_client_w_responses(responses) | |
| with pytest.raises(httpx2.HTTPStatusError) as err: | |
| client.get("http://error") | |
| assert not isinstance(err.value, ServerResponseError) | |
| def test_error_parsing_other_json(self): | |
| responses = [httpx2.Response(404, json={"detail": "Not found"})] | |
| client = make_client_w_responses(responses) | |
| with pytest.raises(ServerResponseError) as err: | |
| client.get("http://error") | |
| assert err.value.args == ("Not found",) | |
| assert err.value.detail is None | |
| def test_server_response_error_pickling(self): | |
| responses = [httpx2.Response(404, json={"detail": {"message": "Invalid input"}})] | |
| client = make_client_w_responses(responses) | |
| with pytest.raises(ServerResponseError) as exc_info: | |
| client.get("http://error") | |
| err = exc_info.value | |
| assert err.args == ("Server returned error",) | |
| assert err.detail == {"message": "Invalid input"} | |
| # Check that the error is picklable | |
| pickled = pickle.dumps(err) | |
| unpickled = pickle.loads(pickled) | |
| assert isinstance(unpickled, ServerResponseError) | |
| # Test that unpickled error has the same attributes as the original | |
| assert unpickled.response.json() == {"detail": {"message": "Invalid input"}} | |
| assert unpickled.detail == {"message": "Invalid input"} | |
| assert unpickled.response.status_code == 404 | |
| assert unpickled.request.url == "http://error" | |
| def test_server_error_detail_added_as_note(self): | |
| """Notes survive uncaught propagation, handled sites still log detail directly.""" | |
| responses = [httpx2.Response(404, json={"detail": {"message": "Invalid input"}})] | |
| client = make_client_w_responses(responses) | |
| with pytest.raises(ServerResponseError) as exc_info: | |
| client.get("http://error") | |
| err = exc_info.value | |
| assert err.args == ("Server returned error",) | |
| assert any( | |
| note.startswith("Server error detail:") and "Invalid input" in note | |
| for note in getattr(err, "__notes__", []) | |
| ), err.__notes__ | |
| def test_retry_handling_unrecoverable_error(self): | |
| with time_machine.travel("2023-01-01T00:00:00Z", tick=False): | |
| responses: list[httpx2.Response] = [ | |
| *[httpx2.Response(500, text="Internal Server Error")] * 6, | |
| httpx2.Response(200, json={"detail": "Recovered from error - but will fail before"}), | |
| httpx2.Response(400, json={"detail": "Should not get here"}), | |
| ] | |
| client = make_client_w_responses(responses) | |
| with pytest.raises(httpx2.HTTPStatusError) as err: | |
| client.get("http://error") | |
| assert not isinstance(err.value, ServerResponseError) | |
| assert len(responses) == 3 | |
| def test_retry_handling_recovered(self): | |
| with time_machine.travel("2023-01-01T00:00:00Z", tick=False): | |
| responses: list[httpx2.Response] = [ | |
| *[httpx2.Response(500, text="Internal Server Error")] * 2, | |
| httpx2.Response(200, json={"detail": "Recovered from error"}), | |
| httpx2.Response(400, json={"detail": "Should not get here"}), | |
| ] | |
| client = make_client_w_responses(responses) | |
| response = client.get("http://error") | |
| assert response.status_code == 200 | |
| assert len(responses) == 1 | |
| def test_retry_handling_non_retry_error(self): | |
| with time_machine.travel("2023-01-01T00:00:00Z", tick=False): | |
| responses: list[httpx2.Response] = [ | |
| httpx2.Response(422, json={"detail": "Somehow this is a bad request"}), | |
| httpx2.Response(400, json={"detail": "Should not get here"}), | |
| ] | |
| client = make_client_w_responses(responses) | |
| with pytest.raises(ServerResponseError) as err: | |
| client.get("http://error") | |
| assert len(responses) == 1 | |
| assert err.value.args == ("Somehow this is a bad request",) | |
| def test_retry_handling_ok(self): | |
| with time_machine.travel("2023-01-01T00:00:00Z", tick=False): | |
| responses: list[httpx2.Response] = [ | |
| httpx2.Response(200, json={"detail": "Recovered from error"}), | |
| httpx2.Response(400, json={"detail": "Should not get here"}), | |
| ] | |
| client = make_client_w_responses(responses) | |
| response = client.get("http://error") | |
| assert response.status_code == 200 | |
| assert len(responses) == 1 | |
| def test_token_renewal(self): | |
| responses: list[httpx2.Response] = [ | |
| httpx2.Response(200, json={"ok": "1"}), | |
| httpx2.Response(404, json={"var": "not_found"}, headers={"Refreshed-API-Token": "abc"}), | |
| httpx2.Response(200, json={"ok": "3"}), | |
| ] | |
| client = make_client_w_responses(responses) | |
| response = client.get("/") | |
| assert response.status_code == 200 | |
| assert client.auth is not None | |
| assert not client.auth.token | |
| with pytest.raises(ServerResponseError): | |
| response = client.get("/") | |
| # Even thought it was Not Found, we should still respect the header | |
| assert client.auth is not None | |
| assert client.auth.token == "abc" | |
| # Test that the next request is made with that new auth token | |
| response = client.get("/") | |
| assert response.status_code == 200 | |
| assert response.request.headers["Authorization"] == "Bearer abc" | |
| @pytest.mark.parametrize( | |
| ("status_code", "description"), | |
| [ | |
| (399, "status code < 400"), | |
| (301, "3xx redirect status code"), | |
| (600, "status code >= 600"), | |
| ], | |
| ) | |
| def test_server_response_error_invalid_status_codes(self, status_code, description): | |
| """Test that ServerResponseError.from_response returns None for invalid status codes.""" | |
| response = httpx2.Response(status_code, json={"detail": f"Test {description}"}) | |
| assert ServerResponseError.from_response(response) is None | |
| @mock.patch("airflow.sdk.api.client.API_CLIENT_SSL_CERT", "/etc/airflow/certs/client.crt") | |
| @mock.patch("airflow.sdk.api.client.API_CLIENT_SSL_KEY", "/etc/airflow/certs/client.key") | |
| def test_sets_cert_tuple(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(status_code=200) | |
| captured: dict[str, object] = {} | |
| real_init = httpx2.Client.__init__ | |
| def spy_init(self, *args, **kwargs): | |
| captured["cert"] = kwargs.get("cert") | |
| return real_init(self, *args, **kwargs) | |
| with mock.patch.object(httpx2.Client, "__init__", spy_init): | |
| make_client(httpx2.MockTransport(handle_request)) | |
| assert captured["cert"] == ("/etc/airflow/certs/client.crt", "/etc/airflow/certs/client.key") | |
| @mock.patch("airflow.sdk.api.client.API_CLIENT_SSL_CERT", "/etc/airflow/certs/client.crt") | |
| @mock.patch("airflow.sdk.api.client.API_CLIENT_SSL_KEY", None) | |
| def test_requires_both_cert_and_key(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(status_code=200) | |
| with pytest.raises(ValueError, match="Both client_ssl_cert and client_ssl_key must be set"): | |
| make_client(httpx2.MockTransport(handle_request)) | |
| class TestTaskInstanceOperations: | |
| """ | |
| Test that the TestTaskInstanceOperations class works as expected. While the operations are simple, it | |
| still catches the basic functionality of the client for task instances including endpoint and | |
| response parsing. | |
| """ | |
| def test_task_instance_start(self, make_ti_context): | |
| with time_machine.travel("2023-01-01T00:00:00Z", tick=False): | |
| # Simulate a successful response from the server that starts a task | |
| ti_id = uuid6.uuid7() | |
| start_date = "2024-10-31T12:00:00Z" | |
| ti_context = make_ti_context( | |
| start_date=start_date, | |
| logical_date="2024-10-31T12:00:00Z", | |
| run_type="manual", | |
| ) | |
| # ...including a validation that retry really works | |
| call_count = 0 | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| nonlocal call_count | |
| call_count += 1 | |
| if call_count < 3: | |
| return httpx2.Response(status_code=500, json={"detail": "Internal Server Error"}) | |
| if request.url.path == f"/task-instances/{ti_id}/run": | |
| actual_body = json.loads(request.read()) | |
| assert actual_body["pid"] == 100 | |
| assert actual_body["start_date"] == start_date | |
| assert actual_body["state"] == "running" | |
| return httpx2.Response( | |
| status_code=200, | |
| json=ti_context.model_dump(mode="json"), | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| resp = client.task_instances.start(ti_id, 100, start_date) | |
| assert resp == ti_context | |
| assert call_count == 3 | |
| @pytest.mark.parametrize( | |
| ("reason", "previous_state"), | |
| [("invalid_state", "running"), ("running_elsewhere", "restarting")], | |
| ) | |
| def test_task_instance_start_already_running(self, reason, previous_state): | |
| """Test that start() raises TaskAlreadyRunningError when TI is already running.""" | |
| ti_id = uuid6.uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{ti_id}/run": | |
| return httpx2.Response( | |
| 409, | |
| json={ | |
| "detail": { | |
| "reason": reason, | |
| "message": "TI was not in a state where it could be marked as running", | |
| "previous_state": previous_state, | |
| } | |
| }, | |
| ) | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| with pytest.raises(TaskAlreadyRunningError, match="already running"): | |
| client.task_instances.start(ti_id, 100, datetime(2024, 10, 31, tzinfo=timezone.utc)) | |
| @pytest.mark.parametrize("previous_state", ["failed", "success", "skipped"]) | |
| def test_task_instance_start_other_invalid_states(self, previous_state): | |
| """Test that start() raises ServerResponseError for non-running invalid states.""" | |
| ti_id = uuid6.uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{ti_id}/run": | |
| return httpx2.Response( | |
| 409, | |
| json={ | |
| "detail": { | |
| "reason": "invalid_state", | |
| "message": "TI was not in a state where it could be marked as running", | |
| "previous_state": previous_state, | |
| } | |
| }, | |
| ) | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| with pytest.raises(ServerResponseError): | |
| client.task_instances.start(ti_id, 100, datetime(2024, 10, 31, tzinfo=timezone.utc)) | |
| @pytest.mark.parametrize( | |
| "state", [state for state in TerminalTIState if state != TerminalTIState.SUCCESS] | |
| ) | |
| @pytest.mark.parametrize("retry_reason", [None, "auth error, do not retry"]) | |
| def test_task_instance_finish(self, state, retry_reason): | |
| # Simulate a successful response from the server that finishes (moved to terminal state) a task | |
| ti_id = uuid6.uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{ti_id}/state": | |
| actual_body = json.loads(request.read()) | |
| assert actual_body["end_date"] == "2024-10-31T12:00:00Z" | |
| assert actual_body["state"] == state | |
| assert actual_body["rendered_map_index"] == "test" | |
| assert actual_body["retry_reason"] == retry_reason | |
| return httpx2.Response( | |
| status_code=204, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| client.task_instances.finish( | |
| ti_id, | |
| state=state, | |
| when="2024-10-31T12:00:00Z", | |
| rendered_map_index="test", | |
| retry_reason=retry_reason, | |
| ) | |
| def test_task_instance_heartbeat(self): | |
| # Simulate a successful response from the server that sends a heartbeat for a ti | |
| ti_id = uuid6.uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{ti_id}/heartbeat": | |
| actual_body = json.loads(request.read()) | |
| assert actual_body["pid"] == 100 | |
| return httpx2.Response( | |
| status_code=204, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| client.task_instances.heartbeat(ti_id, 100) | |
| def test_task_instance_heartbeat_is_not_retried(self): | |
| """A failing heartbeat surfaces at once so the supervisor's own retry budget governs it.""" | |
| ti_id = uuid6.uuid7() | |
| responses: list[httpx2.Response] = [ | |
| httpx2.Response(500, text="Internal Server Error"), | |
| httpx2.Response(204), | |
| ] | |
| client = make_client_w_responses(responses) | |
| with pytest.raises(httpx2.HTTPStatusError): | |
| client.task_instances.heartbeat(ti_id, 100) | |
| assert len(responses) == 1 | |
| def test_task_instance_update_dagrun_note(self): | |
| ti_id = uuid6.uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{ti_id}/dag-run-note": | |
| assert json.loads(request.read()) == {"note": "Updated from task runtime"} | |
| return httpx2.Response(status_code=204) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| response = client.task_instances.update_dagrun_note(ti_id, "Updated from task runtime") | |
| assert response == OKResponse(ok=True) | |
| @pytest.mark.parametrize("queues_enabled", [False, True]) | |
| def test_task_instance_defer(self, queues_enabled: bool): | |
| # Simulate a successful response from the server that defers a task | |
| ti_id = uuid6.uuid7() | |
| task_queue = "test" | |
| msg = DeferTask( | |
| classpath="airflow.providers.standard.triggers.temporal.DateTimeTrigger", | |
| next_method="execute_complete", | |
| trigger_kwargs={ | |
| "__type": "dict", | |
| "__var": { | |
| "moment": {"__type": "datetime", "__var": 1730982899.0}, | |
| "end_from_trigger": False, | |
| }, | |
| }, | |
| next_kwargs={"__type": "dict", "__var": {}}, | |
| queue=task_queue if queues_enabled else None, | |
| ) | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{ti_id}/state": | |
| actual_body = json.loads(request.read()) | |
| assert actual_body["state"] == "deferred" | |
| assert actual_body["trigger_kwargs"] == msg.trigger_kwargs | |
| assert ( | |
| actual_body["classpath"] == "airflow.providers.standard.triggers.temporal.DateTimeTrigger" | |
| ) | |
| assert actual_body["next_method"] == "execute_complete" | |
| if queues_enabled: | |
| assert actual_body["queue"] == task_queue | |
| else: | |
| assert actual_body.get("queue") is None | |
| return httpx2.Response( | |
| status_code=204, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| client.task_instances.defer(ti_id, msg) | |
| def test_task_instance_reschedule(self): | |
| # Simulate a successful response from the server that reschedules a task | |
| ti_id = uuid6.uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{ti_id}/state": | |
| actual_body = json.loads(request.read()) | |
| assert actual_body["state"] == "up_for_reschedule" | |
| assert actual_body["reschedule_date"] == "2024-10-31T12:00:00Z" | |
| assert actual_body["end_date"] == "2024-10-31T12:00:00Z" | |
| return httpx2.Response( | |
| status_code=204, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| msg = RescheduleTask( | |
| reschedule_date=timezone.parse("2024-10-31T12:00:00Z"), | |
| end_date=timezone.parse("2024-10-31T12:00:00Z"), | |
| ) | |
| client.task_instances.reschedule(ti_id, msg) | |
| def test_task_instance_up_for_retry(self): | |
| ti_id = uuid6.uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{ti_id}/state": | |
| actual_body = json.loads(request.read()) | |
| assert actual_body["state"] == "up_for_retry" | |
| assert actual_body["end_date"] == "2024-10-31T12:00:00Z" | |
| assert actual_body["rendered_map_index"] == "test" | |
| return httpx2.Response( | |
| status_code=204, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| client.task_instances.retry( | |
| ti_id, end_date=timezone.parse("2024-10-31T12:00:00Z"), rendered_map_index="test" | |
| ) | |
| @pytest.mark.parametrize( | |
| "rendered_fields", | |
| [ | |
| pytest.param({"field1": "rendered_value1", "field2": "rendered_value2"}, id="simple-rendering"), | |
| pytest.param( | |
| { | |
| "field1": "ClassWithCustomAttributes({'nested1': ClassWithCustomAttributes(" | |
| "{'att1': 'test', 'att2': 'test2'), " | |
| "'nested2': ClassWithCustomAttributes(" | |
| "{'att3': 'test3', 'att4': 'test4')" | |
| }, | |
| id="complex-rendering", | |
| ), | |
| ], | |
| ) | |
| def test_taskinstance_set_rtif_success(self, rendered_fields): | |
| TI_ID = uuid6.uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{TI_ID}/rtif": | |
| return httpx2.Response( | |
| status_code=201, | |
| json={"message": "Rendered task instance fields successfully set"}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.set_rtif(id=TI_ID, body=rendered_fields) | |
| assert result == OKResponse(ok=True) | |
| def test_taskinstance_set_rendered_map_index_success(self): | |
| TI_ID = uuid6.uuid7() | |
| rendered_map_index = "Label: task_1" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-instances/{TI_ID}/rendered-map-index": | |
| actual_body = json.loads(request.read()) | |
| assert request.method == "PATCH" | |
| # Body should be the string directly, not wrapped in JSON | |
| assert actual_body == rendered_map_index | |
| return httpx2.Response(status_code=204) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.set_rendered_map_index(id=TI_ID, rendered_map_index=rendered_map_index) | |
| assert result == OKResponse(ok=True) | |
| def test_get_count_basic(self): | |
| """Test basic get_count functionality with just dag_id.""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.url.path == "/task-instances/count" | |
| assert request.url.params.get("dag_id") == "test_dag" | |
| return httpx2.Response(200, json=5) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.get_count(dag_id="test_dag") | |
| assert result.count == 5 | |
| def test_get_count_with_all_params(self): | |
| """Test get_count with all optional parameters.""" | |
| logical_dates_str = ["2024-01-01T00:00:00+00:00", "2024-01-02T00:00:00+00:00"] | |
| logical_dates = [timezone.parse(d) for d in logical_dates_str] | |
| task_ids = ["task1", "task2"] | |
| states = ["success", "failed"] | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.url.path == "/task-instances/count" | |
| assert request.method == "GET" | |
| params = request.url.params | |
| assert params["dag_id"] == "test_dag" | |
| assert params.get_list("task_ids") == task_ids | |
| assert params["task_group_id"] == "group1" | |
| assert params.get_list("logical_dates") == logical_dates_str | |
| assert params.get_list("run_ids") == [] | |
| assert params.get_list("states") == states | |
| assert params["map_index"] == "0" | |
| return httpx2.Response(200, json=10) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.get_count( | |
| dag_id="test_dag", | |
| map_index=0, | |
| task_ids=task_ids, | |
| task_group_id="group1", | |
| logical_dates=logical_dates, | |
| states=states, | |
| ) | |
| assert result.count == 10 | |
| def test_get_task_states_basic(self): | |
| """Test basic get_task_states functionality with just dag_id.""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.url.path == "/task-instances/states" | |
| assert request.url.params.get("dag_id") == "test_dag" | |
| assert request.url.params.get("task_group_id") == "group1" | |
| return httpx2.Response( | |
| 200, json={"task_states": {"run_id": {"group1.task1": "success", "group1.task2": "failed"}}} | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.get_task_states(dag_id="test_dag", task_group_id="group1") | |
| assert result.task_states == {"run_id": {"group1.task1": "success", "group1.task2": "failed"}} | |
| def test_get_task_states_with_all_params(self): | |
| """Test get_task_states with all optional parameters.""" | |
| logical_dates_str = ["2024-01-01T00:00:00+00:00", "2024-01-02T00:00:00+00:00"] | |
| logical_dates = [timezone.parse(d) for d in logical_dates_str] | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.url.path == "/task-instances/states" | |
| assert request.method == "GET" | |
| params = request.url.params | |
| assert params["dag_id"] == "test_dag" | |
| assert params["task_group_id"] == "group1" | |
| assert params.get_list("logical_dates") == logical_dates_str | |
| assert params.get_list("task_ids") == [] | |
| assert params.get_list("run_ids") == [] | |
| assert params.get("map_index") == "0" | |
| return httpx2.Response( | |
| 200, json={"task_states": {"run_id": {"group1.task1": "success", "group1.task2": "failed"}}} | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.get_task_states( | |
| dag_id="test_dag", | |
| map_index=0, | |
| task_group_id="group1", | |
| logical_dates=logical_dates, | |
| ) | |
| assert result.task_states == {"run_id": {"group1.task1": "success", "group1.task2": "failed"}} | |
| def test_get_previous_basic(self): | |
| """Test basic get_previous functionality.""" | |
| logical_date = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/task-instances/previous/test_dag/test_task": | |
| assert request.url.params == httpx2.QueryParams( | |
| logical_date=logical_date.isoformat(), | |
| map_index="-1", | |
| ) | |
| # Return complete TI data | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "task_id": "test_task", | |
| "dag_id": "test_dag", | |
| "run_id": "prev_run", | |
| "logical_date": "2024-01-14T12:00:00+00:00", | |
| "start_date": "2024-01-14T12:05:00+00:00", | |
| "end_date": "2024-01-14T12:10:00+00:00", | |
| "state": "success", | |
| "try_number": 1, | |
| "map_index": -1, | |
| "duration": 300.0, | |
| }, | |
| ) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.get_previous( | |
| dag_id="test_dag", task_id="test_task", logical_date=logical_date | |
| ) | |
| assert isinstance(result, PreviousTIResult) | |
| assert result.task_instance.task_id == "test_task" | |
| assert result.task_instance.dag_id == "test_dag" | |
| assert result.task_instance.run_id == "prev_run" | |
| assert result.task_instance.state == "success" | |
| def test_get_previous_with_state_filter(self): | |
| """Test get_previous functionality with state filtering.""" | |
| logical_date = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/task-instances/previous/test_dag/test_task": | |
| assert request.url.params == httpx2.QueryParams( | |
| logical_date=logical_date.isoformat(), | |
| map_index="-1", | |
| state="success", | |
| ) | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "task_id": "test_task", | |
| "dag_id": "test_dag", | |
| "run_id": "prev_run", | |
| "logical_date": "2024-01-14T12:00:00+00:00", | |
| "start_date": "2024-01-14T12:05:00+00:00", | |
| "end_date": "2024-01-14T12:10:00+00:00", | |
| "state": "success", | |
| "try_number": 1, | |
| "map_index": -1, | |
| "duration": 300.0, | |
| }, | |
| ) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.get_previous( | |
| dag_id="test_dag", task_id="test_task", logical_date=logical_date, state="success" | |
| ) | |
| assert result.task_instance.state == "success" | |
| def test_get_previous_with_map_index_filter(self): | |
| """Test get_previous functionality with map_index filtering.""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/task-instances/previous/test_dag/test_task": | |
| assert request.url.params == httpx2.QueryParams( | |
| map_index="0", | |
| ) | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "task_id": "test_task", | |
| "dag_id": "test_dag", | |
| "run_id": "prev_run", | |
| "logical_date": "2024-01-14T12:00:00+00:00", | |
| "start_date": "2024-01-14T12:05:00+00:00", | |
| "end_date": "2024-01-14T12:10:00+00:00", | |
| "state": "success", | |
| "try_number": 1, | |
| "map_index": 0, | |
| "duration": 300.0, | |
| }, | |
| ) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.get_previous(dag_id="test_dag", task_id="test_task", map_index=0) | |
| assert result.task_instance.map_index == 0 | |
| def test_get_previous_not_found(self): | |
| """Test get_previous when no previous TI exists returns None.""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/task-instances/previous/test_dag/test_task": | |
| # Return None (null) when no previous TI found | |
| return httpx2.Response(status_code=200, content="null") | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.get_previous(dag_id="test_dag", task_id="test_task") | |
| assert isinstance(result, PreviousTIResult) | |
| assert result.task_instance is None | |
| class TestVariableOperations: | |
| """ | |
| Test that the VariableOperations class works as expected. While the operations are simple, it | |
| still catches the basic functionality of the client for variables including endpoint and | |
| response parsing. | |
| """ | |
| def test_variable_get_success(self): | |
| with time_machine.travel("2023-01-01T00:00:00Z", tick=False): | |
| # Simulate a successful response from the server with a variable | |
| # ...including a validation that retry really works | |
| call_count = 0 | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| nonlocal call_count | |
| call_count += 1 | |
| if call_count < 2: | |
| return httpx2.Response(status_code=500, json={"detail": "Internal Server Error"}) | |
| if request.url.path == "/variables/test_key": | |
| return httpx2.Response( | |
| status_code=200, | |
| json={"key": "test_key", "value": "test_value"}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.variables.get(key="test_key") | |
| assert isinstance(result, VariableResponse) | |
| assert result.key == "test_key" | |
| assert result.value == "test_value" | |
| assert call_count == 2 | |
| def test_variable_not_found(self, cap_structlog): | |
| # Simulate a 404 response from the server | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/variables/non_existent_var": | |
| return httpx2.Response( | |
| status_code=404, | |
| json={ | |
| "detail": { | |
| "message": "Variable with key 'non_existent_var' not found", | |
| "reason": "not_found", | |
| } | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| with cap_structlog.at_level("debug"): | |
| resp = client.variables.get(key="non_existent_var") | |
| assert isinstance(resp, ErrorResponse) | |
| assert resp.error == ErrorType.VARIABLE_NOT_FOUND | |
| assert resp.detail == {"key": "non_existent_var"} | |
| # Verify the log is at debug level, not error (#52771) | |
| assert {"log_level": "debug", "event": "Variable not found"} in cap_structlog | |
| def test_variable_get_500_error(self): | |
| with time_machine.travel("2023-01-01T00:00:00Z", tick=False): | |
| # Simulate a response from the server returning a 500 error | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/variables/test_key": | |
| return httpx2.Response( | |
| status_code=500, | |
| headers=[("content-Type", "application/json")], | |
| json={ | |
| "reason": "internal_server_error", | |
| "message": "Internal Server Error", | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| with pytest.raises(ServerResponseError): | |
| client.variables.get( | |
| key="test_key", | |
| ) | |
| @pytest.mark.parametrize("status_code", [401, 403]) | |
| def test_variable_get_authz_returns_permission_denied(self, status_code): | |
| """401/403 from the API server is reported as PERMISSION_DENIED, not raised. | |
| The ExecutionAPISecretsBackend uses this distinction to refuse fallback | |
| to a less-restrictive backend on an explicit deny. | |
| """ | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(status_code=status_code, json={"detail": "Forbidden"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| resp = client.variables.get(key="denied_var") | |
| assert isinstance(resp, ErrorResponse) | |
| assert resp.error == ErrorType.PERMISSION_DENIED | |
| assert resp.detail == {"key": "denied_var", "status_code": status_code} | |
| def test_variable_set_success(self): | |
| # Simulate a successful response from the server when putting a variable | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/variables/test_key": | |
| return httpx2.Response( | |
| status_code=201, | |
| json={"message": "Variable successfully set"}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.variables.set(key="test_key", value="test_value", description="test_description") | |
| assert result == OKResponse(ok=True) | |
| def test_variable_delete_success(self): | |
| # Simulate a successful response from the server when deleting a variable | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.method == "DELETE" and request.url.path == "/variables/test_key": | |
| return httpx2.Response( | |
| status_code=200, | |
| json={"count": 1}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.variables.delete(key="test_key") | |
| assert result == OKResponse(ok=True) | |
| class TestXCOMOperations: | |
| """ | |
| Test that the XComOperations class works as expected. While the operations are simple, it | |
| still catches the basic functionality of the client for xcoms including endpoint and | |
| response parsing. | |
| """ | |
| @pytest.mark.parametrize( | |
| "value", | |
| [ | |
| pytest.param("value1", id="string-value"), | |
| pytest.param({"key1": "value1"}, id="dict-value"), | |
| pytest.param('{"key1": "value1"}', id="dict-str-value"), | |
| pytest.param(["value1", "value2"], id="list-value"), | |
| pytest.param({"key": "test_key", "value": {"key2": "value2"}}, id="nested-dict-value"), | |
| ], | |
| ) | |
| def test_xcom_get_success(self, value): | |
| with time_machine.travel("2023-01-01T00:00:00Z", tick=False): | |
| # Simulate a successful response from the server when getting an xcom | |
| # ...including a validation that retry really works | |
| call_count = 0 | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| nonlocal call_count | |
| call_count += 1 | |
| if call_count < 3: | |
| return httpx2.Response(status_code=500, json={"detail": "Internal Server Error"}) | |
| if request.url.path == "/xcoms/dag_id/run_id/task_id/key": | |
| return httpx2.Response( | |
| status_code=201, | |
| json={"key": "test_key", "value": value}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.xcoms.get( | |
| dag_id="dag_id", | |
| run_id="run_id", | |
| task_id="task_id", | |
| key="key", | |
| ) | |
| assert isinstance(result, XComResponse) | |
| assert result.key == "test_key" | |
| assert result.value == value | |
| assert call_count == 3 | |
| def test_xcom_get_success_with_map_index(self): | |
| # Simulate a successful response from the server when getting an xcom with map_index passed | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if ( | |
| request.url.path == "/xcoms/dag_id/run_id/task_id/key" | |
| and request.url.params.get("map_index") == "2" | |
| ): | |
| return httpx2.Response( | |
| status_code=201, | |
| json={"key": "test_key", "value": "test_value"}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.xcoms.get( | |
| dag_id="dag_id", | |
| run_id="run_id", | |
| task_id="task_id", | |
| key="key", | |
| map_index=2, | |
| ) | |
| assert isinstance(result, XComResponse) | |
| assert result.key == "test_key" | |
| assert result.value == "test_value" | |
| def test_xcom_get_success_with_include_prior_dates(self): | |
| # Simulate a successful response from the server when getting an xcom with include_prior_dates passed | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/xcoms/dag_id/run_id/task_id/key" and request.url.params.get( | |
| "include_prior_dates" | |
| ): | |
| return httpx2.Response( | |
| status_code=201, | |
| json={"key": "test_key", "value": "test_value"}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.xcoms.get( | |
| dag_id="dag_id", | |
| run_id="run_id", | |
| task_id="task_id", | |
| key="key", | |
| include_prior_dates=True, | |
| ) | |
| assert isinstance(result, XComResponse) | |
| assert result.key == "test_key" | |
| assert result.value == "test_value" | |
| def test_xcom_get_500_error(self): | |
| with time_machine.travel("2023-01-01T00:00:00Z", tick=False): | |
| # Simulate a successful response from the server returning a 500 error | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/xcoms/dag_id/run_id/task_id/key": | |
| return httpx2.Response( | |
| status_code=500, | |
| headers=[("content-Type", "application/json")], | |
| json={ | |
| "reason": "invalid_format", | |
| "message": "XCom value is not a valid JSON", | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| with pytest.raises(ServerResponseError): | |
| client.xcoms.get( | |
| dag_id="dag_id", | |
| run_id="run_id", | |
| task_id="task_id", | |
| key="key", | |
| ) | |
| @pytest.mark.parametrize( | |
| "values", | |
| [ | |
| pytest.param("value1", id="string-value"), | |
| pytest.param({"key1": "value1"}, id="dict-value"), | |
| pytest.param(["value1", "value2"], id="list-value"), | |
| pytest.param({"key": "test_key", "value": {"key2": "value2"}}, id="nested-dict-value"), | |
| ], | |
| ) | |
| def test_xcom_set_success(self, values): | |
| # Simulate a successful response from the server when setting an xcom | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/xcoms/dag_id/run_id/task_id/key": | |
| assert json.loads(request.read()) == values | |
| return httpx2.Response( | |
| status_code=201, | |
| json={"message": "XCom successfully set"}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.xcoms.set( | |
| dag_id="dag_id", | |
| run_id="run_id", | |
| task_id="task_id", | |
| key="key", | |
| value=values, | |
| ) | |
| assert result == OKResponse(ok=True) | |
| def test_xcom_set_with_map_index(self): | |
| # Simulate a successful response from the server when setting an xcom with map_index passed | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if ( | |
| request.url.path == "/xcoms/dag_id/run_id/task_id/key" | |
| and request.url.params.get("map_index") == "2" | |
| ): | |
| assert json.loads(request.read()) == "value1" | |
| return httpx2.Response( | |
| status_code=201, | |
| json={"message": "XCom successfully set"}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.xcoms.set( | |
| dag_id="dag_id", | |
| run_id="run_id", | |
| task_id="task_id", | |
| key="key", | |
| value="value1", | |
| map_index=2, | |
| ) | |
| assert result == OKResponse(ok=True) | |
| def test_xcom_set_with_mapped_length(self): | |
| # Simulate a successful response from the server when setting an xcom with mapped_length | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if ( | |
| request.url.path == "/xcoms/dag_id/run_id/task_id/key" | |
| and request.url.params.get("map_index") == "2" | |
| and request.url.params.get("mapped_length") == "3" | |
| ): | |
| assert json.loads(request.read()) == "value1" | |
| return httpx2.Response( | |
| status_code=201, | |
| json={"message": "XCom successfully set"}, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.xcoms.set( | |
| dag_id="dag_id", | |
| run_id="run_id", | |
| task_id="task_id", | |
| key="key", | |
| value="value1", | |
| map_index=2, | |
| mapped_length=3, | |
| ) | |
| assert result == OKResponse(ok=True) | |
| class TestConnectionOperations: | |
| """ | |
| Test that the TestConnectionOperations class works as expected. While the operations are simple, it | |
| still catches the basic functionality of the client for connections including endpoint and | |
| response parsing. | |
| """ | |
| def test_connection_get_success(self): | |
| # Simulate a successful response from the server with a connection | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/connections/test_conn": | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "conn_id": "test_conn", | |
| "conn_type": "mysql", | |
| "host": None, | |
| "schema": None, | |
| "login": None, | |
| "password": None, | |
| "port": None, | |
| "extra": None, | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.connections.get(conn_id="test_conn") | |
| assert isinstance(result, ConnectionResponse) | |
| assert result.conn_id == "test_conn" | |
| assert result.conn_type == "mysql" | |
| def test_connection_get_404_not_found(self): | |
| # Simulate a successful response from the server with a connection | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/connections/test_conn": | |
| return httpx2.Response( | |
| status_code=404, | |
| json={ | |
| "reason": "not_found", | |
| "message": "Connection with ID test_conn not found", | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.connections.get(conn_id="test_conn") | |
| assert isinstance(result, ErrorResponse) | |
| assert result.error == ErrorType.CONNECTION_NOT_FOUND | |
| def test_connection_get_url_encodes_conn_id(self): | |
| requests_seen = [] | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| requests_seen.append(request) | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "conn_id": "dev/my_conn", | |
| "conn_type": "http", | |
| "host": None, | |
| "schema": None, | |
| "login": None, | |
| "password": None, | |
| "port": None, | |
| "extra": None, | |
| }, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.connections.get(conn_id="dev/my_conn") | |
| assert isinstance(result, ConnectionResponse) | |
| assert requests_seen[0].url.raw_path == b"/connections/dev%2Fmy_conn" | |
| @pytest.mark.parametrize("status_code", [401, 403]) | |
| def test_connection_get_authz_returns_permission_denied(self, status_code): | |
| """401/403 from the API server is reported as PERMISSION_DENIED, not raised.""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(status_code=status_code, json={"detail": "Forbidden"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.connections.get(conn_id="denied_conn") | |
| assert isinstance(result, ErrorResponse) | |
| assert result.error == ErrorType.PERMISSION_DENIED | |
| assert result.detail == {"conn_id": "denied_conn", "status_code": status_code} | |
| class TestAssetEventOperations: | |
| @pytest.mark.parametrize( | |
| "request_params", | |
| [ | |
| ({"name": "this_asset", "uri": "s3://bucket/key"}), | |
| ({"alias_name": "this_asset_alias"}), | |
| ], | |
| ) | |
| @pytest.mark.parametrize( | |
| ("created_dagruns", "expected_created_dagruns"), | |
| [ | |
| pytest.param([], 0, id="without-created-dagrun"), | |
| pytest.param( | |
| [ | |
| { | |
| "dag_id": "created_dag", | |
| "run_id": "queued_run", | |
| "logical_date": "2023-01-01T00:00:00Z", | |
| "start_date": None, | |
| "end_date": None, | |
| "state": "queued", | |
| "data_interval_start": None, | |
| "data_interval_end": None, | |
| "partition_key": None, | |
| } | |
| ], | |
| 1, | |
| id="queued-created-dagrun-without-start-date", | |
| ), | |
| ], | |
| ) | |
| def test_by_name_get_success(self, request_params, created_dagruns, expected_created_dagruns): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| params = request.url.params | |
| if request.url.path == "/asset-events/by-asset": | |
| assert params.get("name") == request_params.get("name") | |
| assert params.get("uri") == request_params.get("uri") | |
| elif request.url.path == "/asset-events/by-asset-alias": | |
| assert params.get("name") == request_params.get("alias_name") | |
| else: | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "asset_events": [ | |
| { | |
| "id": 1, | |
| "asset": { | |
| "name": "this_asset", | |
| "uri": "s3://bucket/key", | |
| "group": "asset", | |
| }, | |
| "created_dagruns": created_dagruns, | |
| "timestamp": "2023-01-01T00:00:00Z", | |
| } | |
| ] | |
| }, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_events.get(**request_params) | |
| assert isinstance(result, AssetEventsResponse) | |
| assert len(result.asset_events) == 1 | |
| assert result.asset_events[0].asset.name == "this_asset" | |
| assert result.asset_events[0].asset.uri == "s3://bucket/key" | |
| assert len(result.asset_events[0].created_dagruns) == expected_created_dagruns | |
| if expected_created_dagruns: | |
| assert result.asset_events[0].created_dagruns[0].start_date is None | |
| def test_partition_key_exact_match_param_passed(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| params = request.url.params | |
| assert params.get("partition_key") == "2024-01-01" | |
| assert "partition_key_regexp_pattern" not in params | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "asset_events": [ | |
| { | |
| "id": 1, | |
| "asset": { | |
| "name": "this_asset", | |
| "uri": "s3://bucket/key", | |
| "group": "asset", | |
| }, | |
| "created_dagruns": [], | |
| "timestamp": "2023-01-01T00:00:00Z", | |
| "partition_key": "2024-01-01", | |
| } | |
| ] | |
| }, | |
| ) | |
| client = make_client(httpx2.MockTransport(handle_request)) | |
| result = client.asset_events.get(name="this_asset", partition_key="2024-01-01") | |
| assert isinstance(result, AssetEventsResponse) | |
| assert len(result.asset_events) == 1 | |
| def test_partition_key_regexp_pattern_param_passed(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| params = request.url.params | |
| assert params.get("partition_key_regexp_pattern") == "^2024-01-" | |
| assert "partition_key" not in params | |
| return httpx2.Response( | |
| status_code=200, | |
| json={"asset_events": []}, | |
| ) | |
| client = make_client(httpx2.MockTransport(handle_request)) | |
| result = client.asset_events.get(name="this_asset", partition_key_regexp_pattern="^2024-01-") | |
| assert isinstance(result, AssetEventsResponse) | |
| assert len(result.asset_events) == 0 | |
| def test_partition_key_regexp_pattern_with_other_params(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| params = request.url.params | |
| assert params.get("partition_key_regexp_pattern") == r"^us\|2024-.*" | |
| assert "partition_key" not in params | |
| assert params.get("after") == "2023-06-01T00:00:00+00:00" | |
| assert params.get("limit") == "5" | |
| assert params.get("ascending") == "false" | |
| return httpx2.Response( | |
| status_code=200, | |
| json={"asset_events": []}, | |
| ) | |
| client = make_client(httpx2.MockTransport(handle_request)) | |
| result = client.asset_events.get( | |
| name="this_asset", | |
| partition_key_regexp_pattern=r"^us\|2024-.*", | |
| after=datetime(2023, 6, 1, tzinfo=UTC), | |
| limit=5, | |
| ascending=False, | |
| ) | |
| assert isinstance(result, AssetEventsResponse) | |
| assert len(result.asset_events) == 0 | |
| def test_partition_key_with_alias(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.url.path == "/asset-events/by-asset-alias" | |
| params = request.url.params | |
| assert params.get("name") == "my_alias" | |
| assert params.get("partition_key") == "us-east|2024-01-01" | |
| assert "partition_key_regexp_pattern" not in params | |
| return httpx2.Response( | |
| status_code=200, | |
| json={"asset_events": []}, | |
| ) | |
| client = make_client(httpx2.MockTransport(handle_request)) | |
| result = client.asset_events.get(alias_name="my_alias", partition_key="us-east|2024-01-01") | |
| assert isinstance(result, AssetEventsResponse) | |
| assert len(result.asset_events) == 0 | |
| def test_extra_dict_param_passed(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| params = request.url.params | |
| extra_pairs = [v for k, v in params.multi_items() if k == "extra"] | |
| assert sorted(extra_pairs) == sorted(["region=us", "env=prod"]) | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "asset_events": [ | |
| { | |
| "id": 1, | |
| "asset": {"name": "a", "uri": "s3://b", "group": "asset"}, | |
| "created_dagruns": [], | |
| "timestamp": "2023-01-01T00:00:00Z", | |
| } | |
| ] | |
| }, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_events.get(name="a", uri="s3://b", extra={"region": "us", "env": "prod"}) | |
| assert isinstance(result, AssetEventsResponse) | |
| assert len(result.asset_events) == 1 | |
| def test_extra_dict_with_alias(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| params = request.url.params | |
| assert request.url.path == "/asset-events/by-asset-alias" | |
| extra_pairs = [v for k, v in params.multi_items() if k == "extra"] | |
| assert extra_pairs == ["env=prod"] | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "asset_events": [ | |
| { | |
| "id": 1, | |
| "asset": {"name": "a", "uri": "s3://b", "group": "asset"}, | |
| "created_dagruns": [], | |
| "timestamp": "2023-01-01T00:00:00Z", | |
| } | |
| ] | |
| }, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_events.get(alias_name="my_alias", extra={"env": "prod"}) | |
| assert isinstance(result, AssetEventsResponse) | |
| assert len(result.asset_events) == 1 | |
| def test_extra_none_not_sent(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| params = request.url.params | |
| assert "extra" not in params | |
| return httpx2.Response( | |
| status_code=200, | |
| json={"asset_events": []}, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_events.get(name="a", uri="s3://b") | |
| assert isinstance(result, AssetEventsResponse) | |
| class TestAssetOperations: | |
| @pytest.mark.parametrize( | |
| "request_params", | |
| [ | |
| ({"name": "this_asset"}), | |
| ({"uri": "s3://bucket/key"}), | |
| ], | |
| ) | |
| def test_by_name_get_success(self, request_params): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path in ("/assets/by-name", "/assets/by-uri"): | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "name": "this_asset", | |
| "uri": "s3://bucket/key", | |
| "group": "asset", | |
| "extra": {"foo": "bar"}, | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.assets.get(**request_params) | |
| assert isinstance(result, AssetResponse) | |
| assert result.name == "this_asset" | |
| assert result.uri == "s3://bucket/key" | |
| @pytest.mark.parametrize( | |
| "request_params", | |
| [ | |
| ({"name": "this_asset"}), | |
| ({"uri": "s3://bucket/key"}), | |
| ], | |
| ) | |
| def test_by_name_get_404_not_found(self, request_params): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path in ("/assets/by-name", "/assets/by-uri"): | |
| return httpx2.Response( | |
| status_code=404, | |
| json={ | |
| "detail": { | |
| "message": "Asset with name non_existent not found", | |
| "reason": "not_found", | |
| } | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.assets.get(**request_params) | |
| assert isinstance(result, ErrorResponse) | |
| assert result.error == ErrorType.ASSET_NOT_FOUND | |
| def test_get_by_alias_returns_list(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.url.path == "/assets/by-alias" | |
| assert request.url.params["alias_name"] == "my_alias" | |
| return httpx2.Response( | |
| status_code=200, | |
| json=[ | |
| {"name": "asset_a", "uri": "s3://bucket/a", "group": "asset", "extra": {}}, | |
| {"name": "asset_b", "uri": "s3://bucket/b", "group": "asset", "extra": {}}, | |
| ], | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.assets.get_by_alias("my_alias") | |
| assert isinstance(result, AssetsByAliasResult) | |
| assert len(result.assets) == 2 | |
| assert isinstance(result.assets[0], AssetResponse) | |
| assert isinstance(result.assets[1], AssetResponse) | |
| assert result.assets[0].name == "asset_a" | |
| assert result.assets[1].name == "asset_b" | |
| def test_get_by_alias_returns_empty_for_unknown_alias(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response(status_code=200, json=[]) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.assets.get_by_alias("unknown_alias") | |
| assert result.assets == [] | |
| class TestDagRunOperations: | |
| def test_trigger(self): | |
| # Simulate a successful response from the server when triggering a dag run | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/test_trigger/test_run_id": | |
| actual_body = json.loads(request.read()) | |
| assert actual_body["logical_date"] == "2025-01-01T00:00:00Z" | |
| assert actual_body["conf"] == {} | |
| # Since the value for `reset_dag_run` is default, it should not be present in the request body | |
| assert "reset_dag_run" not in actual_body | |
| return httpx2.Response(status_code=204) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.trigger( | |
| dag_id="test_trigger", | |
| run_id="test_run_id", | |
| logical_date=timezone.datetime(2025, 1, 1), | |
| run_after=timezone.datetime(2025, 1, 2), | |
| ) | |
| assert result == OKResponse(ok=True) | |
| def test_trigger_conflict(self): | |
| """Test that if the dag run already exists, the client returns an error when default reset_dag_run=False""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/test_trigger_conflict/test_run_id": | |
| return httpx2.Response( | |
| status_code=409, | |
| json={ | |
| "detail": { | |
| "reason": "already_exists", | |
| "message": "A Dag Run already exists for Dag test_trigger_conflict with run id test_run_id", | |
| } | |
| }, | |
| ) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.trigger(dag_id="test_trigger_conflict", run_id="test_run_id") | |
| assert result == ErrorResponse(error=ErrorType.DAGRUN_ALREADY_EXISTS) | |
| def test_trigger_conflict_reset_dag_run(self): | |
| """Test that if dag run already exists and reset_dag_run=True, the client clears the dag run""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/test_trigger_conflict_reset/test_run_id": | |
| return httpx2.Response( | |
| status_code=409, | |
| json={ | |
| "detail": { | |
| "reason": "already_exists", | |
| "message": "A Dag Run already exists for Dag test_trigger_conflict with run id test_run_id", | |
| } | |
| }, | |
| ) | |
| if request.url.path == "/dag-runs/test_trigger_conflict_reset/test_run_id/clear": | |
| return httpx2.Response(status_code=204) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.trigger( | |
| dag_id="test_trigger_conflict_reset", | |
| run_id="test_run_id", | |
| reset_dag_run=True, | |
| ) | |
| assert result == OKResponse(ok=True) | |
| def test_clear(self): | |
| """Test that the client can clear a dag run""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/test_clear/test_run_id/clear": | |
| return httpx2.Response(status_code=204) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.clear(dag_id="test_clear", run_id="test_run_id") | |
| assert result == OKResponse(ok=True) | |
| def test_get_state(self): | |
| """Test that the client can get the state of a dag run""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/test_state/test_run_id/state": | |
| return httpx2.Response( | |
| status_code=200, | |
| json={"state": "running"}, | |
| ) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.get_state(dag_id="test_state", run_id="test_run_id") | |
| assert result == DagRunStateResponse(state=DagRunState.RUNNING) | |
| def test_get_count_basic(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/count": | |
| assert request.url.params["dag_id"] == "test_dag" | |
| return httpx2.Response(status_code=200, json=1) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.get_count(dag_id="test_dag") | |
| assert result.count == 1 | |
| def test_get_count_with_states(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/count": | |
| assert request.url.params["dag_id"] == "test_dag" | |
| assert request.url.params.get_list("states") == ["success", "failed"] | |
| return httpx2.Response(status_code=200, json=2) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.get_count(dag_id="test_dag", states=["success", "failed"]) | |
| assert result.count == 2 | |
| def test_get_count_with_logical_dates(self): | |
| logical_dates = [timezone.datetime(2025, 1, 1), timezone.datetime(2025, 1, 2)] | |
| logical_dates_str = [d.isoformat() for d in logical_dates] | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/count": | |
| assert request.url.params["dag_id"] == "test_dag" | |
| assert request.url.params.get_list("logical_dates") == logical_dates_str | |
| return httpx2.Response(status_code=200, json=2) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.get_count( | |
| dag_id="test_dag", logical_dates=[timezone.datetime(2025, 1, 1), timezone.datetime(2025, 1, 2)] | |
| ) | |
| assert result.count == 2 | |
| def test_get_count_with_run_ids(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/count": | |
| assert request.url.params["dag_id"] == "test_dag" | |
| assert request.url.params.get_list("run_ids") == ["run1", "run2"] | |
| return httpx2.Response(status_code=200, json=2) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.get_count(dag_id="test_dag", run_ids=["run1", "run2"]) | |
| assert result.count == 2 | |
| def test_get_previous_basic(self): | |
| """Test basic get_previous functionality with dag_id and logical_date.""" | |
| logical_date = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/previous": | |
| assert request.url.params == httpx2.QueryParams( | |
| dag_id="test_dag", | |
| logical_date=logical_date.isoformat(), | |
| ) | |
| # Return complete DagRun data | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "dag_id": "test_dag", | |
| "run_id": "prev_run", | |
| "logical_date": "2024-01-14T12:00:00+00:00", | |
| "start_date": "2024-01-14T12:05:00+00:00", | |
| "run_after": "2024-01-14T12:00:00+00:00", | |
| "run_type": "scheduled", | |
| "state": "success", | |
| "consumed_asset_events": [], | |
| "data_interval_start": None, | |
| "data_interval_end": None, | |
| "end_date": None, | |
| "partition_key": None, | |
| }, | |
| ) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.get_previous(dag_id="test_dag", logical_date=logical_date) | |
| assert isinstance(result, PreviousDagRunResult) | |
| assert result.dag_run.dag_id == "test_dag" | |
| assert result.dag_run.run_id == "prev_run" | |
| assert result.dag_run.state == "success" | |
| def test_get_previous_with_state_filter(self): | |
| """Test get_previous functionality with state filtering.""" | |
| logical_date = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/previous": | |
| assert request.url.params == httpx2.QueryParams( | |
| dag_id="test_dag", | |
| logical_date=logical_date.isoformat(), | |
| state="success", | |
| ) | |
| # Return complete DagRun data | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "dag_id": "test_dag", | |
| "run_id": "prev_success_run", | |
| "logical_date": "2024-01-14T12:00:00+00:00", | |
| "start_date": "2024-01-14T12:05:00+00:00", | |
| "run_after": "2024-01-14T12:00:00+00:00", | |
| "run_type": "scheduled", | |
| "state": "success", | |
| "consumed_asset_events": [], | |
| "data_interval_start": None, | |
| "data_interval_end": None, | |
| "end_date": None, | |
| "partition_key": None, | |
| }, | |
| ) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.get_previous(dag_id="test_dag", logical_date=logical_date, state="success") | |
| assert isinstance(result, PreviousDagRunResult) | |
| assert result.dag_run.dag_id == "test_dag" | |
| assert result.dag_run.run_id == "prev_success_run" | |
| assert result.dag_run.state == "success" | |
| def test_get_previous_not_found(self): | |
| """Test get_previous when no previous Dag run exists returns None.""" | |
| logical_date = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dag-runs/previous": | |
| assert request.url.params == httpx2.QueryParams( | |
| dag_id="test_dag", | |
| logical_date=logical_date.isoformat(), | |
| ) | |
| # Return None (null) when no previous Dag run found | |
| return httpx2.Response(status_code=200, content="null") | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dag_runs.get_previous(dag_id="test_dag", logical_date=logical_date) | |
| assert isinstance(result, PreviousDagRunResult) | |
| assert result.dag_run is None | |
| class TestTaskRescheduleOperations: | |
| def test_get_start_date(self): | |
| """Test that the client can get the start date of a task reschedule""" | |
| ti_id = uuid6.uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/task-reschedules/{ti_id}/start_date": | |
| assert request.url.params.get("try_number") == "1" | |
| return httpx2.Response( | |
| status_code=200, | |
| json="2024-01-01T00:00:00Z", | |
| ) | |
| return httpx2.Response(status_code=422) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_instances.get_reschedule_start_date(id=ti_id, try_number=1) | |
| assert isinstance(result, TaskRescheduleStartDate) | |
| assert result.start_date == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) | |
| class TestHITLOperations: | |
| def test_add_response(self) -> None: | |
| ti_id = uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path in (f"/hitlDetails/{ti_id}"): | |
| return httpx2.Response( | |
| status_code=201, | |
| json={ | |
| "ti_id": str(ti_id), | |
| "options": ["Approval", "Reject"], | |
| "subject": "This is subject", | |
| "body": "This is body", | |
| "defaults": ["Approval"], | |
| "params": None, | |
| "multiple": False, | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.hitl.add_response( | |
| ti_id=ti_id, | |
| options=["Approval", "Reject"], | |
| subject="This is subject", | |
| body="This is body", | |
| defaults=["Approval"], | |
| params=None, | |
| multiple=False, | |
| ) | |
| assert isinstance(result, HITLDetailRequest) | |
| assert result.ti_id == ti_id | |
| assert result.options == ["Approval", "Reject"] | |
| assert result.subject == "This is subject" | |
| assert result.body == "This is body" | |
| assert result.defaults == ["Approval"] | |
| assert result.params is None | |
| assert result.multiple is False | |
| assert result.assigned_users is None | |
| def test_update_response(self, time_machine: TimeMachineFixture) -> None: | |
| time_machine.move_to(datetime(2025, 7, 3, 0, 0, 0)) | |
| ti_id = uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path in (f"/hitlDetails/{ti_id}"): | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "chosen_options": ["Approval"], | |
| "params_input": {}, | |
| "responded_by_user": {"id": "admin", "name": "admin"}, | |
| "response_received": True, | |
| "responded_at": "2025-07-03T00:00:00Z", | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.hitl.update_response( | |
| ti_id=ti_id, | |
| chosen_options=["Approve"], | |
| params_input={}, | |
| ) | |
| assert isinstance(result, HITLDetailResponse) | |
| assert result.response_received is True | |
| assert result.chosen_options == ["Approval"] | |
| assert result.params_input == {} | |
| assert result.responded_by_user == HITLUser(id="admin", name="admin") | |
| assert result.responded_at == timezone.datetime(2025, 7, 3, 0, 0, 0) | |
| def test_get_detail_response(self, time_machine: TimeMachineFixture) -> None: | |
| time_machine.move_to(datetime(2025, 7, 3, 0, 0, 0)) | |
| ti_id = uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path in (f"/hitlDetails/{ti_id}"): | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "chosen_options": ["Approval"], | |
| "params_input": {}, | |
| "responded_by_user": {"id": "admin", "name": "admin"}, | |
| "response_received": True, | |
| "responded_at": "2025-07-03T00:00:00Z", | |
| }, | |
| ) | |
| return httpx2.Response(status_code=400, json={"detail": "Bad Request"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.hitl.get_detail_response(ti_id=ti_id) | |
| assert isinstance(result, HITLDetailResponse) | |
| assert result.response_received is True | |
| assert result.chosen_options == ["Approval"] | |
| assert result.params_input == {} | |
| assert result.responded_by_user == HITLUser(id="admin", name="admin") | |
| assert result.responded_at == timezone.datetime(2025, 7, 3, 0, 0, 0) | |
| class TestSSLContextCaching: | |
| @pytest.fixture(autouse=True) | |
| def clear_ssl_context_cache(self): | |
| Client._get_ssl_context_cached.cache_clear() | |
| yield | |
| Client._get_ssl_context_cached.cache_clear() | |
| def test_cache_hit_on_same_parameters(self, ca_file): | |
| ctx1 = Client._get_ssl_context_cached(ca_file, None) | |
| ctx2 = Client._get_ssl_context_cached(ca_file, None) | |
| assert ctx1 is ctx2 | |
| def test_cache_miss_if_cache_cleared(self, ca_file): | |
| ctx1 = Client._get_ssl_context_cached(ca_file, None) | |
| Client._get_ssl_context_cached.cache_clear() | |
| ctx2 = Client._get_ssl_context_cached(ca_file, None) | |
| assert ctx1 is not ctx2 | |
| def test_cache_miss_on_different_parameters(self, ca_file): | |
| ctx1 = Client._get_ssl_context_cached(ca_file, None) | |
| ctx2 = Client._get_ssl_context_cached(ca_file, ca_file) | |
| info = Client._get_ssl_context_cached.cache_info() | |
| assert ctx1 is not ctx2 | |
| assert info.misses == 2 | |
| assert info.currsize == 2 | |
| class TestDagsOperations: | |
| def test_get(self): | |
| """Test that the client can get a dag.""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dags/test_dag": | |
| return httpx2.Response( | |
| status_code=200, | |
| json={ | |
| "dag_id": "test_dag", | |
| "is_paused": False, | |
| "bundle_name": "dags-folder", | |
| "bundle_version": "bundle-version", | |
| "relative_fileloc": "dags/example.py", | |
| "owners": "owner_1", | |
| "tags": ["a_tag", "z_tag"], | |
| "next_dagrun": "2026-04-13T00:00:00Z", | |
| }, | |
| ) | |
| return httpx2.Response(status_code=200) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.dags.get(dag_id="test_dag") | |
| assert result == DagResponse( | |
| dag_id="test_dag", | |
| is_paused=False, | |
| bundle_name="dags-folder", | |
| bundle_version="bundle-version", | |
| relative_fileloc="dags/example.py", | |
| owners="owner_1", | |
| tags=["a_tag", "z_tag"], | |
| next_dagrun=datetime(2026, 4, 13, tzinfo=UTC), | |
| ) | |
| def test_get_url_quotes_dag_id_as_single_path_segment(self): | |
| """Test that Dag IDs cannot escape the dags API path.""" | |
| requests_seen = [] | |
| crafted_dag_id = "x/../../variables/secret_key" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| requests_seen.append(request) | |
| if request.url.path == "/variables/secret_key": | |
| return httpx2.Response( | |
| status_code=200, | |
| json={"key": "secret_key", "value": "super-secret-value"}, | |
| ) | |
| return httpx2.Response( | |
| status_code=404, | |
| json={ | |
| "detail": { | |
| "message": "The Dag was not found", | |
| "reason": "not_found", | |
| } | |
| }, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| with pytest.raises(ServerResponseError) as exc_info: | |
| client.dags.get(dag_id=crafted_dag_id) | |
| assert requests_seen[0].url.raw_path == b"/dags/x%2F..%2F..%2Fvariables%2Fsecret_key" | |
| assert requests_seen[0].url.path != "/variables/secret_key" | |
| assert "super-secret-value" not in str(exc_info.value) | |
| def test_get_not_found(self): | |
| """Test that getting a missing dag raises a server response error.""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dags/missing_dag": | |
| return httpx2.Response( | |
| status_code=404, | |
| json={ | |
| "detail": { | |
| "message": "The Dag with dag_id: `missing_dag` was not found", | |
| "reason": "not_found", | |
| } | |
| }, | |
| ) | |
| return httpx2.Response(status_code=200) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| with pytest.raises(ServerResponseError) as exc_info: | |
| client.dags.get(dag_id="missing_dag") | |
| assert exc_info.value.response.status_code == 404 | |
| assert exc_info.value.detail == { | |
| "message": "The Dag with dag_id: `missing_dag` was not found", | |
| "reason": "not_found", | |
| } | |
| def test_get_server_error(self): | |
| """Test that a server error while getting a dag.""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == "/dags/test_dag": | |
| return httpx2.Response( | |
| status_code=500, | |
| headers=[("content-Type", "application/json")], | |
| json={ | |
| "reason": "internal_server_error", | |
| "message": "Internal Server Error", | |
| }, | |
| ) | |
| return httpx2.Response(status_code=200) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| with pytest.raises(ServerResponseError): | |
| client.dags.get(dag_id="test_dag") | |
| class TestTaskStateOperations: | |
| TI_ID = uuid7() | |
| def test_get_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if request.url.path == f"/store/ti/{self.TI_ID}/job_id": | |
| return httpx2.Response(status_code=200, json={"value": "spark_app_001"}) | |
| return httpx2.Response(status_code=400) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_state_store.get(ti_id=self.TI_ID, key="job_id") | |
| assert isinstance(result, TaskStateStoreResponse) | |
| assert result.value == "spark_app_001" | |
| def test_get_url_encodes_key_with_slash(self): | |
| requests_seen = [] | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| requests_seen.append(request) | |
| return httpx2.Response(status_code=200, json={"value": "spark_app_001"}) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| client.task_state_store.get(ti_id=self.TI_ID, key="spark/job_id") | |
| assert b"%2F" in requests_seen[0].url.raw_path | |
| assert requests_seen[0].url.raw_path == f"/store/ti/{self.TI_ID}/spark%2Fjob_id".encode() | |
| def test_get_returns_error_response_on_404(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response( | |
| status_code=404, | |
| json={"detail": {"reason": "not_found", "message": "Task state key 'job_id' not found"}}, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_state_store.get(ti_id=self.TI_ID, key="job_id") | |
| assert isinstance(result, ErrorResponse) | |
| assert result.error == ErrorType.TASK_STORE_NOT_FOUND | |
| def test_set_success(self): | |
| expires = datetime(2026, 6, 13, 12, 0, 0, tzinfo=UTC) | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "PUT" | |
| assert request.url.path == f"/store/ti/{self.TI_ID}/job_id" | |
| assert b'"value":"spark_app_001"' in request.content | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_state_store.set( | |
| ti_id=self.TI_ID, key="job_id", value="spark_app_001", expires_at=expires | |
| ) | |
| assert result == OKResponse(ok=True) | |
| def test_set_url_encodes_key_with_slash(self): | |
| requests_seen = [] | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| requests_seen.append(request) | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| client.task_state_store.set( | |
| ti_id=self.TI_ID, key="spark/job_id", value="spark_app_001", expires_at=None | |
| ) | |
| assert b"%2F" in requests_seen[0].url.raw_path | |
| assert requests_seen[0].url.raw_path == f"/store/ti/{self.TI_ID}/spark%2Fjob_id".encode() | |
| def test_set_with_expires_at_sends_field(self): | |
| """expires_at is forwarded as an ISO datetime string in the request body.""" | |
| expires = datetime(2026, 5, 21, 12, 0, 0, tzinfo=UTC) | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| body = json.loads(request.content) | |
| assert body["value"] == "spark_app_001" | |
| assert body["expires_at"] == "2026-05-21T12:00:00Z" | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_state_store.set( | |
| ti_id=self.TI_ID, key="job_id", value="spark_app_001", expires_at=expires | |
| ) | |
| assert result == OKResponse(ok=True) | |
| def test_set_with_never_expire_sends_null_expires_at(self): | |
| """NEVER_EXPIRE sends expires_at=null — stored as NULL in DB, GC skips it.""" | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| body = json.loads(request.content) | |
| assert body.get("expires_at") is None | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_state_store.set( | |
| ti_id=self.TI_ID, | |
| key="job_id", | |
| value="v", | |
| expires_at=None, | |
| ) | |
| assert result == OKResponse(ok=True) | |
| def test_delete_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "DELETE" | |
| assert request.url.path == f"/store/ti/{self.TI_ID}/job_id" | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_state_store.delete(ti_id=self.TI_ID, key="job_id") | |
| assert result == OKResponse(ok=True) | |
| def test_delete_url_encodes_key_with_slash(self): | |
| requests_seen = [] | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| requests_seen.append(request) | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| client.task_state_store.delete(ti_id=self.TI_ID, key="spark/job_id") | |
| assert b"%2F" in requests_seen[0].url.raw_path | |
| assert requests_seen[0].url.raw_path == f"/store/ti/{self.TI_ID}/spark%2Fjob_id".encode() | |
| def test_clear_sends_delete_request(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "DELETE" | |
| assert request.url.path == f"/store/ti/{self.TI_ID}" | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.task_state_store.clear(ti_id=self.TI_ID) | |
| assert result == OKResponse(ok=True) | |
| class TestAssetStateOperations: | |
| def test_get_by_name_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if ( | |
| request.url.path == "/store/asset/by-name/value" | |
| and request.url.params["name"] == "test_asset" | |
| and request.url.params["key"] == "watermark" | |
| ): | |
| return httpx2.Response(status_code=200, json={"value": "2026-04-30T00:00:00Z"}) | |
| return httpx2.Response(status_code=400) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_state_store.get(key="watermark", name="test_asset") | |
| assert isinstance(result, AssetStateStoreResponse) | |
| assert result.value == "2026-04-30T00:00:00Z" | |
| def test_get_by_uri_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| if ( | |
| request.url.path == "/store/asset/by-uri/value" | |
| and request.url.params["uri"] == "s3://bucket/key" | |
| and request.url.params["key"] == "watermark" | |
| ): | |
| return httpx2.Response(status_code=200, json={"value": "2026-04-30T00:00:00Z"}) | |
| return httpx2.Response(status_code=400) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_state_store.get(key="watermark", uri="s3://bucket/key") | |
| assert isinstance(result, AssetStateStoreResponse) | |
| assert result.value == "2026-04-30T00:00:00Z" | |
| def test_get_returns_error_response_on_404(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response( | |
| status_code=404, | |
| json={"detail": {"reason": "not_found", "message": "Asset state key 'watermark' not found"}}, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_state_store.get(key="watermark", name="test_asset") | |
| assert isinstance(result, ErrorResponse) | |
| assert result.error == ErrorType.ASSET_STORE_NOT_FOUND | |
| def test_set_by_name_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "PUT" | |
| assert request.url.path == "/store/asset/by-name/value" | |
| assert request.url.params["name"] == "test_asset" | |
| assert request.url.params["key"] == "watermark" | |
| assert b'"value":"2026-04-30T00:00:00Z"' in request.content | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_state_store.set( | |
| key="watermark", value="2026-04-30T00:00:00Z", name="test_asset" | |
| ) | |
| assert result == OKResponse(ok=True) | |
| def test_set_by_uri_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "PUT" | |
| assert request.url.path == "/store/asset/by-uri/value" | |
| assert request.url.params["uri"] == "s3://bucket/key" | |
| assert request.url.params["key"] == "watermark" | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_state_store.set( | |
| key="watermark", value="2026-04-30T00:00:00Z", uri="s3://bucket/key" | |
| ) | |
| assert result == OKResponse(ok=True) | |
| def test_delete_by_name_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "DELETE" | |
| assert request.url.path == "/store/asset/by-name/value" | |
| assert request.url.params["name"] == "test_asset" | |
| assert request.url.params["key"] == "watermark" | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_state_store.delete(key="watermark", name="test_asset") | |
| assert result == OKResponse(ok=True) | |
| def test_delete_by_uri_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "DELETE" | |
| assert request.url.path == "/store/asset/by-uri/value" | |
| assert request.url.params["uri"] == "s3://bucket/key" | |
| assert request.url.params["key"] == "watermark" | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_state_store.delete(key="watermark", uri="s3://bucket/key") | |
| assert result == OKResponse(ok=True) | |
| def test_clear_by_name_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "DELETE" | |
| assert request.url.path == "/store/asset/by-name/clear" | |
| assert request.url.params["name"] == "test_asset" | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_state_store.clear(name="test_asset") | |
| assert result == OKResponse(ok=True) | |
| def test_clear_by_uri_success(self): | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "DELETE" | |
| assert request.url.path == "/store/asset/by-uri/clear" | |
| assert request.url.params["uri"] == "s3://bucket/key" | |
| return httpx2.Response(status_code=204) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| result = client.asset_state_store.clear(uri="s3://bucket/key") | |
| assert result == OKResponse(ok=True) | |
| class TestCallbackOperations: | |
| def test_run_exchanges_token(self): | |
| callback_id = uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| assert request.method == "PATCH" | |
| assert request.url.path == f"/callbacks/{callback_id}/run" | |
| return httpx2.Response( | |
| status_code=204, | |
| headers={"Refreshed-API-Token": "execution-token"}, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| client.callbacks.run(callback_id) | |
| assert client.auth is not None | |
| assert client.auth.token == "execution-token" | |
| def test_run_raises_on_conflict(self): | |
| callback_id = uuid7() | |
| def handle_request(request: httpx2.Request) -> httpx2.Response: | |
| return httpx2.Response( | |
| status_code=409, | |
| json={"detail": {"reason": "invalid_state", "previous_state": "running"}}, | |
| ) | |
| client = make_client(transport=httpx2.MockTransport(handle_request)) | |
| with pytest.raises(ServerResponseError) as err: | |
| client.callbacks.run(callback_id) | |
| assert err.value.response.status_code == 409 |