Skip to content

feat: add management commands from MODULES and SUBMODULES to help #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
52 changes: 52 additions & 0 deletions src/management_commands/core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from __future__ import annotations

import contextlib
import importlib
import pkgutil
import typing
from contextlib import suppress

from django.apps.registry import apps
Expand All @@ -14,6 +18,9 @@
CommandTypeError,
)

if typing.TYPE_CHECKING:
from collections.abc import Iterator

Check warning on line 22 in src/management_commands/core.py

View check run for this annotation

Codecov / codecov/patch

src/management_commands/core.py#L22

Added line #L22 was not covered by tests


def import_command_class(dotted_path: str) -> type[BaseCommand]:
try:
Expand All @@ -27,6 +34,51 @@
return command_class


def iterate_modules(dotted_path: str) -> Iterator[str]:
for _, name, is_pkg in pkgutil.iter_modules(
importlib.import_module(dotted_path).__path__,
):
if not is_pkg and not name.startswith("_"):
yield name

Check warning on line 42 in src/management_commands/core.py

View check run for this annotation

Codecov / codecov/patch

src/management_commands/core.py#L42

Added line #L42 was not covered by tests


def _discover_commands_in_module(module: str) -> list[str]:
commands: list[str] = []
try:
files_in_dir = list(iterate_modules(module))
except ImportError: # module doesn't exist
return commands

for file in files_in_dir:
with (
contextlib.suppress(CommandImportError),
contextlib.suppress(CommandTypeError),
):
import_command_class(f"{module}.{file}.Command")
commands.append(file)

return commands


def get_commands_from_modules_and_submodules() -> dict[str, list[str]]:
commands = {}
for module in settings.MODULES:
if module_commands := _discover_commands_in_module(module):
commands[module] = module_commands

for app in apps.get_app_configs():
for submodule in settings.SUBMODULES:
if app.name == "django.core" or submodule == "management.commands":
continue

Check warning on line 72 in src/management_commands/core.py

View check run for this annotation

Codecov / codecov/patch

src/management_commands/core.py#L72

Added line #L72 was not covered by tests

if module_commands := _discover_commands_in_module(
f"{app.name}.{submodule}",
):
commands[app.name] = module_commands

return commands


def get_command_paths(name: str, app_label: str | None = None) -> list[str]:
if not app_label:
app_names = [
Expand Down
17 changes: 16 additions & 1 deletion src/management_commands/management.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from __future__ import annotations

import itertools
import sys
from typing import TYPE_CHECKING

from django.core.management import ManagementUtility as BaseManagementUtility
from django.core.management.color import color_style

from .conf import settings
from .core import import_command_class, load_command_class
from .core import (
get_commands_from_modules_and_submodules,
import_command_class,
load_command_class,
)

if TYPE_CHECKING:
from django.core.management.base import BaseCommand
Expand Down Expand Up @@ -43,11 +48,21 @@ def main_help_text(self, commands_only: bool = False) -> str:
if (aliases := settings.ALIASES)
else []
)
modules = get_commands_from_modules_and_submodules()
modules_usage = (
[
style.NOTICE(f"[django-management-commands: {module}]"),
*[f" {file}" for file in modules[module]],
"",
]
for module in modules
)

usage_list = usage.split("\n")
usage_list.append("")
usage_list.extend(commands_usage)
usage_list.extend(aliases_usage)
usage_list.extend(itertools.chain(*modules_usage))

return "\n".join(usage_list)

Expand Down
76 changes: 76 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from management_commands.core import (
get_command_paths,
get_commands_from_modules_and_submodules,
import_command_class,
load_command_class,
)
Expand Down Expand Up @@ -119,6 +120,81 @@ def test_get_command_paths_returns_list_of_all_dotted_paths_to_command_classes_i
]


def test_get_commands_from_modules_and_submodules_returns_dictionary_of_available_commands(
mocker: MockerFixture,
) -> None:
# Configure.
mocker.patch.multiple(
"management_commands.conf.settings",
MODULES=[
"module_a",
"module_b", # no commands
],
SUBMODULES=[
"submodule_a",
"submodule_b", # no commands
],
)

# Arrange.
app_config_a_mock = mocker.Mock()
app_config_a_mock.name = "app_a"
app_config_b_mock = mocker.Mock() # no commands
app_config_b_mock.name = "app_b"

class CommandA:
pass

class CommandB(BaseCommand):
pass

# Mock.
mocker.patch(
"management_commands.core.apps.app_configs",
{
"app_a": app_config_a_mock,
"app_b": app_config_b_mock,
},
)

def import_string_side_effect(dotted_path: str) -> type:
if dotted_path == "module_a.command_a.Command":
return CommandA
if dotted_path == "module_a.command_b.Command":
return CommandB
if dotted_path == "app_a.submodule_a.command_a.Command":
return CommandA
if dotted_path == "app_a.submodule_a.command_b.Command":
return CommandB

raise ImportError

mocker.patch(
"management_commands.core.import_string",
side_effect=import_string_side_effect,
)

def iterate_modules_side_effect(dotted_path: str) -> list[str]:
if dotted_path == "module_a":
return ["command_a", "command_b"]
if dotted_path == "app_a.submodule_a":
return ["command_a", "command_b"]
raise ImportError

mocker.patch(
"management_commands.core.iterate_modules",
side_effect=iterate_modules_side_effect,
)

# Act.
commands = get_commands_from_modules_and_submodules()

# Assert.
assert set(commands.keys()) == {"module_a", "app_a"}
assert commands["module_a"] == ["command_b"]
assert commands["app_a"] == ["command_b"]


def test_get_command_paths_returns_list_of_dotted_paths_to_app_submodules_if_app_label_specified(
mocker: MockerFixture,
) -> None:
Expand Down
65 changes: 65 additions & 0 deletions tests/test_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,71 @@ def test_execute_from_command_line_help_displays_paths_and_aliases(
) in captured.out


def test_execute_from_command_line_help_displays_modules_and_submodules(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
) -> None:
# Mock.
mocker.patch.multiple(
"management_commands.management.settings",
MODULES=["module_a"],
SUBMODULES=["submodule_a"],
)

# Arrange.
app_config_a_mock = mocker.Mock()
app_config_a_mock.name = "app_a"

class CommandB(BaseCommand):
pass

# Mock.
mocker.patch(
"management_commands.core.apps.app_configs",
{"app_a": app_config_a_mock},
)

def import_string_side_effect(dotted_path: str) -> type:
if dotted_path == "module_a.command_b.Command":
return CommandB
if dotted_path == "app_a.submodule_a.command_b.Command":
return CommandB

raise ImportError

mocker.patch(
"management_commands.core.import_string",
side_effect=import_string_side_effect,
)

def iterate_modules_side_effect(dotted_path: str) -> list[str]:
if dotted_path == "module_a":
return ["command_b"]
if dotted_path == "app_a.submodule_a":
return ["command_b"]
raise ImportError

mocker.patch(
"management_commands.core.iterate_modules",
side_effect=iterate_modules_side_effect,
)

# Act.

execute_from_command_line(["manage.py", "--help"])
captured = capsys.readouterr()

# Assert.
assert (
"[django-management-commands: module_a]\n"
" command_b\n"
"\n"
"[django-management-commands: app_a]\n"
" command_b\n"
"\n"
) in captured.out


def test_execute_from_command_line_falls_back_to_django_management_utility_if_command_name_is_not_passed(
mocker: MockerFixture,
) -> None:
Expand Down
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy