This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new c67fbcb8e9b Fix airflowctl sending ids with URL special characters to 
the wrong path (#76147)
c67fbcb8e9b is described below

commit c67fbcb8e9bed544666a71576ec6be25c1531f30
Author: Andrew Chang <[email protected]>
AuthorDate: Sun Nov 1 01:21:20 2026 +0900

    Fix airflowctl sending ids with URL special characters to the wrong path 
(#76147)
    
    airflowctl put ids such as a variable key, a connection id or a pool
    name into the request path without encoding them. A `#` cut the path,
    `?` started a query string, and `/` or `..` moved the request to
    another endpoint. For example, `variables delete 'a#b'` deleted the
    variable `a` and reported success.
    
    Encode every path parameter with quote(value, safe=""), the same as
    the UI does with encodeURIComponent. "." and ".." are encoded too,
    because httpx removes them from the path as dot-segments.
---
 airflow-ctl/src/airflowctl/api/operations.py       | 142 +++++++++++++--------
 .../tests/airflow_ctl/api/test_operations.py       |  18 +++
 2 files changed, 105 insertions(+), 55 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/api/operations.py 
b/airflow-ctl/src/airflowctl/api/operations.py
index 8c62ed3d98e..05570e52ca6 100644
--- a/airflow-ctl/src/airflowctl/api/operations.py
+++ b/airflow-ctl/src/airflowctl/api/operations.py
@@ -25,6 +25,7 @@ import sys
 import types
 import typing
 from typing import TYPE_CHECKING, Any, TypeVar, cast, get_args, get_origin
+from urllib.parse import quote
 
 import httpx
 from pydantic import BaseModel, RootModel, ValidationError, create_model
@@ -104,6 +105,16 @@ if TYPE_CHECKING:
 T = TypeVar("T", bound=BaseModel)
 
 
+def _quote_path_param(value: Any) -> str:
+    """
+    Encode a value for one URL path segment, like the UI does with 
``encodeURIComponent``.
+
+    ``.`` and ``..`` are encoded too, because httpx removes them from the path 
as dot-segments.
+    """
+    quoted = quote(str(value), safe="")
+    return quoted.replace(".", "%2E") if quoted in (".", "..") else quoted
+
+
 def _serialize_query_param(value: Any) -> Any:
     # datetime.datetime subclasses datetime.date, so this covers both.
     if isinstance(value, datetime.date):
@@ -353,12 +364,12 @@ class AssetsOperations(BaseOperations):
 
     def get(self, asset_id: str) -> AssetResponse | ServerResponseError:
         """Get an asset from the API server."""
-        self.response = self.client.get(f"assets/{asset_id}")
+        self.response = 
self.client.get(f"assets/{_quote_path_param(asset_id)}")
         return validate_response(self.response.content, AssetResponse)
 
     def get_alias(self, asset_alias_id: str) -> AssetAliasResponse | 
ServerResponseError:
         """Get an asset alias by its ID from the API server."""
-        self.response = self.client.get(f"assets/aliases/{asset_alias_id}")
+        self.response = 
self.client.get(f"assets/aliases/{_quote_path_param(asset_alias_id)}")
         return validate_response(self.response.content, AssetAliasResponse)
 
     def list(self) -> AssetCollectionResponse | ServerResponseError:
@@ -383,69 +394,78 @@ class AssetsOperations(BaseOperations):
 
     def materialize(self, asset_id: str) -> DAGRunResponse | 
ServerResponseError:
         """Materialize an asset."""
-        self.response = self.client.post(f"assets/{asset_id}/materialize", 
json={})
+        self.response = 
self.client.post(f"assets/{_quote_path_param(asset_id)}/materialize", json={})
         return validate_response(self.response.content, DAGRunResponse)
 
     def get_queued_events(self, asset_id: str) -> 
QueuedEventCollectionResponse | ServerResponseError:
         """Get queued events for an asset."""
-        self.response = self.client.get(f"assets/{asset_id}/queuedEvents")
+        self.response = 
self.client.get(f"assets/{_quote_path_param(asset_id)}/queuedEvents")
         return validate_response(self.response.content, 
QueuedEventCollectionResponse)
 
     def get_dag_queued_events(
         self, dag_id: str, before: str
     ) -> QueuedEventCollectionResponse | ServerResponseError:
         """Get queued events for a dag."""
-        self.response = self.client.get(f"dags/{dag_id}/assets/queuedEvents", 
params={"before": before})
+        self.response = self.client.get(
+            f"dags/{_quote_path_param(dag_id)}/assets/queuedEvents", 
params={"before": before}
+        )
         return validate_response(self.response.content, 
QueuedEventCollectionResponse)
 
     def get_dag_queued_event(self, dag_id: str, asset_id: str) -> 
QueuedEventResponse | ServerResponseError:
         """Get a queued event for a dag."""
-        self.response = 
self.client.get(f"dags/{dag_id}/assets/{asset_id}/queuedEvents")
+        self.response = self.client.get(
+            
f"dags/{_quote_path_param(dag_id)}/assets/{_quote_path_param(asset_id)}/queuedEvents"
+        )
         return validate_response(self.response.content, QueuedEventResponse)
 
     def delete_queued_events(self, asset_id: str) -> str | ServerResponseError:
         """Delete a queued event for an asset."""
-        self.client.delete(f"assets/{asset_id}/queuedEvents")
+        
self.client.delete(f"assets/{_quote_path_param(asset_id)}/queuedEvents")
         return asset_id
 
     def delete_dag_queued_events(self, dag_id: str, before: str) -> str | 
ServerResponseError:
         """Delete a queued event for a Dag."""
-        self.client.delete(f"dags/{dag_id}/assets/queuedEvents", 
params={"before": before})
+        
self.client.delete(f"dags/{_quote_path_param(dag_id)}/assets/queuedEvents", 
params={"before": before})
         return dag_id
 
     def delete_queued_event(self, dag_id: str, asset_id: str) -> str | 
ServerResponseError:
         """Delete a queued event for a Dag."""
-        self.client.delete(f"dags/{dag_id}/assets/{asset_id}/queuedEvents")
+        self.client.delete(
+            
f"dags/{_quote_path_param(dag_id)}/assets/{_quote_path_param(asset_id)}/queuedEvents"
+        )
         return asset_id
 
     def list_state_store(self, asset_id: str) -> 
AssetStateStoreCollectionResponse | ServerResponseError:
         """List all state store entries for an asset."""
         return super().execute_list(
-            path=f"assets/{asset_id}/state-store", 
data_model=AssetStateStoreCollectionResponse
+            path=f"assets/{_quote_path_param(asset_id)}/state-store",
+            data_model=AssetStateStoreCollectionResponse,
         )
 
     def get_state_store(self, asset_id: str, key: str) -> 
AssetStateStoreResponse | ServerResponseError:
         """Get a single asset state store entry."""
-        self.response = self.client.get(f"assets/{asset_id}/state-store/{key}")
+        self.response = self.client.get(
+            
f"assets/{_quote_path_param(asset_id)}/state-store/{_quote_path_param(key)}"
+        )
         return validate_response(self.response.content, 
AssetStateStoreResponse)
 
     def set_state_store(self, asset_id: str, key: str, value: str) -> str | 
ServerResponseError:
         """Set an asset state store value. Creates or overwrites the key."""
         parsed_value = _parse_json_or_str(value)
         self.client.put(
-            f"assets/{asset_id}/state-store/{key}",
+            
f"assets/{_quote_path_param(asset_id)}/state-store/{_quote_path_param(key)}",
             
json=AssetStateStoreBody(value=parsed_value).model_dump(mode="json"),
         )
         return key
 
     def delete_state_store(self, asset_id: str, key: str) -> str | 
ServerResponseError:
         """Delete a single asset state store key."""
-        self.client.delete(f"assets/{asset_id}/state-store/{key}")
+        
self.client.delete(f"assets/{_quote_path_param(asset_id)}/state-store/{_quote_path_param(key)}")
         return key
 
     def clear_state_store(self, asset_id: str) -> str | ServerResponseError:
         """Delete all state store keys for an asset."""
-        self.client.delete(f"assets/{asset_id}/state-store")
+        self.client.delete(f"assets/{_quote_path_param(asset_id)}/state-store")
         return asset_id
 
 
@@ -469,7 +489,7 @@ class BackfillOperations(BaseOperations):
 
     def get(self, backfill_id: str) -> BackfillResponse | ServerResponseError:
         """Get a backfill."""
-        self.response = self.client.get(f"backfills/{backfill_id}")
+        self.response = 
self.client.get(f"backfills/{_quote_path_param(backfill_id)}")
         return validate_response(self.response.content, BackfillResponse)
 
     def list(self, dag_id: str) -> BackfillCollectionResponse | 
ServerResponseError:
@@ -479,17 +499,17 @@ class BackfillOperations(BaseOperations):
 
     def pause(self, backfill_id: str) -> BackfillResponse | 
ServerResponseError:
         """Pause a backfill."""
-        self.response = self.client.put(f"backfills/{backfill_id}/pause")
+        self.response = 
self.client.put(f"backfills/{_quote_path_param(backfill_id)}/pause")
         return validate_response(self.response.content, BackfillResponse)
 
     def unpause(self, backfill_id: str) -> BackfillResponse | 
ServerResponseError:
         """Unpause a backfill."""
-        self.response = self.client.put(f"backfills/{backfill_id}/unpause")
+        self.response = 
self.client.put(f"backfills/{_quote_path_param(backfill_id)}/unpause")
         return validate_response(self.response.content, BackfillResponse)
 
     def cancel(self, backfill_id: str) -> BackfillResponse | 
ServerResponseError:
         """Cancel a backfill."""
-        self.response = self.client.put(f"backfills/{backfill_id}/cancel")
+        self.response = 
self.client.put(f"backfills/{_quote_path_param(backfill_id)}/cancel")
         return validate_response(self.response.content, BackfillResponse)
 
 
@@ -498,7 +518,9 @@ class ConfigOperations(BaseOperations):
 
     def get(self, section: str, option: str) -> Config | ServerResponseError:
         """Get a config from the API server."""
-        self.response = 
self.client.get(f"/config/section/{section}/option/{option}")
+        self.response = self.client.get(
+            
f"/config/section/{_quote_path_param(section)}/option/{_quote_path_param(option)}"
+        )
         return validate_response(self.response.content, Config)
 
     def list(self) -> Config | ServerResponseError:
@@ -512,7 +534,7 @@ class ConnectionsOperations(BaseOperations):
 
     def get(self, conn_id: str) -> ConnectionResponse | ServerResponseError:
         """Get a connection from the API server."""
-        self.response = self.client.get(f"connections/{conn_id}")
+        self.response = 
self.client.get(f"connections/{_quote_path_param(conn_id)}")
         return validate_response(self.response.content, ConnectionResponse)
 
     def list(self) -> ConnectionCollectionResponse | ServerResponseError:
@@ -543,7 +565,7 @@ class ConnectionsOperations(BaseOperations):
 
     def delete(self, conn_id: str) -> str | ServerResponseError:
         """Delete a connection."""
-        self.client.delete(f"connections/{conn_id}")
+        self.client.delete(f"connections/{_quote_path_param(conn_id)}")
         return conn_id
 
     def update(
@@ -552,7 +574,7 @@ class ConnectionsOperations(BaseOperations):
     ) -> ConnectionResponse | ServerResponseError:
         """Update a connection."""
         self.response = self.client.patch(
-            f"connections/{connection.connection_id}",
+            f"connections/{_quote_path_param(connection.connection_id)}",
             json=connection.model_dump(mode="json", by_alias=True, 
exclude_none=True),
         )
         return validate_response(self.response.content, ConnectionResponse)
@@ -574,12 +596,12 @@ class DagsOperations(BaseOperations):
 
     def get(self, dag_id: str) -> DAGResponse | ServerResponseError:
         """Get a Dag."""
-        self.response = self.client.get(f"dags/{dag_id}")
+        self.response = self.client.get(f"dags/{_quote_path_param(dag_id)}")
         return validate_response(self.response.content, DAGResponse)
 
     def get_details(self, dag_id: str) -> DAGDetailsResponse | 
ServerResponseError:
         """Get a Dag details."""
-        self.response = self.client.get(f"dags/{dag_id}/details")
+        self.response = 
self.client.get(f"dags/{_quote_path_param(dag_id)}/details")
         return validate_response(self.response.content, DAGDetailsResponse)
 
     def get_tags(self) -> DAGTagCollectionResponse | ServerResponseError:
@@ -591,15 +613,17 @@ class DagsOperations(BaseOperations):
         return super().execute_list(path="dags", 
data_model=DAGCollectionResponse)
 
     def update(self, dag_id: str, dag_body: DAGPatchBody) -> DAGResponse | 
ServerResponseError:
-        self.response = self.client.patch(f"dags/{dag_id}", 
json=dag_body.model_dump(mode="json"))
+        self.response = self.client.patch(
+            f"dags/{_quote_path_param(dag_id)}", 
json=dag_body.model_dump(mode="json")
+        )
         return validate_response(self.response.content, DAGResponse)
 
     def delete(self, dag_id: str) -> str | ServerResponseError:
-        self.client.delete(f"dags/{dag_id}")
+        self.client.delete(f"dags/{_quote_path_param(dag_id)}")
         return dag_id
 
     def get_import_error(self, import_error_id: str) -> ImportErrorResponse | 
ServerResponseError:
-        self.response = self.client.get(f"importErrors/{import_error_id}")
+        self.response = 
self.client.get(f"importErrors/{_quote_path_param(import_error_id)}")
         return validate_response(self.response.content, ImportErrorResponse)
 
     def list_import_errors(self) -> ImportErrorCollectionResponse | 
ServerResponseError:
@@ -610,12 +634,14 @@ class DagsOperations(BaseOperations):
         return validate_response(self.response.content, 
DagStatsCollectionResponse)
 
     def get_version(self, dag_id: str, version_number: int) -> 
DagVersionResponse | ServerResponseError:
-        self.response = 
self.client.get(f"dags/{dag_id}/dagVersions/{version_number}")
+        self.response = self.client.get(
+            
f"dags/{_quote_path_param(dag_id)}/dagVersions/{_quote_path_param(version_number)}"
+        )
         return validate_response(self.response.content, DagVersionResponse)
 
     def list_version(self, dag_id: str) -> DAGVersionCollectionResponse | 
ServerResponseError:
         return super().execute_list(
-            path=f"dags/{dag_id}/dagVersions", 
data_model=DAGVersionCollectionResponse
+            path=f"dags/{_quote_path_param(dag_id)}/dagVersions", 
data_model=DAGVersionCollectionResponse
         )
 
     def list_warning(self) -> DAGWarningCollectionResponse | 
ServerResponseError:
@@ -628,7 +654,8 @@ class DagsOperations(BaseOperations):
         if trigger_dag_run.conf is None:
             trigger_dag_run.conf = {}
         self.response = self.client.post(
-            f"dags/{dag_id}/dagRuns", 
json=trigger_dag_run.model_dump(mode="json", exclude_defaults=True)
+            f"dags/{_quote_path_param(dag_id)}/dagRuns",
+            json=trigger_dag_run.model_dump(mode="json", 
exclude_defaults=True),
         )
         return validate_response(self.response.content, DAGRunResponse)
 
@@ -641,7 +668,7 @@ class DagRunOperations(BaseOperations):
     ) -> DAGRunResponse | ServerResponseError:
         """Get a Dag run."""
         self.response = self.client.get(
-            f"/dags/{dag_id}/dagRuns/{dag_run_id}",
+            
f"/dags/{_quote_path_param(dag_id)}/dagRuns/{_quote_path_param(dag_run_id)}",
             extensions={"airflowctl_suppress_error_log": suppress_error_log},
         )
         return validate_response(self.response.content, DAGRunResponse)
@@ -702,7 +729,7 @@ class DagRunOperations(BaseOperations):
         )
 
         self.response = self.client.get(
-            f"/dags/{dag_id}/dagRuns",
+            f"/dags/{_quote_path_param(dag_id)}/dagRuns",
             params=params,
             extensions={"airflowctl_suppress_error_log": suppress_error_log},
         )
