diff --git a/airflow-core/src/airflow/cli/commands/provider_command.py b/airflow-core/src/airflow/cli/commands/provider_command.py index 645618fd852cc..9175922676d72 100644 --- a/airflow-core/src/airflow/cli/commands/provider_command.py +++ b/airflow-core/src/airflow/cli/commands/provider_command.py @@ -21,7 +21,9 @@ import re import sys +from airflow.cli.api_client import NEW_API_CLIENT, Client, provide_api_client from airflow.cli.simple_table import AirflowConsole +from airflow.cli.utils import deprecated_for_airflowctl from airflow.providers_manager import ProvidersManager from airflow.utils.cli import suppress_logs_and_warning from airflow.utils.providers_configuration_loader import providers_configuration_loaded @@ -33,27 +35,35 @@ def _remove_rst_syntax(value: str) -> str: return re.sub("[`_<>]", "", value.strip(" \n.")) +@deprecated_for_airflowctl("airflowctl providers get") @suppress_logs_and_warning @providers_configuration_loaded -def provider_get(args): +@provide_api_client +def provider_get(args, api_client: Client = NEW_API_CLIENT): """Get a provider info.""" - providers = ProvidersManager().providers - if args.provider_name in providers: - provider_version = providers[args.provider_name].version - provider_info = providers[args.provider_name].data - if args.full: - provider_info["description"] = _remove_rst_syntax(provider_info["description"]) - AirflowConsole().print_as( - data=[provider_info], - output=args.output, - ) - else: - AirflowConsole().print_as( - data=[{"Provider": args.provider_name, "Version": provider_version}], output=args.output - ) - else: + # No single-provider API endpoint exists, so filter the providers collection by name. + providers = {provider.package_name: provider for provider in api_client.providers.list().providers} + provider = providers.get(args.provider_name) + if provider is None: raise SystemExit(f"No such provider installed: {args.provider_name}") + if args.full: + AirflowConsole().print_as( + data=[ + { + "package_name": provider.package_name, + "version": provider.version, + "description": _remove_rst_syntax(provider.description), + "documentation_url": provider.documentation_url, + } + ], + output=args.output, + ) + else: + AirflowConsole().print_as( + data=[{"Provider": provider.package_name, "Version": provider.version}], output=args.output + ) + @suppress_logs_and_warning @providers_configuration_loaded diff --git a/airflow-core/tests/unit/cli/commands/test_command_deprecations.py b/airflow-core/tests/unit/cli/commands/test_command_deprecations.py index b4eb6840c9069..92e13cee40361 100644 --- a/airflow-core/tests/unit/cli/commands/test_command_deprecations.py +++ b/airflow-core/tests/unit/cli/commands/test_command_deprecations.py @@ -30,7 +30,7 @@ import pytest -from airflow.cli.commands import asset_command, dag_command, pool_command +from airflow.cli.commands import asset_command, dag_command, pool_command, provider_command from airflow.exceptions import RemovedInAirflow4Warning # (command callable, argv to parse, expected airflowctl replacement named in the warning) @@ -52,6 +52,11 @@ ["assets", "materialize", "--name=foo"], "airflowctl assets materialize", ), + ( + provider_command.provider_get, + ["providers", "get", "apache-airflow-providers-amazon"], + "airflowctl providers get", + ), ] diff --git a/airflow-core/tests/unit/cli/commands/test_provider_command.py b/airflow-core/tests/unit/cli/commands/test_provider_command.py new file mode 100644 index 0000000000000..a1d4da5588596 --- /dev/null +++ b/airflow-core/tests/unit/cli/commands/test_provider_command.py @@ -0,0 +1,77 @@ +# +# 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 pytest +from airflowctl.api.datamodels.generated import ProviderResponse + +from airflow.cli import cli_parser +from airflow.cli.commands import provider_command + + +class TestCliProviderGet: + @classmethod + def setup_class(cls): + cls.parser = cli_parser.get_parser() + + @staticmethod + def _providers() -> list[ProviderResponse]: + return [ + ProviderResponse( + package_name="apache-airflow-providers-amazon", + description="Amazon provider", + version="9.0.0", + documentation_url="https://example.com", + ) + ] + + def test_provider_get(self, mock_cli_api_client, stdout_capture): + mock_cli_api_client.providers.list.return_value.providers = self._providers() + with stdout_capture as stdout: + provider_command.provider_get( + self.parser.parse_args( + ["providers", "get", "apache-airflow-providers-amazon", "--output", "json"] + ) + ) + assert json.loads(stdout.getvalue()) == [ + {"Provider": "apache-airflow-providers-amazon", "Version": "9.0.0"} + ] + + def test_provider_get_full(self, mock_cli_api_client, stdout_capture): + mock_cli_api_client.providers.list.return_value.providers = self._providers() + with stdout_capture as stdout: + provider_command.provider_get( + self.parser.parse_args( + ["providers", "get", "apache-airflow-providers-amazon", "--full", "--output", "json"] + ) + ) + assert json.loads(stdout.getvalue()) == [ + { + "package_name": "apache-airflow-providers-amazon", + "version": "9.0.0", + "description": "Amazon provider", + "documentation_url": "https://example.com", + } + ] + + def test_provider_get_not_found(self, mock_cli_api_client): + mock_cli_api_client.providers.list.return_value.providers = self._providers() + with pytest.raises(SystemExit, match="No such provider installed: does-not-exist"): + provider_command.provider_get(self.parser.parse_args(["providers", "get", "does-not-exist"])) diff --git a/airflow-ctl/docs/images/command_hashes.txt b/airflow-ctl/docs/images/command_hashes.txt index 53c93e7546d1e..b8a4b78e31825 100644 --- a/airflow-ctl/docs/images/command_hashes.txt +++ b/airflow-ctl/docs/images/command_hashes.txt @@ -8,7 +8,7 @@ dags:6b38e6bcd491bc1941e7814b77e63bde dagrun:c32e0011aa9a845456c778786717208e jobs:a5b644c5da8889443bb40ee10b599270 pools:19efe105b9515ab1926ebcaf0e028d71 -providers:34502fe09dc0b8b0a13e7e46efdffda6 +providers:532e07c3c11c6100a753e23a921efabd variables:f8fc76d3d398b2780f4e97f7cd816646 version:31f4efdf8de0dbaaa4fac71ff7efecc3 plugins:4864fd8f356704bd2b3cd1aec3567e35 diff --git a/airflow-ctl/docs/images/output_providers.svg b/airflow-ctl/docs/images/output_providers.svg index f4ce50e83cd57..983a543f3dfd5 100644 --- a/airflow-ctl/docs/images/output_providers.svg +++ b/airflow-ctl/docs/images/output_providers.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + + + + - + - + - - Usage:airflowctl providers [-hCOMMAND... - -Perform Providers operations - -Positional Arguments: -COMMAND -listList all installed Airflow providers - -Options: --h--helpshow this help message and exit + + Usage:airflowctl providers [-hCOMMAND... + +Perform Providers operations + +Positional Arguments: +COMMAND +getGet information about a single provider. +listList all installed Airflow providers + +Options: +-h--helpshow this help message and exit diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py b/airflow-ctl/src/airflowctl/ctl/cli_config.py index 11ff4542e01ef..a04f0ec7199d5 100755 --- a/airflow-ctl/src/airflowctl/ctl/cli_config.py +++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py @@ -319,6 +319,16 @@ def _load_help_texts_yaml() -> dict[str, dict[str, str]]: action="store_true", ) +# Providers command args +ARG_PROVIDER_NAME = Arg( + flags=("provider_name",), type=str, help="Provider package name, e.g. apache-airflow-providers-amazon" +) +ARG_PROVIDER_FULL = Arg( + flags=("-f", "--full"), + action="store_true", + help="Show full provider information instead of just the version.", +) + class ActionCommand(NamedTuple): """Single CLI command.""" @@ -1015,6 +1025,15 @@ def merge_commands( ), ) +PROVIDER_COMMANDS = ( + ActionCommand( + name="get", + help="Get information about a single provider.", + func=lazy_load_command("airflowctl.ctl.commands.provider_command.get"), + args=(ARG_PROVIDER_NAME, ARG_PROVIDER_FULL, ARG_OUTPUT), + ), +) + core_commands: list[CLICommand] = [ GroupCommand( name="auth", @@ -1042,6 +1061,11 @@ def merge_commands( help="Manage Airflow pools", subcommands=POOL_COMMANDS, ), + GroupCommand( + name="providers", + help="Manage Airflow providers", + subcommands=PROVIDER_COMMANDS, + ), ActionCommand( name="version", help="Show version information", diff --git a/airflow-ctl/src/airflowctl/ctl/commands/provider_command.py b/airflow-ctl/src/airflowctl/ctl/commands/provider_command.py new file mode 100644 index 0000000000000..fdf5635f09928 --- /dev/null +++ b/airflow-ctl/src/airflowctl/ctl/commands/provider_command.py @@ -0,0 +1,38 @@ +# 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. +"""Providers commands.""" + +from __future__ import annotations + +from airflowctl.api.client import NEW_API_CLIENT, ClientKind, provide_api_client +from airflowctl.ctl.console_formatting import AirflowConsole + + +@provide_api_client(kind=ClientKind.CLI) +def get(args, api_client=NEW_API_CLIENT) -> None: + """Get information about a single installed provider.""" + # No single-provider API endpoint exists, so filter the providers collection by name. + providers = {provider.package_name: provider for provider in api_client.providers.list().providers} + provider = providers.get(args.provider_name) + if provider is None: + raise SystemExit(f"No such provider installed: {args.provider_name}") + + if args.full: + data = provider.model_dump(mode="json") + else: + data = {"package_name": provider.package_name, "version": provider.version} + AirflowConsole().print_as(data=[data], output=args.output) diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_provider_command.py b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_provider_command.py new file mode 100644 index 0000000000000..f22c7de92b15c --- /dev/null +++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_provider_command.py @@ -0,0 +1,72 @@ +# 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 pytest + +from airflowctl.api.client import ClientKind +from airflowctl.api.datamodels.generated import ProviderCollectionResponse, ProviderResponse +from airflowctl.ctl import cli_parser +from airflowctl.ctl.commands import provider_command + + +class TestProviderCommands: + parser = cli_parser.get_parser() + provider = ProviderResponse( + package_name="apache-airflow-providers-amazon", + description="Amazon provider", + version="9.0.0", + documentation_url="https://example.com", + ) + collection = ProviderCollectionResponse(providers=[provider], total_entries=1) + + def _client(self, api_client_maker): + return api_client_maker( + path="/api/v2/providers", + response_json=self.collection.model_dump(mode="json"), + expected_http_status_code=200, + kind=ClientKind.CLI, + ) + + def test_get(self, api_client_maker, capsys): + provider_command.get( + self.parser.parse_args( + ["providers", "get", "apache-airflow-providers-amazon", "--output", "json"] + ), + api_client=self._client(api_client_maker), + ) + assert json.loads(capsys.readouterr().out) == [ + {"package_name": "apache-airflow-providers-amazon", "version": "9.0.0"} + ] + + def test_get_full(self, api_client_maker, capsys): + provider_command.get( + self.parser.parse_args( + ["providers", "get", "apache-airflow-providers-amazon", "--full", "--output", "json"] + ), + api_client=self._client(api_client_maker), + ) + assert json.loads(capsys.readouterr().out) == [self.provider.model_dump(mode="json")] + + def test_get_not_found(self, api_client_maker): + with pytest.raises(SystemExit, match="No such provider installed: does-not-exist"): + provider_command.get( + self.parser.parse_args(["providers", "get", "does-not-exist"]), + api_client=self._client(api_client_maker), + )