Django커맨드들은 어떻게 만들어지고 어떻게 동작하는가? - [0]

antoliny0919
202484
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??
Django에서 DJANGO_SETTINGS_MODULE환경변수는 Django에서 사용할 settings.py를 의미한다.위와 같이 기본적으로 projectname.settings --> django-admin startproject xxx 했을때 xxx.settings가 기본값이 된다.
no-img
마지막으로 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.basenameself.argv[0]인 "./folder/manage.py"를 manage.py로 반환한다.
no-img
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")
execute메서드는 가장먼저 subcommand변수에 값을 할당하는데 subcommand는 self.argv[1] --> manage.py 바로 옆 인자가 된다.
예시로 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)
여기서 CommandParser클래스는 파이썬 표준 라이브러리 argparse의 ArgumentParser클래스를 상속한 클래스로
ArgumentParser는 파이썬 명령어 파싱 처리를 하는 클래스이다.
이번 포스트가 명령어와 관련이 있는만큼 argparse라이브러리에 대한 이해가 조금 필요한데
argparse자습서argparse공식문서를 참고해도 좋고 필자가 앞으로 이해가 필요한 부분에 대해서만 조금씩 설명을 첨부하도록 하겠다.
일단 먼저 execute메서드에서 CommandParser클래스 객체를 생성할때 전달해준 인자부터 확인하면 전부 다 CommandParser __init__에서 kwargs값으로 전달될 것이므로 super().__init__(**kwargs) --> ArgumentParser클래스 객체 생성과 관련된 인자라는걸 알 수 있다.
no-img
여기서 add_help가 False라는 점이 조금 예외일것이다.
참고로 add_help는 ArgumentParser클래스에서 -h, --help옵션을 기본적으로 추가해준다.
-h, --help
-h, --help 옵션은 사용하는 커맨드의 도움 --> 가이드라인을 출력할때 사용하는 옵션이다.
그렇다면 python3 manage.py -h or python3 manage.py --help는 동작하지 않는걸까?
no-img
예상과는 다르게 --help옵션은 잘 동작하는걸 확인할 수 있다.(-h도 잘 동작한다.)
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옵션으로 출력되는 형태가 따로 정해져있다.
no-img
위 예제를 보면 prefix('-')가 없는 positional arguments인 'bar'는 positional arguments하위에 출력되고 add_argument의 인자로 전달된 help또한 같은 라인에 출력된다.
반대로 prefix가 있는 '--foo'는 options하위에 출력되고 'bar'와 같이 인자로 전달된 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클래스가 어떤 인자들을 가지고 있는지 알기 때문에 메서드 호출 결과를 예상할 수 있다.
실제로 테스트해보면
no-img
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메서드에 대해 설명하기전에 한 가지 알고가면 좋은점이 있다.
no-img
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명령어를 실행했을때 이다.
no-img
위와 같이 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메시지의 최상단 메시지와 같다는걸 확인할 수 있다.
no-img
나머지 코드들을 보면 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.coremanagement.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}")
    ...
no-img
".../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
반복문을 보면 가장 먼저 apps.get_app_configs()앱과 관련된 get_app_configs라는 메서드를 호출한다.
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()
결국 알아야 할 값은 self.app_configs인데 Apps클래스의 populate메서드를 확인하면 해당 어트리뷰트에 어떤 값이 담겨있을지 추측할 수 있다.
참고로 populate메서드는 django.setup을 통해 호출되는데 이전에 봤던 ManagementUtility의 execute메서드에 해당 과정이 포함되어 있다.
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하위에 익숙한 값들이 보인다.
no-img
이제 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도 추가했다.)
no-img
위 사진을 보면 필자의 Django프로젝트 트리에 test폴더가 있는걸 확인할 수 있다.
그리고 python3 manage.py help를 통해 help메시지를 출력해보면
no-img
마지막에 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 helppython3 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라는 메서드를 통해 호출되는데 이와 관련된 설명은 다음 포스트에서 이어서 작성하도록 하겠다.