@@ -710,7 +737,7 @@ class DagRunOperations(BaseOperations):
 
     def delete(self, dag_id: str, dag_run_id: str) -> str | 
ServerResponseError:
         """Delete a Dag run."""
-        self.client.delete(f"/dags/{dag_id}/dagRuns/{dag_run_id}")
+        
self.client.delete(f"/dags/{_quote_path_param(dag_id)}/dagRuns/{_quote_path_param(dag_run_id)}")
         return dag_run_id
 
 
@@ -752,7 +779,7 @@ class PoolsOperations(BaseOperations):
 
     def get(self, pool_name: str) -> PoolResponse | ServerResponseError:
         """Get a pool."""
-        self.response = self.client.get(f"pools/{pool_name}")
+        self.response = 
self.client.get(f"pools/{_quote_path_param(pool_name)}")
         return validate_response(self.response.content, PoolResponse)
 
     def list(self) -> PoolCollectionResponse | ServerResponseError:
@@ -771,13 +798,14 @@ class PoolsOperations(BaseOperations):
 
     def delete(self, pool: str) -> str | ServerResponseError:
         """Delete a pool."""
-        self.client.delete(f"pools/{pool}")
+        self.client.delete(f"pools/{_quote_path_param(pool)}")
         return pool
 
     def update(self, pool_body: PoolPatchBody) -> PoolResponse | 
