From 4ea0f18795b754df929dd688c85043f7126157d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Poul?= Date: Tue, 1 Oct 2024 11:02:24 +0100 Subject: [PATCH 1/2] feat: implement custom call_command --- docs/README.md | 13 ++ src/management_commands/management.py | 30 ++++- tests/test_management.py | 185 +++++++++++++++++++++++++- 3 files changed, 222 insertions(+), 6 deletions(-) diff --git a/docs/README.md b/docs/README.md index 7a52c55..c8f0420 100644 --- a/docs/README.md +++ b/docs/README.md @@ -85,6 +85,18 @@ with: from management_commands.management import execute_from_command_line ``` +If you use `call_command` anywhere in your command, you then need to replace + +```python +from django.core.management import call_command +``` + +with: + +```python +from management_commands.management import call_command +``` + That's it! No further steps are needed. ## Usage @@ -209,6 +221,7 @@ and `myapp.commands.command` for an app installed from the `myapp` module. **Default:** `{}` Allows the definition of shortcuts or aliases for sequences of Django commands. +Note: `call_command` does not support aliases. Example: diff --git a/src/management_commands/management.py b/src/management_commands/management.py index c046422..7b3758a 100644 --- a/src/management_commands/management.py +++ b/src/management_commands/management.py @@ -1,19 +1,17 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING from django.core.management import ManagementUtility as BaseManagementUtility +from django.core.management import call_command as base_call_command +from django.core.management.base import BaseCommand from django.core.management.color import color_style from .conf import settings from .core import import_command_class, load_command_class -if TYPE_CHECKING: - from django.core.management.base import BaseCommand - if sys.version_info >= (3, 12): - from typing import override + from typing import Any, override else: from typing_extensions import override @@ -88,3 +86,25 @@ def execute(self) -> None: def execute_from_command_line(argv: list[str] | None = None) -> None: utility = ManagementUtility(argv) utility.execute() + + +def call_command(command_name: str, *args: Any, **options: Any) -> Any: + if isinstance(command_name, BaseCommand): + return base_call_command(command_name, *args, **options) + + if dotted_path := settings.PATHS.get(command_name): + command_class = import_command_class(dotted_path) + elif command_name in settings.ALIASES: + msg = "Running aliases from call_command is not supported" + raise ValueError(msg) + else: + try: + app_label, name = command_name.rsplit(".", 1) + except ValueError: + app_label, name = None, command_name + + command_class = load_command_class(name, app_label) + + command = command_class() + + return base_call_command(command, *args, **options) diff --git a/tests/test_management.py b/tests/test_management.py index 0ab3849..702fe1a 100644 --- a/tests/test_management.py +++ b/tests/test_management.py @@ -7,7 +7,7 @@ from django.core.management import get_commands from django.core.management.base import BaseCommand -from management_commands.management import execute_from_command_line +from management_commands.management import call_command, execute_from_command_line if TYPE_CHECKING: from pytest_mock import MockerFixture @@ -124,6 +124,41 @@ def import_string_side_effect(dotted_path: str) -> type[BaseCommand]: command_run_from_argv_mock.assert_called_once() +def test_call_command_runs_command_from_path( + mocker: MockerFixture, +) -> None: + # Configure. + mocker.patch( + "management_commands.management.settings.PATHS", + { + "command": "module.Command", + }, + ) + + # Arrange. + class Command(BaseCommand): + pass + + # Mock. + def import_string_side_effect(dotted_path: str) -> type[BaseCommand]: + if dotted_path == "module.Command": + return Command + raise ImportError + + mocker.patch( + "management_commands.core.import_string", + side_effect=import_string_side_effect, + ) + + command_execute = mocker.patch.object(Command, "execute") + + # Act. + call_command("command") + + # Assert. + command_execute.assert_called_once() + + def test_execute_from_command_line_uses_django_management_utility_to_run_command_from_path( mocker: MockerFixture, ) -> None: @@ -211,6 +246,28 @@ def import_string_side_effect(dotted_path: str) -> type[BaseCommand]: ) +def test_call_command_raises_on_alias( + mocker: MockerFixture, +) -> None: + # Configure. + mocker.patch( + "management_commands.management.settings.ALIASES", + { + "alias": [ + "command_a arg_a --option value_a", + "command_b arg_b --option value_b", + ], + }, + ) + + # Assert. + with pytest.raises( + ValueError, + match="Running aliases from call_command is not supported", + ): + call_command("alias") + + def test_execute_from_command_line_prefers_path_command_over_django_core_command( mocker: MockerFixture, django_core_command_name: str, @@ -247,6 +304,42 @@ def import_string_side_effect(dotted_path: str) -> type[BaseCommand]: command_run_from_argv_mock.assert_called_once() +def test_call_command_prefers_path_command_over_django_core_command( + mocker: MockerFixture, + django_core_command_name: str, +) -> None: + # Configure. + mocker.patch( + "management_commands.management.settings.PATHS", + { + django_core_command_name: "module.Command", + }, + ) + + # Arrange. + class Command(BaseCommand): + pass + + # Mock. + def import_string_side_effect(dotted_path: str) -> type[BaseCommand]: + if dotted_path == "module.Command": + return Command + raise ImportError + + mocker.patch( + "management_commands.core.import_string", + side_effect=import_string_side_effect, + ) + + command_execute = mocker.patch.object(Command, "execute") + + # Act. + call_command(django_core_command_name) + + # Assert. + command_execute.assert_called_once() + + def test_execute_from_command_line_prefers_path_command_over_alias( mocker: MockerFixture, ) -> None: @@ -301,6 +394,60 @@ def import_string_side_effect(dotted_path: str) -> type[BaseCommand]: command_a_run_from_argv_mock.assert_called_once() +def test_call_command_prefers_path_command_over_alias( + mocker: MockerFixture, +) -> None: + # Configure. + mocker.patch.multiple( + "management_commands.management.settings", + PATHS={ + "command": "module.CommandA", + }, + ALIASES={ + "command": [ + "command_b", + ], + }, + ) + + # Arrange. + class CommandA(BaseCommand): + pass + + class CommandB(BaseCommand): + pass + + app_config_mock = mocker.Mock() + app_config_mock.name = "app" + + # Mock. + def import_string_side_effect(dotted_path: str) -> type[BaseCommand]: + if dotted_path == "module.CommandA": + return CommandA + if dotted_path == "app.management.commands.command_b.Command": + return CommandB + raise ImportError + + mocker.patch( + "management_commands.core.apps.app_configs", + { + "app": app_config_mock, + }, + ) + mocker.patch( + "management_commands.core.import_string", + side_effect=import_string_side_effect, + ) + + command_a_execute_mock = mocker.patch.object(CommandA, "execute") + + # Act. + call_command("command") + + # Assert. + command_a_execute_mock.assert_called_once() + + def test_execute_from_command_line_prefers_alias_over_django_core_command( mocker: MockerFixture, django_core_command_name: str, @@ -447,6 +594,42 @@ def import_string_side_effect(dotted_path: str) -> type[BaseCommand]: command_run_from_argv_mock.assert_called_once() +def test_call_command_runs_command_passed_with_explicit_app_label( + mocker: MockerFixture, +) -> None: + # Arrange. + class Command(BaseCommand): + pass + + app_config_mock = mocker.Mock() + app_config_mock.name = "app" + + # Mock. + def import_string_side_effect(dotted_path: str) -> type[BaseCommand]: + if dotted_path == "app.management.commands.command.Command": + return Command + raise ImportError + + mocker.patch( + "management_commands.core.apps.app_configs", + { + "app": app_config_mock, + }, + ) + mocker.patch( + "management_commands.core.import_string", + side_effect=import_string_side_effect, + ) + + command_execute_mock = mocker.patch.object(Command, "execute") + + # Act. + call_command("app.command") + + # Assert. + command_execute_mock.assert_called_once() + + def test_execute_from_command_line_runs_command_defined_in_path_when_referenced_by_alias( mocker: MockerFixture, ) -> None: From 2f77c6a434eac1cab6815ac88c66a04c9ee7fd96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Poul?= Date: Wed, 2 Oct 2024 17:49:17 +0100 Subject: [PATCH 2/2] bugfix: fix Any import --- src/management_commands/management.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/management_commands/management.py b/src/management_commands/management.py index 7b3758a..5e88b01 100644 --- a/src/management_commands/management.py +++ b/src/management_commands/management.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from typing import Any from django.core.management import ManagementUtility as BaseManagementUtility from django.core.management import call_command as base_call_command @@ -11,7 +12,7 @@ from .core import import_command_class, load_command_class if sys.version_info >= (3, 12): - from typing import Any, override + from typing import override else: from typing_extensions import override