Django커맨드들은 어떻게 만들어지고 어떻게 동작하는가? - [0]
2024년8월4일
Django를 사용하다보면 startapp, makemigrations, runserver 등등 다양한 커맨드들을 접할때가 있다.
하지만 매번 커맨드들을 사용하면서 이러한 커맨드들은 어떻게 만들어졌을까? 에 대한 궁금증을 가졌는데
이번 포스트를 통해 Django의 커맨드들은 어떠한 구조로 만들어졌는지 한 번 알아보자.
Django커맨드
겉모습
보통 Django 커맨드를 사용할때
python3 manage.py xxx
식으로 입력한다.위 코드는 아주 기본적으로 보통 파이썬 파일을 실행할때 python3 xxxx.py를 입력하면 된다.
즉 python3 manage.py는 "manage.py를 실행한다"와 같다.
그리고 xxx는 해당 파일에 전달할 인수들로 python에서 sys.args로 접근할 수 있다.
일단은 어떠한 커맨드를 입력하던 시작점은 manage.py를 실행하는 것이라는 걸 알았기 때문에 manage.py에는 어떤 코드가 있는지 확인해 보자.
python
COPY
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test-jwt.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
manage.py는 굉장히 간단하다.
가장 먼저 맨아래에 있는 조건문
if __name__ == '__main__'
은 main함수의 호출은 manage.py가 다른 모듈에 의해 실행되지 않았을때 동작하도록 한다.(직접 해당 파일을 실행했을때만 동작)다음으로 main함수를 보면
os.environ.setdefault
로 환경변수를 설정하는 부분이있는데 위 코드에서는 DJANGO_SETTINGS_MODULE이라는 환경변수값을 설정한다.DJANGO_SETTINGS_MODULE??
마지막으로
execute_from_command_line(sys.argv)
함수를 실행하는데 인자로 sys.argv
전달한다.sys.argv
는 Python을 커맨드라인으로 실행했을때 Python을 제외한 나머지 값들이 인자로 전달된다.예시로 python3 manage.py migrate을 실행했다면 "manage.py", "migrate"이 배열에 담겨
sys.argv
의 값이 된다.execute_from_command_line
execute_from_command_line함수도 굉장히 간단하다.
python
COPY
def execute_from_command_line(argv=None):
"""Run a ManagementUtility."""
utility = ManagementUtility(argv)
utility.execute()
ManagementUtility클래스 객체를 생성하고 해당 객체의 execute메서드를 호출한다는것 밖에 없다.
일단 먼저 ManagementUtility클래스 객체를 생성하기 때문에 가장 먼저 초기화 메서드에는 어떠한 로직이 담겨있는지 확인해야한다.
python
COPY
class ManagementUtility:
"""
Encapsulate the logic of the django-admin and manage.py utilities.
"""
def __init__(self, argv=None):
self.argv = argv or sys.argv[:]
self.prog_name = os.path.basename(self.argv[0])
if self.prog_name == "__main__.py":
self.prog_name = "python -m django"
self.settings_exception = None
초기화 메서드
__init__
에서 argv, prog_name, settings_exception 어트리뷰트를 설정하는데argv는 ManagementUtility클래스 객체를 생성할때 인자로 전달한 argv -->
sys.argv
가 되며 prog_name은os.path.basename
함수를 사용하는데 해당 함수는 경로형태의 문자열에서 상위 경로를 제외한 마지막 파일명, 폴더명을 반환한다.즉 커맨드를 manage.py와 다른 레벨에서 실행했을때를 대비한 코드로 python3 manage.py xxx가 아닌 python3 ./folder/manage.py xxx로 실행했을때
os.path.basename
은 self.argv[0]
인 "./folder/manage.py"를 manage.py로 반환한다.ManagementUtility객체를 생성하고 해당 객체의 어트리뷰트로 어떤 값이 있을지 예상할 수 있다.
이제 execute메서드가 호출될 차례이다.
ManagementUtility - execute()
python
COPY
def execute(self):
"""
Given the command-line arguments, figure out which subcommand is being
run, create a parser appropriate to that command, and run it.
"""
try:
subcommand = self.argv[1]
except IndexError:
subcommand = "help" # Display help if no arguments were given.
# Preprocess options to extract --settings and --pythonpath.
# These options could affect the commands that are available, so they
# must be processed early.
parser = CommandParser(
prog=self.prog_name,
usage="%(prog)s subcommand [options] [args]",
add_help=False,
allow_abbrev=False,
)
parser.add_argument("--settings")
parser.add_argument("--pythonpath")
예시로 python3 manage.py makemigrations --dry-run이라고 명령어를 실행했을때 "makemigrations"에 해당하며 예외처리를 보면 해당 인덱스가 존재하지 않는 상황에는 subcommand에 "help"를 할당한다.
이와 같은 상황은 python3 manage.py만 입력했을때에 해당하며
self.argv
값이 ['manage.py']밖에 없어 [1]인덱스가 존재하지 않는 상황에 대한 예외처리이다.다음으로 CommandParser클래스 객체를 생성한다.
python
COPY
class CommandParser(ArgumentParser):
"""
Customized ArgumentParser class to improve some error messages and prevent
SystemExit in several occasions, as SystemExit is unacceptable when a
command is called programmatically.
"""
def __init__(
self, *, missing_args_message=None, called_from_command_line=None, **kwargs
):
self.missing_args_message = missing_args_message
self.called_from_command_line = called_from_command_line
super().__init__(**kwargs)
def parse_args(self, args=None, namespace=None):
# Catch missing argument for a better error message
if self.missing_args_message and not (
args or any(not arg.startswith("-") for arg in args)
):
self.error(self.missing_args_message)
return super().parse_args(args, namespace)
def error(self, message):
if self.called_from_command_line:
super().error(message)
else:
raise CommandError("Error: %s" % message)
def add_subparsers(self, **kwargs):
parser_class = kwargs.get("parser_class", type(self))
if issubclass(parser_class, CommandParser):
kwargs["parser_class"] = partial(
parser_class,
called_from_command_line=self.called_from_command_line,
)
return super().add_subparsers(**kwargs)
ArgumentParser는 파이썬 명령어 파싱 처리를 하는 클래스이다.
이번 포스트가 명령어와 관련이 있는만큼 argparse라이브러리에 대한 이해가 조금 필요한데
일단 먼저 execute메서드에서 CommandParser클래스 객체를 생성할때 전달해준 인자부터 확인하면 전부 다 CommandParser
__init__
에서 kwargs값으로 전달될 것이므로 super().__init__(**kwargs)
--> ArgumentParser클래스 객체 생성과 관련된 인자라는걸 알 수 있다.여기서 add_help가 False라는 점이 조금 예외일것이다.
참고로 add_help는 ArgumentParser클래스에서 -h, --help옵션을 기본적으로 추가해준다.
-h, --help
그렇다면 python3 manage.py -h or python3 manage.py --help는 동작하지 않는걸까?
예상과는 다르게 --help옵션은 잘 동작하는걸 확인할 수 있다.(-h도 잘 동작한다.)
ArgumentParser클래스에서 옵션을 추가하려면 add_argument라는 메서드를 호출해야한다.
ArgumentParser클래스에서 옵션을 추가하려면 add_argument라는 메서드를 호출해야한다.
CommandParser클래스 객체를 생성할때 add_help를 False로 전달했기 때문에 아래와 같이 ArgumentParser의
__init__
메서드 일부인 if self.add_help
조건문을 통과하지 못해 -h, --help옵션을 추가하는 블럭을 실행하지 않는다.python
COPY
class ArgumentParser(_AttributeHolder, _ActionsContainer):
...
default_prefix = '-' if '-' in prefix_chars else prefix_chars[0]
if self.add_help:
self.add_argument(
default_prefix+'h', default_prefix*2+'help',
action='help', default=SUPPRESS,
help=_('show this help message and exit'))
하지만 어떻게 --help, -h 옵션이 동작하는걸까?
그리고 또 한 가지 예외인점이 있다면 ArgumentParser는 --help옵션으로 출력되는 형태가 따로 정해져있다.
위 예제를 보면 prefix('-')가 없는 positional arguments인 'bar'는 positional arguments하위에 출력되고 add_argument의 인자로 전달된 help또한 같은 라인에 출력된다.
반대로 prefix가 있는 '--foo'는 options하위에 출력되고 'bar'와 같이 인자로 전달된 help또한 같은 라인에 출력된다.
위와 같이 execute메서드 CommonParser클래스 객체는 -h, --help 옵션을 사용할시 ArgumentParser의 print_help메서드를 사용하지 않는다는걸 알 수 있다.
위와 같이 execute메서드 CommonParser클래스 객체는 -h, --help 옵션을 사용할시 ArgumentParser의 print_help메서드를 사용하지 않는다는걸 알 수 있다.
왜 이렇게 동작하는걸까??
이러한 이유에 대해서는 차차 이후 코드들을 보면 자연스럽게 이해될것이다.
python
COPY
class ArgumentParser(_AttributeHolder, _ActionsContainer):
...
def execute(self):
...
parser = CommandParser(
prog=self.prog_name,
usage="%(prog)s subcommand [options] [args]",
add_help=False,
allow_abbrev=False,
)
parser.add_argument("--settings")
parser.add_argument("--pythonpath")
parser.add_argument("args", nargs="*")
...
CommandParser객체 생성 이후 해당 객체에 add_argument메서드를 사용하여 옵션 --settings, --pythonpath를 추가하고 위치인자 args를 추가한다.
참고로 위치인자 args를 추가할때 nargs인자로 *을 전달함으로써 여러개의 인자를 전달할 수 있게했다.
예시로 python3 manage.py hello world 라고 실행하면 args위치인자가 ["hello", "world"]를 가지게 된다.
python
COPY
class ArgumentParser(_AttributeHolder, _ActionsContainer):
...
def execute(self):
...
parser = CommandParser(
prog=self.prog_name,
usage="%(prog)s subcommand [options] [args]",
add_help=False,
allow_abbrev=False,
)
parser.add_argument("--settings")
parser.add_argument("--pythonpath")
parser.add_argument("args", nargs="*") # catch-all
try:
options, args = parser.parse_known_args(self.argv[2:])
handle_default_options(options)
except CommandError:
pass # Ignore any option errors at this point.
try:
settings.INSTALLED_APPS
except ImproperlyConfigured as exc:
self.settings_exception = exc
except ImportError as exc:
self.settings_exception = exc
if settings.configured:
다음은
paser.parse_known_args(self.argv[2:])
메서드를 호출하는데 parse_known_args메서드를 간단하게 설명하자면 메서드명 그대로 파서가 아는 인자들과 모르는 인자들을 분리해서 반환하는 메서드이다.파서클래스가 아는 인자들은 바로 위에서 추가한 "--settings", "--pythonpath", "args"이다.
예시로 필자가 python3 manage.py makemigrations authentication --dry-run --settings=test-jwt.settings라고 명령어를 실행하면
인자로
self.argv[2:]
를 전달하기 때문에 python3 manage.py makemigrations이후 인자들인 ["authentication" "--dry-run" "--settings=test-jwt.settings"]가 전달되게 된다.여기서 필자는 parse클래스가 어떤 인자들을 가지고 있는지 알기 때문에 메서드 호출 결과를 예상할 수 있다.
실제로 테스트해보면
Namespace클래스 객체로 아는 인자들이 담겨있고 모르는 인자는 배열에 담겨져 반환된다는걸 확인할 수 있다.
위 parse_known_args가 별거 아닌거 같지만 필자가 생각하기에 Django와 같은 커맨드를 만들때 parse_known_args메서드와 위에서 추가한 여러 위치인자를 받는 args가 핵심이라고 생각한다.
결과적으로 보면 makemigrations또한 또 다른 파서인데 이렇게 다른 파서를 연결하고 다른 파서와 관련된 인자또한 분류해서 전달 할 수 있는 구조를 만들게 한다.
즉 execute메서드 CommonParser클래스 객체는 이후 다른 파서를 연결하는 징검다리와 같은 역할을 한다고 볼 수 있다.
parse_known_args메서드가 호출 되고 난 뒤 handle_default_options함수가 호출된다.
python
COPY
def handle_default_options(options):
"""
Include any default options that all commands should accept here
so that ManagementUtility can handle them before searching for
user commands.
"""
if options.settings:
os.environ["DJANGO_SETTINGS_MODULE"] = options.settings
if options.pythonpath:
sys.path.insert(0, options.pythonpath)
handle_default_options함수는 --settings옵션 값으로 DJANGO_SETTINGS_MODULE환경변수를 설정하고 --pythonpath옵션 값으로
sys.path.insert
를 통해 파이썬 모듈에 접근할 수 있는 경로를 추가한다.DJANGO_SETTINGS_MODULE은 이전에도 설명했듯이 사용할 settings.py의 경로를 의미한다.
python
COPY
class ArgumentParser(_AttributeHolder, _ActionsContainer):
...
def execute(self):
...
if subcommand == "help":
if "--commands" in args:
sys.stdout.write(self.main_help_text(commands_only=True) + "\n")
elif not options.args:
sys.stdout.write(self.main_help_text() + "\n")
else:
self.fetch_command(options.args[0]).print_help(
self.prog_name, options.args[0]
)
# Special-cases: We want 'django-admin --version' and
# 'django-admin --help' to work, for backwards compatibility.
elif subcommand == "version" or self.argv[1:] == ["--version"]:
sys.stdout.write(django.get_version() + "\n")
elif self.argv[1:] in (["--help"], ["-h"]):
sys.stdout.write(self.main_help_text() + "\n")
else:
self.fetch_command(subcommand).run_from_argv(self.argv)
위 코드는 excute메서드의 마지막 부분이다.
필자가 이전에 CommonParser클래스 객체를 생성할때 add_help인자로 False를 전달했지만
예상과 달리 --help, -h 옵션이 동작하고 ArgumentParser클래스의 print_help메서드와는 다른 help메시지 출력에 대해서 설명했던걸 기억하는가?
excute메서드 마지막 부분을 보면 해당 궁금증에 대한 정답을 알 수 있다.
CommonParser의 help 구현
execute메서드의 마지막 부분을 보면 가장 먼저 subcommand가 "help"인가에 대한 조건문으로 시작한다.
이전에 봤듯이 subcommand가 "help"일 수 있는 경우는 두 가지가 존재하는데
먼저 execute메서드 맨 처음 부분에서 봤듯이
self.argv[1]
인덱스가 존재하지 않는 상황즉 python3 manage.py와 같이 커맨드를 실행했을때와 python3 manage.py help로 직접 help를 실행했을때
이렇게 두 가지 상황이 가능하다.
일단 먼저 위와 같이 커맨드를 전달했을때 로직에 대해 살펴보겠다.
if Subcommend == "help"
python
COPY
class ArgumentParser(_AttributeHolder, _ActionsContainer):
...
def execute(self):
...
if subcommand == "help":
if "--commands" in args:
sys.stdout.write(self.main_help_text(commands_only=True) + "\n")
elif not options.args:
sys.stdout.write(self.main_help_text() + "\n")
else:
self.fetch_command(options.args[0]).print_help(
self.prog_name, options.args[0]
)
...
subcommand가 "help"일 때 세가지 로직이 존재한다.
가장 먼저 --commands가 args에 존재할때
여기서 args는 이전에 parse_known_arsg메서드를 호출했을때 CommonParser가 가지지 않은 인자들을 담고 있다.
예시로 python3 manage.py help --commands와 같이 전달했을때가 첫 번째 조건문에 해당한다.
두 번째는
options.args
가 비어있을 때options.args
는 CommonParser에서 위치 인자 args에 해당하며 아무런 position argument가 없는 상황이다.예시로 python3 manage.py와 같은 상황이 두 번째 조건문에 해당한다.
마지막은 위 두 상황을 제외한 모든 상황을 의미하며 한 가지 예시를 들자면 subcommand가 help여야 하기 때문에
python3 manage.py help makemigrations와 같은 상황이 마지막 조건문에 해당한다.
main_help_text()
첫 번째와 두 번째 조건문에서 실행하는 메서드는 같다.
main_help_text메서드를 실행하는데 해당 메서드는 이전에 봤던 help를 입력했을때 출력됐던 메시지를 출력하는 역할을 한다.
일단 main_help_text메서드에 대해 설명하기전에 한 가지 알고가면 좋은점이 있다.
help메시지를 보면 빨간색으로 표시된 문자들이 있는데
해당 문자들을 보면 django를 제외하고는 전부 INSTALLED_APPS에 있는 값이라는 걸 알 수 있다.
여기서 참고로 authentication은 필자가 따로 생성한 앱이기 때문에 독자분들의 manage.py에는 없을것이고
독자분들의 manage.py는 아마 빨간색이 아닌 노란색으로 표시되어 있을것이다.
python
COPY
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'authentication',
]
INSTALLED_APPS에서 auth, authentication, contenttypes, sessions, staticfiles가 manage.py에 빨간 문자로 존재한다.
여기서 한 가지 추측해볼 수 있는것은 "Django 커맨드가 앱과 관련이 있는것인가?" 이다.
실제로 확인해보자.
python
COPY
class ArgumentParser(_AttributeHolder, _ActionsContainer):
def main_help_text(self, commands_only=False):
"""Return the script's main help text, as a string."""
if commands_only:
usage = sorted(get_commands())
else:
usage = [
"",
"Type '%s help <subcommand>' for help on a specific subcommand."
% self.prog_name,
"",
"Available subcommands:",
]
commands_dict = defaultdict(lambda: [])
for name, app in get_commands().items():
if app == "django.core":
app = "django"
else:
app = app.rpartition(".")[-1]
commands_dict[app].append(name)
style = color_style()
for app in sorted(commands_dict):
usage.append("")
usage.append(style.NOTICE("[%s]" % app))
for name in sorted(commands_dict[app]):
usage.append(" %s" % name)
# Output an extra note if settings are not properly configured
if self.settings_exception is not None:
usage.append(
style.NOTICE(
"Note that only Django core commands are listed "
"as settings are not properly configured (error: %s)."
% self.settings_exception
)
)
return "\n".join(usage)
main_help_text메서드의 시작점은
if commands_only
로 인자로 전달된 commands_only값이 True일 때는 sorted(get_commands())
만 실행하고 해당 값을 반환한다.위에서 확인했듯이 commands_only가 True일때가 어떤 상황인지 알고있다.
바로 subcommand가 "help"일때 첫 번째 조건에 해당되며
python3 manage.py help --commands명령어를 실행했을때 이다.
위와 같이 commands_only값이 True일때 이전 help메시지와 달리 빨간색 글씨를 제외한 나머지 값들을 전부 출력한다.
정말 의미 그대로 커맨드만 출력하는 것이다.
이번에는 else문을 통해 --commands옵션을 전달했을때가 아닌 가장 기본적인 help메시지를 확인해보자.
python
COPY
class ArgumentParser(_AttributeHolder, _ActionsContainer):
def main_help_text(self, commands_only=False):
...
else:
usage = [
"",
"Type '%s help <subcommand>' for help on a specific subcommand."
% self.prog_name,
"",
"Available subcommands:",
]
commands_dict = defaultdict(lambda: [])
for name, app in get_commands().items():
if app == "django.core":
app = "django"
else:
app = app.rpartition(".")[-1]
commands_dict[app].append(name)
style = color_style()
for app in sorted(commands_dict):
usage.append("")
usage.append(style.NOTICE("[%s]" % app))
for name in sorted(commands_dict[app]):
usage.append(" %s" % name)
# Output an extra note if settings are not properly configured
if self.settings_exception is not None:
usage.append(
style.NOTICE(
"Note that only Django core commands are listed "
"as settings are not properly configured (error: %s)."
% self.settings_exception
)
)
return "\n".join(usage)
가장 먼저 usage값을 보면 일반적인 help메시지의 최상단 메시지와 같다는걸 확인할 수 있다.
나머지 코드들을 보면
for name, app in get_commands().item()
get_commands메서드를 호출하는 부분이 있는데get_commands메서드는 의미 그대로 커맨드들을 가져오는 메서드이다.
하지만 어떤 커맨드를 가져오는건가??
분명 커맨드를 가져오는 조건이 존재할것이고 get_commands는 해당 조건과 일치하는 커맨드들을 가져올 것이다.
커맨드가 되기 위한 조건
python
COPY
@functools.lru_cache(maxsize=None)
def get_commands():
"""
Return a dictionary mapping command names to their callback applications.
Look for a management.commands package in django.core, and in each
installed application -- if a commands package exists, register all
commands in that package.
Core commands are always included. If a settings module has been
specified, also include user-defined commands.
The dictionary is in the format {command_name: app_name}. Key-value
pairs from this dictionary can then be used in calls to
load_command_class(app_name, command_name)
The dictionary is cached on the first call and reused on subsequent
calls.
"""
commands = {name: "django.core" for name in find_commands(__path__[0])}
if not settings.configured:
return commands
for app_config in reversed(apps.get_app_configs()):
path = os.path.join(app_config.path, "management")
commands.update({name: app_config.name for name in find_commands(path)})
return commands
get_commands메서드의 주석 두번째 블럭을 보면
django.core
에 management.commands
패키지가 있는 커맨드들과 커맨드 패키지가 설치된 각 어플리케이션에 있는 커맨드들을 모두 등록한다고 되어있다.아직 무슨 말인지 잘 이해되지 않을 수 있다 소스코드를 통해 실제로 확인해보자.
가장 먼저 딕셔너리 컴프리헨션문을 보면 find_commands함수의 반환값을 반복한 값은 키가 되고 값으로 전부
django.core
를 가지게 된다.여기서 인자로 전달된 __path__ 매직메서드는 현재 패키지가 임포트되었을때 자동으로 설정하는 속성으로 해당 패키지 경로를 나타낸다.(manage.py로부터 호출된다.)
즉 인자로 전달되는 값은 ".../django/core/management"가 된다.
python
COPY
def find_commands(management_dir):
"""
Given a path to a management directory, return a list of all the command
names that are available.
"""
command_dir = os.path.join(management_dir, "commands")
return [
name
for _, name, is_pkg in pkgutil.iter_modules([command_dir])
if not is_pkg and not name.startswith("_")
]
find_commands메서드는 인자로 전달된 ".../django/core/management"에 commands 경로를 추가하여
command_dir은 ".../django/core/management/commands"가 되며 pkgutil.iter_modules메서드를 사용하여 반환된 튜플에서 실질적으로 name만 반환하게 된다.
여기서
pkgutil.iter_modules
에 대해 잠깐 설명하자면 해당 메서드는 인자로 전달된 경로의 바로 하위에 존재하는 폴더, 파일에 대한 정보를 전달한다. 결국 ".../django/core/management/commands"하위에 존재하는 폴더나 파일중에서
if not is_pkg
--> 패키지가 아니면서(폴더 제외) 파일 이름이 "_"로 시작되지 않는 파일의 이름만 반환하게 된다.실제 해당 경로의 Django폴더에 가보면 여러 익숙한 파일 이름들이 보일것이다.
python
COPY
@functools.lru_cache(maxsize=None)
def get_commands():
commands = {name: "django.core" for name in find_commands(__path__[0])}
if not settings.configured:
return commands
for app_config in reversed(apps.get_app_configs()):
path = os.path.join(app_config.path, "management")
commands.update({name: app_config.name for name in find_commands(path)})
return commands
다시 get_commands메서드로 돌아와서 이제 commands변수에 어떤값이 담겨있을지 추측할 수 있다.
딕셔너리 형태로
django.core
를 키로 가지고 값은 Django폴더 하위에 존재하는 모든 파일들의 이름(check, dbshell, makemigrations, runserver ...)이 담겨있을 것이다.실제로 반복문을 실행하기 전에 commands값을 출력해보면
python
COPY
@functools.lru_cache(maxsize=None)
def get_commands():
commands = {name: "django.core" for name in find_commands(__path__[0])}
for key, value in commands.items():
print(f"{key}: {value}")
...
".../django/core/management/commands"하위에 존재하는 모든 파일들의 이름을 확인할 수 있다.
그리고 해당 이름들은 많이 익숙하다.
다음은 앱과 관련된 커맨드들을 등록할 때다.
python
COPY
@functools.lru_cache(maxsize=None)
def get_commands():
...
for app_config in reversed(apps.get_app_configs()):
path = os.path.join(app_config.path, "management")
commands.update({name: app_config.name for name in find_commands(path)})
return commands
get_app_configs메서드는 앱이 등록되었는지 확인하고
app_configs.values
를 반환한다.python
COPY
class Apps:
...
def get_app_configs(self):
"""Import applications and return an iterable of app configs."""
self.check_apps_ready()
return self.app_configs.values()
python
COPY
class Apps:
def populate(self, installed_apps=None):
...
for entry in installed_apps:
if isinstance(entry, AppConfig):
app_config = entry
else:
app_config = AppConfig.create(entry)
if app_config.label in self.app_configs:
raise ImproperlyConfigured(
"Application labels aren't unique, "
"duplicates: %s" % app_config.label
)
self.app_configs[app_config.label] = app_config
...
populate메서드를 보면 installed_apps를 반복하는데 installed_apps는 settings.py의 INSTALLED_APPS값이다.
반복된 값은 AppConfig클래스의 객체인지 확인하는데 알다시피 INSTALLED_APPS의 값들은 문자열이다.
그렇기 때문에 populate를 최초 실행시 무조건 else문을 실행하게 된다.
else문에서는 Appconfig.create메서드를 호출한다.
간단하게 설명하면 "python3 manage.py startapp"을 통해 앱을 만들어본 독자들은 알다시피 각 앱마다 apps.py가 있고 apps.py에 "AppnameConfig"라는 클래스가 있다.
Appconfig.create
메서드는 각 앱 모듈을 동적으로 임포트하고 "AppnameConfig"클래스 객체를 생성하여 반환한다.python
COPY
@functools.lru_cache(maxsize=None)
def get_commands():
...
for app_config in reversed(apps.get_app_configs()):
path = os.path.join(app_config.path, "management")
commands.update({name: app_config.name for name in find_commands(path)})
return commands
이제 최종적인 commands값에 대해 추측 할 수 있다.
반복된 app_config에는 각 앱의 "AppnameConfig"클래스 객체이고 해당 객체의 path 어트리뷰트는 각 앱의 경로가 담겨있다.
os.path.join
메서드를 통해 해당 경로에 management를 추가하고 find_commands로 각 경로 하위에 존재하는 파일들("_"로 시작하지 않는)의 이름을 키값 그리고 앱이름을 값으로 하여 commands에 추가한다.결론적으로 간단하게 setting.py INSTALLED_APPS에 존재하는 앱들의 경로에 "/management/commands"하위에 존재하는 파일들의 이름을 가져오는 것과 같다.
예시로 INSTALLED_APPS에 기본으로 있는 "django.contrib.staticfiles"을 예로 들어보겠다.
위 과정을 진행하게 되면 'django.contrib.staticfiles.management.commands"하위에 존재하는 파일들을 commands에 추가하는 건데 실제로 해당 폴더에 가보면 하위에 collectstatic, findstatic, runserver가 있는걸 확인할 수 있다.
그리고 manage.py의 help문을 보면 빨간글씨로 된 staticfiles하위에 익숙한 값들이 보인다.
이제 Django에서 커맨드가 될 수 있는 1차적인 조건에 대해 어느정도 알 수 있다.
앱을 생성하고 해당 앱의 apps.py에 AppConfig를 상속받은 클래스가 존재하면서 settings.py INSTALLED_APPS에 해당 앱 이름을 추가하고 앱 하위에 "management/commands"폴더를 생성한 뒤 commands폴더 하위에 "_"로 시작하지 않는 파일을 만들면 Django에서 커맨드가 될 수 있는 1차적인 조건에 부합한다고 볼 수 있으며
manage.py help메시지에 해당 파일이름이 등록될 것이다.
실제로 필자의 Django프로젝트에 "python3 manage.py startapp xxx" 커맨드를 실행하지 않고 폴더 test를 생성한 뒤 apps.py에 AppConfig를 상속받는 클래스를 만들고 "management/commands"폴더를 생성한 뒤 commands폴더 하위에 아무런 내용이 없는 test1.py, test2.py, test3.py를 만들어보겠다.
(당연히 INSTALLED_APPS에 test도 추가했다.)
위 사진을 보면 필자의 Django프로젝트 트리에 test폴더가 있는걸 확인할 수 있다.
그리고 python3 manage.py help를 통해 help메시지를 출력해보면
마지막에 test앱 test1, test2, test3 커맨드가 있는걸 확인할 수 있다.
하지만 아직 test1, test2, test3은 아무런 코드가 없는 빈 파일이므로 당연히 커맨드를 실행할 수 없다.
커맨드를 실행하기에는 다른 조건들이 더 필요하고 아직은 Django에서 해당 앱에 커맨드가 있구나 정도로 인식하는 수준이다.
마지막으로 CommandParser의 help메시지를 출력하는 main_help_text메서드를 다시 봐보자.
python
COPY
class ManagementUtility:
...
def main_help_text(self, commands_only=False):
"""Return the script's main help text, as a string."""
if commands_only:
usage = sorted(get_commands())
else:
usage = [
"",
"Type '%s help <subcommand>' for help on a specific subcommand."
% self.prog_name,
"",
"Available subcommands:",
]
commands_dict = defaultdict(lambda: [])
for name, app in get_commands().items():
if app == "django.core":
app = "django"
else:
app = app.rpartition(".")[-1]
commands_dict[app].append(name)
style = color_style()
for app in sorted(commands_dict):
usage.append("")
usage.append(style.NOTICE("[%s]" % app))
for name in sorted(commands_dict[app]):
usage.append(" %s" % name)
# Output an extra note if settings are not properly configured
if self.settings_exception is not None:
usage.append(
style.NOTICE(
"Note that only Django core commands are listed "
"as settings are not properly configured (error: %s)."
% self.settings_exception
)
)
return "\n".join(usage)
이제 get_commands()의 반환값을 알 수 있다.
get_commands에는
django.core
와 INSTALLED_APPS에 등록되어있는 앱들의 커맨드들이 딕셔너리에 {"커맨드 이름": "앱 이름"}형태로 담겨있다.get_commands함수의 반환값은 반복문을 통해 commands_dict에 배열에 앱별로 커맨드들이 담긴 형태로 저장된다.
그리고 color_style함수를 통해
usage.append(style.NOTICE("[%s]" % app))
으로 앱이름들의 색상을 바꾸고 usage에 추가한다.필자의 manage.py help텍스트가 독자들과 달리 앱이름이 빨간색으로 되어있는것도 필자가 color_style과 관련된 소스코드에 테스트로 커스터마이징을 해봤기 때문이다.
(해당 과정에 대해서도 설명하고 싶지만 너무 길어지기 때문에 생략하겠다.)
마지막으로 실질적인 help텍스트인 usage변수에 commands_dict을 정렬한 뒤 반복문으로 각 앱에 맞는 커맨드들을 약간의 들여쓰기를 주고 usage에 개행을 추가하여 반환한다.
이렇게 반환한 usage가 결국 python3 manage.py help나 python3 manage.py로 커맨드를 실행할시 출력되는 help메시지가 되는것이다.
python
COPY
class ManagementUtility:
...
def execute(self):
...
if subcommand == "help":
if "--commands" in args:
sys.stdout.write(self.main_help_text(commands_only=True) + "\n")
elif not options.args:
sys.stdout.write(self.main_help_text() + "\n")
else:
self.fetch_command(options.args[0]).print_help(
self.prog_name, options.args[0]
)
...
이제는 커맨드가 될 수 있는 조건에 대해 알았으니 해당 커맨드들이 어떠한 로직으로 구성되어 있는지 알아야하는 단계이다.
해당 단계는 fetch_command라는 메서드를 통해 호출되는데 이와 관련된 설명은 다음 포스트에서 이어서 작성하도록 하겠다.