ServerResponseError:
         """Update a pool."""
         self.response = self.client.patch(
-            f"pools/{pool_body.pool}", json=pool_body.model_dump(mode="json", 
exclude_none=True)
+            f"pools/{_quote_path_param(pool_body.pool)}",
+            json=pool_body.model_dump(mode="json", exclude_none=True),
         )
         return validate_response(self.response.content, PoolResponse)
 
@@ -787,7 +815,7 @@ class ProvidersOperations(BaseOperations):
 
     def get(self, provider_name: str) -> ProviderDetailsResponse | 
ServerResponseError:
         """Get detailed information for a provider."""
-        self.response = self.client.get(f"providers/{provider_name}")
+        self.response = 
self.client.get(f"providers/{_quote_path_param(provider_name)}")
         return validate_response(self.response.content, 
ProviderDetailsResponse)
 
     def list(self) -> ProviderCollectionResponse | ServerResponseError:
@@ -797,9 +825,12 @@ class ProvidersOperations(BaseOperations):
 
 def _build_task_instance_path(dag_id: str, dag_run_id: str, task_id: str, 
map_index: int | None) -> str:
     """Build the task instance API path, addressing a mapped task instance 
when map_index is given."""
-    path = f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}"
+    path = (
+        
f"dags/{_quote_path_param(dag_id)}/dagRuns/{_quote_path_param(dag_run_id)}"
+        f"/taskInstances/{_quote_path_param(task_id)}"
+    )
     if map_index is not None and map_index >= 0:
