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..5e88b01 100644 --- a/src/management_commands/management.py +++ b/src/management_commands/management.py @@ -1,17 +1,16 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING +from typing import Any 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 else: @@ -88,3 +87,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: