Skip to content

Commit a9d1bb4

Browse files
committed
feat!: add sources for release 1.0.0
1 parent 9fa6901 commit a9d1bb4

File tree

7 files changed

+1328
-0
lines changed

7 files changed

+1328
-0
lines changed

src/management_commands/conf.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from keyword import iskeyword
5+
from typing import ClassVar
6+
7+
import appconf
8+
9+
10+
def _is_identifier(s: str) -> bool:
11+
return s.isidentifier() and not iskeyword(s)
12+
13+
14+
def _is_dotted_path(s: str, /, *, min_parts: int = 0) -> bool:
15+
if not (dotted_path_match := re.match(r"^[^\d\W]\w*(\.[^\d\W]\w*)*$", s)):
16+
return False
17+
18+
parts = dotted_path_match.group().split(".")
19+
20+
return len(parts) >= min_parts and all(map(_is_identifier, parts))
21+
22+
23+
class ManagementCommandsConf(appconf.AppConf): # type: ignore[misc]
24+
PATHS: ClassVar[dict[str, str]] = {}
25+
26+
MODULES: ClassVar[list[str]] = []
27+
28+
SUBMODULES: ClassVar[list[str]] = []
29+
30+
ALIASES: ClassVar[dict[str, list[str]]] = {}
31+
32+
class ImproperlyConfigured(Exception):
33+
def __init__(self, msg: str, code: str | None = None) -> None:
34+
super().__init__(msg)
35+
36+
self.code = code
37+
38+
def improperly_configured(
39+
self,
40+
msg: str,
41+
code: str | None = None,
42+
) -> ImproperlyConfigured:
43+
msg = re.sub(
44+
rf"({'|'.join(dir(self))})",
45+
lambda m: f"{self._meta.prefixed_name(m.group(0))}",
46+
msg,
47+
)
48+
49+
return self.__class__.ImproperlyConfigured(msg, code)
50+
51+
def configure_paths(self, setting_value: dict[str, str]) -> dict[str, str]:
52+
for key, value in setting_value.items():
53+
if not _is_identifier(key):
54+
msg = (
55+
f"invalid key {key!r} in PATHS; "
56+
f"keys must be valid Python identifiers"
57+
)
58+
59+
raise self.improperly_configured(msg, "paths.key")
60+
61+
if not _is_dotted_path(value, min_parts=2):
62+
msg = (
63+
f"invalid value for PATHS[{key!r}]; "
64+
f"values must be valid absolute dotted paths with at least 2 parts"
65+
)
66+
67+
raise self.improperly_configured(msg, "paths.value")
68+
69+
return setting_value
70+
71+
def _configure_path_list(
72+
self,
73+
setting_name: str,
74+
setting_value: list[str],
75+
) -> list[str]:
76+
for index, item in enumerate(setting_value):
77+
if not _is_dotted_path(item):
78+
msg = (
79+
f"invalid value for {setting_name.upper()}[{index}]; "
80+
f"items must be valid absolute dotted paths"
81+
)
82+
83+
raise self.improperly_configured(msg, f"{setting_name}.item")
84+
85+
return setting_value
86+
87+
def configure_modules(self, setting_value: list[str]) -> list[str]:
88+
return self._configure_path_list("modules", setting_value)
89+
90+
def configure_submodules(self, setting_value: list[str]) -> list[str]:
91+
configured_value = self._configure_path_list("submodules", setting_value)
92+
93+
if "management.commands" not in configured_value:
94+
configured_value.insert(0, "management.commands")
95+
96+
return configured_value
97+
98+
def configure_aliases(
99+
self,
100+
setting_value: dict[str, list[str]],
101+
) -> dict[str, list[str]]:
102+
for key, value in setting_value.items():
103+
if not _is_identifier(key):
104+
msg = (
105+
f"invalid key {key!r} in ALIASES; "
106+
f"keys must be valid Python identifiers"
107+
)
108+
109+
raise self.improperly_configured(msg, "aliases.key")
110+
111+
for index, item in enumerate(value):
112+
argv = item.split()
113+
114+
try:
115+
command = argv[0]
116+
except IndexError as exc:
117+
msg = (
118+
f"empty item found in ALIASES[{key!r}][{index}]; "
119+
f"items must not be empty"
120+
)
121+
122+
raise self.improperly_configured(msg, "aliases.empty") from exc
123+
124+
if command == key:
125+
msg = (
126+
f"invalid value for ALIASES[{key!r}][{index}]; "
127+
f"items must not refer to the aliases they are defined by"
128+
)
129+
130+
raise self.improperly_configured(msg, "aliases.self_reference")
131+
132+
return setting_value
133+
134+
135+
settings = ManagementCommandsConf()