-        path = f"{path}/{map_index}"
+        path = f"{path}/{_quote_path_param(map_index)}"
     return path
 
 
@@ -847,7 +878,7 @@ class TaskInstancesOperations(BaseOperations):
     def list(self, dag_id: str, dag_run_id: str) -> 
TaskInstanceCollectionResponse | ServerResponseError:
         """List task instances for a Dag run."""
         return super().execute_list(
-            path=f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances",
+            
path=f"dags/{_quote_path_param(dag_id)}/dagRuns/{_quote_path_param(dag_run_id)}/taskInstances",
             data_model=TaskInstanceCollectionResponse,
         )
 
@@ -856,7 +887,7 @@ class TaskInstancesOperations(BaseOperations):
     ) -> TaskStateStoreCollectionResponse | ServerResponseError:
         """List all state store entries for a task instance."""
         return super().execute_list(
-            
path=f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store",
+            path=f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/state-store",
             data_model=TaskStateStoreCollectionResponse,
             params=_build_query_params(map_index=map_index),
         )
@@ -866,7 +897,7 @@ class TaskInstancesOperations(BaseOperations):
     ) -> TaskStateStoreResponse | ServerResponseError:
         """Get a single task state store entry."""
         self.response = self.client.get(
-            
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}",
+            f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/state-store/{_quote_path_param(key)}",
             params=_build_query_params(map_index=map_index),
         )
         return validate_response(self.response.content, TaskStateStoreResponse)
