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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 0 additions & 24 deletions airflow-core/newsfragments/68175.significant.rst

This file was deleted.

29 changes: 11 additions & 18 deletions airflow-core/src/airflow/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,10 @@

from __future__ import annotations

import functools
import sys
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, TypeVar

from airflow.exceptions import RemovedInAirflow4Warning

# Placeholder for masking sensitive values in CLI output
SENSITIVE_PLACEHOLDER = "***"

Expand All @@ -44,25 +40,22 @@ def deprecated_for_airflowctl(replacement: str) -> Callable[[F], F]:
"""
Mark an ``airflow`` CLI command as deprecated in favour of an ``airflowctl`` equivalent.

These commands now reach Airflow through the API server via the ``airflowctl`` client. They
are kept for backwards compatibility but will be removed in a future Airflow release; users
should switch to ``airflowctl`` directly.
The command keeps its existing implementation and stays in the ``airflow`` CLI as a supported
entry point, so it emits **no user-facing deprecation warning** at runtime. The intent is to
point future development at ``airflowctl``: the equivalent ``airflowctl`` command is recorded
for maintainers only, on the ``_migrated_to_airflowctl`` attribute (the migration registry test
in ``test_command_deprecations.py`` reads it). The decorator at the command's definition site is
the developer-facing trace -- it is source-only and never rendered to users.

See ``contributing-docs/27_cli_implementation_guide.rst`` for the CLI / ``airflowctl``
development guidance.

:param replacement: The equivalent ``airflowctl`` command, e.g. ``airflowctl dags trigger``.
"""

def decorator(func: F) -> F:
@functools.wraps(func)
def wrapper(*args, **kwargs):
warnings.warn(
f"This `airflow` CLI command is deprecated and will be removed in a future "
f"Airflow release. Use `{replacement}` instead.",
RemovedInAirflow4Warning,
stacklevel=2,
)
return func(*args, **kwargs)

return wrapper # type: ignore[return-value]
func._migrated_to_airflowctl = replacement # type: ignore[attr-defined]
return func

return decorator

Expand Down
64 changes: 27 additions & 37 deletions airflow-core/tests/unit/cli/commands/test_command_deprecations.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,55 +18,45 @@
"""
Single source of truth for the ``airflow`` CLI commands deprecated in favour of ``airflowctl``.

Every command decorated with ``deprecated_for_airflowctl`` must have one entry below. When a new
command is migrated and deprecated, add a row to ``DEPRECATED_CLI_COMMANDS`` -- the test then
verifies it emits ``RemovedInAirflow4Warning`` pointing at the right ``airflowctl`` command.
Every command decorated with ``deprecated_for_airflowctl`` must have one entry below. When a
command is deprecated, add a row to ``MIGRATED_CLI_COMMANDS`` -- the test then verifies the decorator
recorded the right ``airflowctl`` replacement for maintainers. The commands stay in the ``airflow``
CLI as supported entry points, so they emit no user-facing deprecation warning; they are simply no
longer developed here -- new work belongs in ``airflowctl``. See
``contributing-docs/27_cli_implementation_guide.rst`` for the CLI / ``airflowctl`` direction.
"""

from __future__ import annotations

import contextlib
import re

import pytest

from airflow.cli.commands import asset_command, dag_command, pool_command
from airflow.exceptions import RemovedInAirflow4Warning

