This is an automated email from the ASF dual-hosted git repository.
shahar1 pushed a commit to branch airflow-ctl/v0-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/airflow-ctl/v0-1-test by this
push:
new a76bfabe30e [airflow-ctl/v0-1-test] Escape Rich markup in airflowctl
variable and pool commands (#76419) (#76476)
a76bfabe30e is described below
commit a76bfabe30eb64b72f631354f28cd0778ef9ebe9
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Nov 4 20:45:23 2026 +0200
[airflow-ctl/v0-1-test] Escape Rich markup in airflowctl variable and pool
commands (#76419) (#76476)
User-supplied inputs such as file paths, imported variable keys, and server
error responses containing square brackets or Rich markup tags can cause
unhandled rich.errors.MarkupError crashes when printed via rich.print or
Console().print. Escaping these strings ensures special characters are
treated as literal text rather than formatting tags.
(cherry picked from commit 6920d2c25f47d4e813710e2882c2d0941eb465fc)
Co-authored-by: Vardhman Gupta <[email protected]>
---
.../src/airflowctl/ctl/commands/pool_command.py | 3 +-
.../airflowctl/ctl/commands/variable_command.py | 9 ++---
.../airflow_ctl/ctl/commands/test_pool_command.py | 33 ++++++++++++++++
.../ctl/commands/test_variable_command.py | 45 ++++++++++++++++++++++
4 files changed, 83 insertions(+), 7 deletions(-)
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
b/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
index 588656a7bf8..f4de3ab8c6a 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
@@ -24,6 +24,7 @@ from pathlib import Path
import rich
from pydantic import ValidationError
+from rich.markup import escape
from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind,
provide_api_client
from airflowctl.api.datamodels.generated import (
@@ -79,7 +80,7 @@ def export(args, api_client: Client = NEW_API_CLIENT) -> None:
file_path = Path(args.file)
with open(file_path, "w") as f:
json.dump(pools_list, f, indent=4, sort_keys=True)
- rich.print(f"Exported {pools_response.total_entries} pool(s) to
{args.file}")
+ rich.print(f"Exported {pools_response.total_entries} pool(s) to
{escape(str(args.file))}")
else:
AirflowConsole().print_as(data=pools_list, output=args.output)
except Exception as e:
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
b/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
index 750b94eb77f..06d5913c932 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
@@ -36,15 +36,12 @@ from airflowctl.ctl.utils.validation import
split_validation_errors
def _print_file_error(message: str, file_path: str) -> None:
- Console().print(f"[red]{message}: {file_path}", soft_wrap=True)
+ Console().print(f"[red]{message}: {escape(file_path)}[/red]",
soft_wrap=True)
@provide_api_client(kind=ClientKind.CLI)
def import_(args, api_client=NEW_API_CLIENT) -> list[str]:
"""Import variables from a given file."""
- success_message = "[green]Import successful! success: {success}[/green]"
- errors_message = "[red]Import failed! errors: {errors}[/red]"
-
if not os.path.exists(args.file):
_print_file_error("Missing variable file", args.file)
sys.exit(1)
@@ -87,8 +84,8 @@ def import_(args, api_client=NEW_API_CLIENT) -> list[str]:
)
result = api_client.variables.bulk(variables=bulk_body)
if result.create.errors:
- rich.print(errors_message.format(errors=result.create.errors))
+ rich.print(f"[red]Import failed! errors:
{escape(str(result.create.errors))}[/red]")
sys.exit(1)
- rich.print(success_message.format(success=result.create.success))
+ rich.print(f"[green]Import successful! success:
{escape(str(result.create.success))}[/green]")
return result.create.success
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
index f66350067a1..995afc078dd 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
@@ -312,3 +312,36 @@ class TestPoolExportCommand:
with pytest.raises(SystemExit, match="Failed to export pools: API
Error"):
pool_command.export(mock.MagicMock(file=export_file,
output="json"))
+
+ def test_export_json_file_path_containing_markup(self, mock_client,
tmp_path, capsys):
+ """Test that exporting to a file path containing markup tags does not
crash."""
+ markup_dir = tmp_path / "dir["
+ markup_dir.mkdir()
+ export_file = markup_dir / "test].json"
+ mock_pools = mock.MagicMock()
+ mock_pools.pools = [
+ type(
+ "Pool",
+ (),
+ {
+ "name": "p",
+ "slots": 1,
+ "description": "",
+ "include_deferred": False,
+ "occupied_slots": 0,
+ "running_slots": 0,
+ "queued_slots": 0,
+ "scheduled_slots": 0,
+ "open_slots": 1,
+ "deferred_slots": 0,
+ "team_name": None,
+ },
+ )()
+ ]
+ mock_pools.total_entries = 1
+ mock_client.pools.list.return_value = mock_pools
+
+ pool_command.export(mock.MagicMock(file=export_file, output="json"))
+
+ out = capsys.readouterr().out.replace("\n", "")
+ assert str(export_file) in out
diff --git
a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
index bbc51738e8a..2d3888c870d 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
@@ -195,3 +195,48 @@ class TestCliVariableCommands:
self.parser.parse_args(["variables", "import",
expected_json_path.as_posix()]),
api_client=api_client,
)
+
+ def test_print_file_error_containing_markup(self, capsys):
+ variable_command._print_file_error("Missing variable file",
"/tmp/[/x]/vars.json")
+ output = capsys.readouterr().out
+ assert "/tmp/[/x]/vars.json" in output
+
+ def test_import_success_prints_variable_keys_containing_markup(self,
tmp_path, monkeypatch, capsys):
+ bulk_response = BulkResponse(
+ create=BulkActionResponse(success=["key_[/x]"], errors=[]),
update=None, delete=None
+ )
+ api_client = SimpleNamespace(variables=SimpleNamespace(bulk=lambda
variables: bulk_response))
+ monkeypatch.chdir(tmp_path)
+ expected_json_path = tmp_path / self.export_file_name
+ expected_json_path.write_text(json.dumps({"key_[/x]": "value"}))
+
+ response = variable_command.import_(
+ self.parser.parse_args(["variables", "import",
expected_json_path.as_posix()]),
+ api_client=api_client,
+ )
+ assert response == ["key_[/x]"]
+ output = capsys.readouterr().out
+ assert "key_[/x]" in output
+
+ def test_import_error_containing_markup(self, tmp_path, monkeypatch,
capsys):
+ bulk_response = BulkResponse(
+ create=BulkActionResponse(
+ success=[],
+ errors=[{"error": "Failed for [/x] key", "status_code": 400}],
+ ),
+ update=None,
+ delete=None,
+ )
+ api_client = SimpleNamespace(variables=SimpleNamespace(bulk=lambda
variables: bulk_response))
+ monkeypatch.chdir(tmp_path)
+ expected_json_path = tmp_path / self.export_file_name
+ expected_json_path.write_text(json.dumps({self.key: self.value}))
+
+ with pytest.raises(SystemExit) as exit_info:
+ variable_command.import_(
+ self.parser.parse_args(["variables", "import",
expected_json_path.as_posix()]),
+ api_client=api_client,
+ )
+ assert exit_info.value.code == 1
+ output = capsys.readouterr().out
+ assert "[/x]" in output