@@ -883,7 +914,7 @@ class TaskInstancesOperations(BaseOperations):
         """Set a task state store value. Creates or overwrites the key."""
         parsed_value = _parse_json_or_str(value)
         self.client.put(
-            
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}",
+            f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/state-store/{_quote_path_param(key)}",
             
json=TaskStateStoreBody(value=parsed_value).model_dump(mode="json"),
             params=_build_query_params(map_index=map_index),
         )
@@ -901,7 +932,7 @@ class TaskInstancesOperations(BaseOperations):
         """Update the value of an existing task state store key."""
         parsed_value = _parse_json_or_str(value)
         self.client.patch(
-            
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}",
+            f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/state-store/{_quote_path_param(key)}",
             
json=TaskStateStorePatchBody(value=parsed_value).model_dump(mode="json"),
             params=_build_query_params(map_index=map_index),
         )
@@ -912,7 +943,7 @@ class TaskInstancesOperations(BaseOperations):
     ) -> str | ServerResponseError:
         """Delete a single task state store key."""
         self.client.delete(
-            
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store/{key}",
+            f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/state-store/{_quote_path_param(key)}",
             params=_build_query_params(map_index=map_index),
         )
         return key
@@ -927,7 +958,7 @@ class TaskInstancesOperations(BaseOperations):
     ) -> str | ServerResponseError:
         """Delete all task state store keys for a task instance."""
         self.client.delete(
-            
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/state-store",
+            f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/state-store",
             params=_build_query_params(map_index=map_index, 
all_map_indices=all_map_indices or None),
         )
         return task_id