src/management_commands/core.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
from contextlib import suppress
4+
5+
from django.apps.registry import apps
6+
from django.core.management.base import BaseCommand
7+
from django.utils.module_loading import import_string
8+
9+
from .conf import settings
10+
from .exceptions import (
11+
CommandAppLookupError,
12+
CommandClassLookupError,
13+
CommandImportError,
14+
CommandTypeError,
15+
)
16+
17+
18+
def import_command_class(dotted_path: str) -> type[BaseCommand]:
19+
try:
20+
command_class: type = import_string(dotted_path)
21+
except ImportError as exc:
22+
raise CommandImportError(dotted_path) from exc
23+
24+
if not (isinstance(command_class, type) and issubclass(command_class, BaseCommand)):
25+
raise CommandTypeError(command_class)
26+
27+
return command_class
28+
29+
30+
def get_command_paths(name: str, app_label: str | None = None) -> list[str]:
31+
if not app_label:
32+
app_names = [
33+
"django.core",
34+
*(app_config.name for app_config in reversed(list(apps.get_app_configs()))),
35+
]
36+
37+
modules_paths = [f"{module}.{name}.Command" for module in settings.MODULES]
38+
else:
39+
try:
40+
app_config = apps.get_app_config(app_label)
41+
except LookupError as exc:
42+
raise CommandAppLookupError(app_label) from exc
43+
else:
44+
app_names = [app_config.name]
45+
46+
modules_paths = []
47+
48+
submodules_paths: list[str] = []
49+
for app_name in app_names:
50+
for submodule in settings.SUBMODULES:
51+
if app_name == "django.core" and submodule != "management.commands":
52+
continue
53+
54+
submodules_paths.append(f"{app_name}.{submodule}.{name}.Command")
55+
56+
return modules_paths + submodules_paths
57+
58+
59+
def load_command_class(name: str, app_label: str | None = None) -> type[BaseCommand]:
60+
command_paths = get_command_paths(name, app_label)
61+
62+
for command_path in command_paths:
63+
with suppress(CommandImportError, CommandTypeError):
64+
return import_command_class(command_path)
65+
66+
raise CommandClassLookupError(name, app_label)