# (command callable, argv to parse, expected airflowctl replacement named in the warning)
DEPRECATED_CLI_COMMANDS = [
(dag_command.dag_trigger, ["dags", "trigger", "example_dag", "--run-id=x"], "airflowctl dags trigger"),
(dag_command.dag_delete, ["dags", "delete", "example_dag", "--yes"], "airflowctl dags delete"),
(pool_command.pool_list, ["pools", "list"], "airflowctl pools list"),
(pool_command.pool_get, ["pools", "get", "foo"], "airflowctl pools get"),
(pool_command.pool_set, ["pools", "set", "foo", "1", "desc"], "airflowctl pools create"),
(pool_command.pool_delete, ["pools", "delete", "foo"], "airflowctl pools delete"),
(pool_command.pool_import, ["pools", "import", "/nonexistent.json"], "airflowctl pools import"),
(
pool_command.pool_export,
["pools", "export", "/tmp/airflow_pools_export.json"],
"airflowctl pools export",
),
(
asset_command.asset_materialize,
["assets", "materialize", "--name=foo"],
"airflowctl assets materialize",
),
# (command callable, expected airflowctl replacement recorded by the decorator)
MIGRATED_CLI_COMMANDS = [
(dag_command.dag_trigger, "airflowctl dags trigger"),
(dag_command.dag_delete, "airflowctl dags delete"),
(pool_command.pool_list, "airflowctl pools list"),
(pool_command.pool_get, "airflowctl pools get"),
(pool_command.pool_set, "airflowctl pools create"),
(pool_command.pool_delete, "airflowctl pools delete"),
(pool_command.pool_import, "airflowctl pools import"),
(pool_command.pool_export, "airflowctl pools export"),
(asset_command.asset_materialize, "airflowctl assets materialize"),
]


@pytest.mark.parametrize(
("command", "argv", "replacement"),
DEPRECATED_CLI_COMMANDS,
ids=[argv[0] + "-" + argv[1] for _, argv, _ in DEPRECATED_CLI_COMMANDS],
("command", "replacement"),
MIGRATED_CLI_COMMANDS,
ids=[replacement for _, replacement in MIGRATED_CLI_COMMANDS],
)
def test_deprecated_cli_command_points_to_airflowctl(command, argv, replacement, parser, mock_cli_api_client):
"""Each migrated command warns it will become an alias for its ``airflowctl`` counterpart.
def test_migrated_cli_command_records_airflowctl_replacement(command, replacement):
"""Each migrated command records its ``airflowctl`` counterpart for maintainers.

We only assert the deprecation warning fires (and names the right replacement); the command
body itself is exercised by the per-command test modules, so any error it raises against the
bare mocked client is irrelevant here and suppressed.
The marker is the maintainer-facing trace of the migration; users see no runtime deprecation
warning. The command body itself is exercised by the per-command test modules.
``functools.wraps`` on the outer ``action_cli`` decorator propagates the attribute up to the
command object imported here.
"""
with pytest.warns(RemovedInAirflow4Warning, match=re.escape(replacement)):
with contextlib.suppress(Exception, SystemExit):
command(parser.parse_args(argv))
assert getattr(command, "_migrated_to_airflowctl", None) == replacement
20 changes: 11 additions & 9 deletions airflow-core/tests/unit/cli/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,34 @@
# under the License.
from __future__ import annotations

import pytest
import warnings

from airflow.cli.utils import deprecated_for_airflowctl
from airflow.exceptions import RemovedInAirflow4Warning


class TestDeprecatedForAirflowctl:
def test_emits_warning_naming_replacement(self):
def test_records_replacement_without_emitting_a_user_warning(self):
@deprecated_for_airflowctl("airflowctl dags trigger")
def command(args):
return "result"

with pytest.warns(RemovedInAirflow4Warning, match="airflowctl dags trigger"):
# Calling the command emits nothing to users (any warning would become an error here).
with warnings.catch_warnings():
warnings.simplefilter("error")
result = command(args=None)

# The wrapped command still runs and returns its value.
assert result == "result"
# The replacement is recorded for maintainers, not shown to users.
assert command._migrated_to_airflowctl == "airflowctl dags trigger"

def test_passes_through_args_and_preserves_metadata(self):
def test_passes_through_args_and_leaves_function_untouched(self):
@deprecated_for_airflowctl("airflowctl pools create")
def command(a, b, *, c):
"""Original docstring."""
return (a, b, c)

with pytest.warns(RemovedInAirflow4Warning):
assert command(1, 2, c=3) == (1, 2, 3)

assert command(1, 2, c=3) == (1, 2, 3)
# The decorator returns the original function untouched apart from the metadata it records.
assert command.__name__ == "command"
assert command.__doc__ == "Original docstring."
assert command._migrated_to_airflowctl == "airflowctl pools create"
Loading