Skip to content
Open
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
13 changes: 13 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
29 changes: 25 additions & 4 deletions src/management_commands/management.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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)
185 changes: 184 additions & 1 deletion tests/test_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down