src/management_commands/exceptions.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import annotations
2+
3+
from django.core.management.base import BaseCommand
4+
5+
6+
class ManagementCommandsException(Exception):
7+
msg: str
8+
9+
def __init__(self, msg: str | None = None, **kwargs: str) -> None:
10+
super().__init__(
11+
msg
12+
if msg
13+
else (
14+
msg_template.format(**kwargs)
15+
if (msg_template := getattr(type(self), "msg", None)) and kwargs
16+
else ""
17+
),
18+
)
19+
20+
21+
class CommandClassLookupError(ManagementCommandsException):
22+
msg = "command {command_name!r} is not registered from {app_info}"
23+
24+
def __init__(
25+
self,
26+
command_name: str | None = None,
27+
app_name: str | None = None,
28+
) -> None:
29+
super().__init__(
30+
**(
31+
{
32+
"command_name": command_name,
33+
"app_info": (
34+
f"the {app_name!r} app"
35+
if app_name
36+
else "any of the installed apps"
37+
),
38+
}
39+
if command_name
40+
else {}
41+
),
42+
)
43+
44+
45+
class CommandAppLookupError(ManagementCommandsException):
46+
msg = "app {app_name!r} is not installed"
47+
48+
def __init__(self, app_name: str | None = None) -> None:
49+
super().__init__(**({"app_name": app_name} if app_name else {}))
50+
51+
52+
class CommandImportError(ManagementCommandsException, ImportError):
53+
msg = "class {class_name!r} could not be imported from the {module_path!r} module"
54+
55+
def __init__(self, command_class_path: str | None = None) -> None:
56+
super().__init__(
57+
**({"module_path": parts[0], "class_name": parts[1]})
58+
if (command_class_path and (parts := command_class_path.rsplit(".", 1)))
59+
else {},
60+
)
61+
62+
63+
class CommandTypeError(ManagementCommandsException):
64+
msg = "class {class_path!r} is not a subclass of {base_command_class_path!r}"
65+
66+
def __init__(self, class_: type | None = None) -> None:
67+
super().__init__(
68+
**(
69+
{
70+
"class_path": self._class_path(class_),
71+
"base_command_class_path": self._class_path(BaseCommand),
72+
}
73+
if class_
74+
else {}
75+
),
76+
)
77+
78+
@staticmethod
79+
def _class_path(class_: type) -> str:
80+
return f"{class_.__module__}.{class_.__qualname__}"

src/management_commands/management.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from typing import TYPE_CHECKING
5+
6+
from django.core.management import ManagementUtility as BaseManagementUtility
7+
from django.core.management.color import color_style
8+
9+
from .conf import settings
10+
from .core import import_command_class, load_command_class
11+
12+
if TYPE_CHECKING:
13+
from django.core.management.base import BaseCommand
14+
15+
if sys.version_info >= (3, 12):
16+
from typing import override
17+
else:
18+
from typing_extensions import override
19+
20+
21+
class ManagementUtility(BaseManagementUtility):
22+
@override
23+
def main_help_text(self, commands_only: bool = False) -> str:
24+
usage = super().main_help_text(commands_only=commands_only)
25+
26+
style = color_style()
27+
28+
commands_usage = (
29+
[
30+
style.NOTICE("[django-management-commands: paths]"),
31+
*[f" {path}" for path in paths],
32+
"",
33+
]
34+
if (paths := settings.PATHS)
35+
else []
36+
)
37+
aliases_usage = (
38+
[
39+
style.NOTICE("[django-management-commands: aliases]"),
40+
*[f" {alias}" for alias in aliases],
41+
"",
42+
]
43+
if (aliases := settings.ALIASES)
44+
else []
45+
)
46+
47+
usage_list = usage.split("\n")
48+
usage_list.append("")
49+
usage_list.extend(commands_usage)
50+
usage_list.extend(aliases_usage)
51+
52+
return "\n".join(usage_list)
53+
54+
@override
55+
def fetch_command(self, subcommand: str) -> BaseCommand:
56+
if dotted_path := settings.PATHS.get(subcommand):
57+
command_class = import_command_class(dotted_path)
58+
else:
59+
try:
60+
app_label, name = subcommand.rsplit(".", 1)
61+
except ValueError:
62+
app_label, name = None, subcommand
63+
64+
command_class = load_command_class(name, app_label)
65+
66+
return command_class()
67+
68+
@override
69+
def execute(self) -> None:
70+
try:
71+
name = self.argv[1]
72+
except IndexError:
73+
super().execute()
74+
else:
75+
if name in settings.PATHS:
76+
utility = self.__class__([self.prog_name, name, *self.argv[2:]])
77+
super(ManagementUtility, utility).execute()
78+
elif alias_exprs := settings.ALIASES.get(name):
79+
for alias_expr in alias_exprs:
80+
argv = alias_expr.split()
81+
82+
utility = ManagementUtility([self.prog_name, *argv])
83+
utility.execute()
84+
else:
85+
super().execute()
86+
87+
88+
def execute_from_command_line(argv: list[str] | None = None) -> None:
89+
utility = ManagementUtility(argv)
90+
utility.execute()

0 commit comments

Comments
 (0)