@@ -941,7 +972,7 @@ class TasksOperations(BaseOperations):
     ) -> TaskInstanceCollectionResponse | ServerResponseError:
         """Clear task instances of a Dag; with dry_run (the default) only 
previews the affected task instances."""
         self.response = self.client.post(
-            f"dags/{dag_id}/clearTaskInstances",
+            f"dags/{_quote_path_param(dag_id)}/clearTaskInstances",
             json=clear_task_instances.model_dump(mode="json", 
exclude_defaults=True),
         )
         return validate_response(self.response.content, 
TaskInstanceCollectionResponse)
@@ -952,7 +983,7 @@ class VariablesOperations(BaseOperations):
 
     def get(self, variable_key: str) -> VariableResponse | ServerResponseError:
         """Get a variable."""
-        self.response = self.client.get(f"variables/{variable_key}")
+        self.response = 
self.client.get(f"variables/{_quote_path_param(variable_key)}")
         return validate_response(self.response.content, VariableResponse)
 
     def list(self) -> VariableCollectionResponse | ServerResponseError:
@@ -973,13 +1004,14 @@ class VariablesOperations(BaseOperations):
 
     def delete(self, variable_key: str) -> str | ServerResponseError:
         """Delete a variable."""
-        self.client.delete(f"variables/{variable_key}")
+        self.client.delete(f"variables/{_quote_path_param(variable_key)}")
         return variable_key
 
     def update(self, variable: VariableBody) -> VariableResponse | 
ServerResponseError:
         """Update a variable."""
         self.response = self.client.patch(
-            f"variables/{variable.key}", json=variable.model_dump(mode="json", 
exclude_none=True)
+            f"variables/{_quote_path_param(variable.key)}",
+            json=variable.model_dump(mode="json", exclude_none=True),
         )
         return validate_response(self.response.content, VariableResponse)
 
@@ -1009,7 +1041,7 @@ class XComOperations(BaseOperations):
         if map_index is not None:
             params["map_index"] = map_index
         self.response = self.client.get(
-            
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{key}",
+            f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/xcomEntries/{_quote_path_param(key)}",
             params=params,
         )
         return validate_response(self.response.content, XComResponseNative)
@@ -1029,7 +1061,7 @@ class XComOperations(BaseOperations):
         if key is not None:
             params["xcom_key"] = key
         return super().execute_list(
-            
path=f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries",
+            path=f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/xcomEntries",
             data_model=XComCollectionResponse,
             params=params,
         )
@@ -1051,7 +1083,7 @@ class XComOperations(BaseOperations):
             body_dict["map_index"] = map_index
         body = XComCreateBody(**body_dict)
         self.response = self.client.post(
-            
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries",
+            f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/xcomEntries",
             json=body.model_dump(mode="json", exclude_unset=True, 
exclude_none=True),
         )
         return validate_response(self.response.content, XComResponseNative)
@@ -1073,7 +1105,7 @@ class XComOperations(BaseOperations):
             body_dict["map_index"] = map_index
         body = XComUpdateBody(**body_dict)
         self.response = self.client.patch(
-            
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{key}",
+            f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/xcomEntries/{_quote_path_param(key)}",
             json=body.model_dump(mode="json", exclude_unset=True, 
exclude_none=True),
         )
         return validate_response(self.response.content, XComResponseNative)
@@ -1091,7 +1123,7 @@ class XComOperations(BaseOperations):
         if map_index is not None:
             params["map_index"] = map_index
         self.client.delete(
-            
f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/xcomEntries/{key}",
+            f"{_build_task_instance_path(dag_id, dag_run_id, task_id, 
None)}/xcomEntries/{_quote_path_param(key)}",
             params=params,
         )
         return key
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py 
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 91ebe43b17b..2aa78e6acb1 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -158,6 +158,24 @@ class TestBaseOperations:
             "order_by": "-id",
         }
 
+    @pytest.mark.parametrize(
+        ("operations", "args", "expected_path"),
+        [
+            ("variables", ("a#b",), "/api/v2/variables/a%23b"),
+            ("variables", ("..",), "/api/v2/variables/%2E%2E"),
+            ("connections", ("a?b",), "/api/v2/connections/a%3Fb"),
+            ("pools", ("../variables/key",), 
"/api/v2/pools/..%2Fvariables%2Fkey"),
+            ("dag_runs", ("dag_id", "run#1"), 
"/api/v2/dags/dag_id/dagRuns/run%231"),
+        ],
+    )
+    def test_delete_encodes_path_params(self, operations, args, expected_path):
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            assert request.url.raw_path.decode() == expected_path
+            return httpx.Response(204)
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        getattr(client, operations).delete(*args)
+
     def test_server_connection_refused(self):
         # A real connect to localhost depends on the machine: a local web 
server answers, and WSL2
         # mirrored networking turns a closed loopback port into a timeout 
instead of a refusal.

Reply via email to