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

antoliny0919
2024821
이전 포스트에서 Django커맨드 python3 manage.py help, python3 manage.py를 실행했을시 출력되는 help메시지가 어떻게 만들어지는지 그리고 help메시지에 출력되는 앱들의 커맨드가 될 수 있는 조건에 대해 알아봤다.
이번 포스트에서는 python3 manage.py app(makemigrations, runserver, check ...)명령어를 실행했을때 각 App 명령어의 목적인 로직을 실행하기 전 단계인 모든 App 커맨드들이 거치는 과정에 대해 알아보겠다.
 
버전 출력 커맨드
 

get_version()

 
python
COPY

# django/core/managemnet/__init__.py
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]
                )
        # 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)
이전 포스트에서 subcommand가 "help"일때 가능한 상황들에 대해서 알아봤다면
이번에는 "help"가 아닐때, 즉 python3 manage.py makemigrations, python3 manage.py runserver와 같이 python3 manage.py (등록된 커맨드)로 커맨드를 실행했을때의 로직에 대해 알아보자.
일단 가장 상위에 존재하는 elif문을 보면 subcommand == "version" or self.argv[1:] == ["--version"]일때
get_version함수를 호출하고 결과를 출력한다.
일단 호출되는 메서드명만 봐도 무슨 동작을 할지 예상할 수 있다.
python3 manage.py version과 같이 subcommand가 version이거나 python3 manage.py --version, python3 manage.py makemigrations --version처럼 self.argv[1:]에 --version 옵션이 포함되기만 하면 된다.
self.argv
self.argv는 sys.argv 즉 python3 manage.py xxx 라고 전달했을때 python3 이후 인자들을 가지고 있다.예시로 python3 manage.py makemigrations --version이라고 전달하면 python3 이후 인자들 ["manage.py", "makemigrations", "--version"]이 sys.argv --> self.argv에 할당되고 self.argv[1:]은 manage.py 이후 인자들을 의미한다.
실제로 해당 커맨드들을 실행해보면 "4.2.13"이라는 값이 출력되는데
no-img
해당 값은 예상한대로 설치된 필자의 Django 버전과 같다.
no-img
위와 같이 동작할 수 있는 이유는 django패키지의 get_version함수를 호출하기 때문이다.
get_version 함수는 의미그대로 현재 파이썬 패키지 버젼을 가져오는 함수이다.
python
COPY

def get_version(version=None):
    """Return a PEP 440-compliant version number from VERSION."""
    version = get_complete_version(version)

    # Now build the two parts of the version number:
    # main = X.Y[.Z]
    # sub = .devN - for pre-alpha releases
    #     | {a|b|rc}N - for alpha, beta, and rc releases

    main = get_main_version(version)
    sub = ""
    if version[3] == "alpha" and version[4] == 0:
        git_changeset = get_git_changeset()
        if git_changeset:
            sub = ".dev%s" % git_changeset

    elif version[3] != "final":
        mapping = {"alpha": "a", "beta": "b", "rc": "rc"}
        sub = mapping[version[3]] + str(version[4])

    return main + sub
get_version함수에서 get_complete_version이라는 함수를 호출하는데 get_complete_version함수에서 django패키지__init__.py에 VERSION이라는 변수값을 가져온다.
이를 통해서 Django는 패키지의 버전을 /django/__init__.py VERSION변수에 저장한다고 볼 수 있다.
필자와 같이 4.2.13 버전은 VERSION = (4, 2, 13, "final", 0)이라는 값으로 저장되어있다.
python
COPY

def get_complete_version(version=None):
    """
    Return a tuple of the django version. If version argument is non-empty,
    check for correctness of the tuple provided.
    """
    if version is None:
        from django import VERSION as version
    else:
        assert len(version) == 5
        assert version[3] in ("alpha", "beta", "rc", "final")

    return version

# django

VERSION = (4, 2, 13, "final", 0)

__version__ = get_version(VERSION)
...
참고로 VERSION의 세번째 인덱스값("final")은 릴리즈 단계에 해당한다.
현재 포스트를 작성하는 시점인 2024년 7월 30일 Django 4.2.x 가장 최신 버전은 4.2.15(이후에 달라질 수 있다.)로 해당 패키지의 VERSION값의 세번째 인덱스는 "alpha"이다.
만약 세번째 인덱스값이 "final"이 아닌경우는 필자와 출력이 조금 다를 수 있지만 아마 대부분의 독자분들의 Django는 stable한 버전인 "final"이 설치되어있을거라고 생각하기 때문에 필자와 같이 각자 VERSION의 0, 1, 2인덱스가 "."으로 붙여진 형태로 출력될 것이다.
 
App 커맨드
 
다시 ManagementUtility 클래스의 execute메서드로 돌아가 version을 출력하는 조건문보다 하위에 있는 조건들에 대해서 알아보자.
python
COPY

# django/core/managemnet/__init__.py
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]
                )
        # 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)
그 다음 조건문은 self.argv[1:] in (["--help"], ["-h"])으로 self.argv[1:] --> manage.py 이후 커맨드 인자에 --help, -h옵션이 존재할때이다.
python manage.py --help, python manage.py -h와 같은 경우에 해당하며 main_help_text를 통해 help메시지를 출력한다.
help메시지에 대해서는 이전 포스트에서 다뤘기 때문에 넘어가도록 하겠다.
  
마지막 조건문이 이번 포스트의 핵심이며 Django를 한 번 쯤이라도 사용해본 대부분의 유저가 runserver, makemigrations와 같은 앱 커맨드를 사용해 봤을것이라고 예상한다.
runserver와 makemigrations같은 커맨드는 Django가 기본적으로 제공해주는 커맨드로 실행만했을뿐인데 서버가 켜지고 모델의 변경사항을 적용해주는 등 복잡한 작업을 단 한번의 커맨드로 편리하게 작업할 수 있도록 사용자를 돕는다.
놀랍게도 아무렇지 않게 썼던 앱 커맨드들은 지금 보고 있는 마지막 else문을 거치게 된다.
 

fetch_command()

 
일단 가장 먼저 else문에서 수행하는 코드를 보면 self.fetch_command(subcommand).run_from_argv(self.argv) 먼저
fetch_command메서드를 실행하고 해당 메서드의 반환값에 run_from_argv메서드를 실행한다.
python
COPY

class ManagementUtility:
    ...
    def fetch_command(self, subcommand):
        """
        Try to fetch the given subcommand, printing a message with the
        appropriate command called from the command line (usually
        "django-admin" or "manage.py") if it can't be found.
        """
        # Get commands outside of try block to prevent swallowing exceptions
        commands = get_commands()
        try:
            app_name = commands[subcommand]
        except KeyError:
            if os.environ.get("DJANGO_SETTINGS_MODULE"):
                # If `subcommand` is missing due to misconfigured settings, the
                # following line will retrigger an ImproperlyConfigured exception
                # (get_commands() swallows the original one) so the user is
                # informed about it.
                settings.INSTALLED_APPS
            elif not settings.configured:
                sys.stderr.write("No Django settings specified.\n")
            possible_matches = get_close_matches(subcommand, commands)
            sys.stderr.write("Unknown command: %r" % subcommand)
            if possible_matches:
                sys.stderr.write(". Did you mean %s?" % possible_matches[0])
            sys.stderr.write("\nType '%s help' for usage.\n" % self.prog_name)
            sys.exit(1)
        if isinstance(app_name, BaseCommand):
            # If the command is already loaded, use it directly.
            klass = app_name
        else:
            klass = load_command_class(app_name, subcommand)
        return klass
    ...
fetch_command메서드는 subcommand를 인자로 받는데 여기서 subcommand는 python3 manage.py xxx명령어를 실행하면 xxx에 해당한다.
fetch_command메서드에서 가장 먼저 실행되는 get_commands함수는 이전 포스트에서 다뤘다.
간단하게 설명하면 등록된 커맨드들을 가져오는 메서드로 help메시지를 출력했을때 들여쓰기 되어있는 모든 것들이 등록된 커맨드라고 볼 수 있다.
no-img
(커맨드를 등록하는 조건과 같은 자세한 부분은 이전 포스트를 참고하기 바란다.)
fetch_command메서드의 try/except문을 보면 get_commands로 가져온 커맨드에 사용자가 요청한 커맨드가 존재하는 지 확인하고 존재한다면 해당 커맨드의 앱이름을 가져온다.
만약 필자가 python3 manage.py helloworld라고 명령어를 실행하면 helloworld라는 커맨드는 존재하지 않기 때문에
존재하지 않은 키에대한 접근으로 KeyError가 발생하고 fetch_command메서드의 try/except문에서 except블럭을 실행하게 된다.
 

친절한 에러 메시지

 
except블럭은 settings와 관련된 설정을 재검토후 에러를 출력한 뒤 종료하는데 필자에게 가장 인상깊었던 부분은 get_close_matches함수 호출 부분이다.
python
COPY

class ManagementUtility:
    ...
    def fetch_command(self, subcommand):
        try:
            app_name = commands[subcommand]
        except KeyError:
            if os.environ.get("DJANGO_SETTINGS_MODULE"):
                # If `subcommand` is missing due to misconfigured settings, the
                # following line will retrigger an ImproperlyConfigured exception
                # (get_commands() swallows the original one) so the user is
                # informed about it.
                settings.INSTALLED_APPS
            elif not settings.configured:
                sys.stderr.write("No Django settings specified.\n")
            possible_matches = get_close_matches(subcommand, commands)
            sys.stderr.write("Unknown command: %r" % subcommand)
            if possible_matches:
                sys.stderr.write(". Did you mean %s?" % possible_matches[0])
            sys.stderr.write("\nType '%s help' for usage.\n" % self.prog_name)
            sys.exit(1)
        if isinstance(app_name, BaseCommand):
            # If the command is already loaded, use it directly.
            klass = app_name
        else:
            klass = load_command_class(app_name, subcommand)
        return klass
    ...
아마 Django커맨드를 입력하면서 누구나 한 번쯤은 오타를 입력하게 된 경험이 있을거라고 생각한다.
get_close_matches함수는 파이썬 표준 라이브러리 difflib의 함수로 첫 번째 인자로 전달해준 값이 두 번째 인자로 전달해준 값 내에서 적절하게 일치된 문자들을 반환한다.
필자가 만약 실수로 python3 manage.py migratepython3 manage.py migratee로 실행했을때
get_close_matches함수는 commands중에 잘못 입력한 "migratee"와 거의 일치하는 ['migrate', 'sqlmigrate']를 반환한다.
참고로 각 값에 유사성 점수를 매겨 가장 유사한 값이 맨 앞 인덱스에 위치하게 된다.
no-img
그렇기 때문에 python3 manage.py migratee로 사용자의 의도는 어느정도 파악되지만 오타를 입력하여 잘못된 커맨드를 실행했을때 "Did you mean migrate"라는 친절한 에러가 출력된다.
이번에는 알맞은 커맨드를 실행했을때 fetch_command메서드 동작을 살펴보자.
 

커맨드가 되기 위한 두 번째 조건

 
python
COPY

class ManagementUtility:
    ...
    def fetch_command(self, subcommand):
        try:
            app_name = commands[subcommand]
        except KeyError:
            ...
        if isinstance(app_name, BaseCommand):
            # If the command is already loaded, use it directly.
            klass = app_name
        else:
            klass = load_command_class(app_name, subcommand)
        return klass
    ...
가장 먼저 app_name이 BaseCommand의 인스턴스인지 확인한다.
예시로 필자가 python3 manage.py makemigrations라고 커맨드를 실행하면
makemigrations는 subcommand값이고 django.core에서 기본적으로 제공하는 커맨드이기 때문에
try문에서 에러가 발생하지않고 app_name에 "django.core"가 할당된다.
하지만 "django.core"는 문자열이기 때문에 최초 실행시 조건문 isinstance(app_name, BaseCommand)를 통과하지 못한다.
그렇기 때문에 else문으로 향하고 load_command_class함수를 호출한다.
python
COPY

def load_command_class(app_name, name):
    """
    Given a command name and an application name, return the Command
    class instance. Allow all errors raised by the import process
    (ImportError, AttributeError) to propagate.
    """
    module = import_module("%s.management.commands.%s" % (app_name, name))
    return module.Command()
load_command_class함수는 인자로 받은 app_name을 통해 모듈을 동적으로 로드한다.
여기서 모듈의 경로를 보면 "{app.name}.management.commands.${command_name}"이 된다.
왜 이런 경로의 모듈을 로드하는지에 대해 이해하려면 이전 포스트에서 다뤘던 커맨드가 되기 위한 조건에 대해 알아야하는데
필자가 간단하게 설명하면 INSTALLED_APPS에 등록된 앱, 해당 앱 폴더하위에 "/management/commands/"경로의 폴더가 존재해야하고 commands폴더 하위에 있는 모든 파일명이 커맨드 이름이 된다.
필자가 이전 포스트에서 임의로 만든 test앱의 경로를 보면 쉽게 알 수 있다.
no-img
test앱 폴더를 보면 다른 앱(users, authentication)과 달리 admin.py, models.py같은 python3 manage.py startapp appname 명령어를 실행시 기본으로 생성되는 파일이 존재하지않는다.
그 이유는 필자가 이전 포스트에서 커맨드가 되기위한 조건을 설명하며 최소한의 조건만 갖추기 위해 python3 manage.py startapp명령어를 사용하지 않고 생성한 앱이기 때문이다.
아무튼 test앱을 보면 하위에 "/management/commands/"경로의 폴더가 존재하고 commands하위에 test1.py, test2.py, test3.py가 있다.
위 정보를 토대로 Django는 test앱이 존재하고 test앱에 test1, test2, test3 커맨드가 있구나 라는걸 파악하고 등록한다.
그렇기 때문에 현재 필자의 help메시지에 test앱과 관련된 커맨드 test1, test2, test3이 등록되어있는걸 확인할 수 있는것이다.
no-img
python
COPY

def load_command_class(app_name, name):
    """
    Given a command name and an application name, return the Command
    class instance. Allow all errors raised by the import process
    (ImportError, AttributeError) to propagate.
    """
    module = import_module("%s.management.commands.%s" % (app_name, name))
    return module.Command()
필자가 만약 python3 manage.py test1 명령어를 실행하면 fetch_command에서 test1은 유효한 커맨드이기 때문에 app_name은 test가 되고 load_command_class함수를 호출할때 인자로 app_name은 test, name은 subcommand인 test1을 전달하게 된다.
즉 load_command_class에서 "test.management.commands.test1"모듈이 동적으로 로드되고 해당 모듈의 Command클래스 객체를 생성한 뒤 반환한다.
여기서 Django 커맨드가 될 수 있는 두 번째 조건에 대해 알 수 있다.
바로 subcommand로 전달한 값인 subcommand.py 파일에 Command클래스가 존재해야 한다는 점이다.
하지만 필자의 test1.py는 아무런 코드가 존재하지 않는 빈 파일이다.
no-img
그렇기 때문에 위 에러를 보면 알 수 있듯이 load_command_class함수의 return module.Command()라인에서 어트리뷰트 에러가 발생한다.
python
COPY

class ManagementUtility:
    ...
    def fetch_command(self, subcommand):
        if isinstance(app_name, BaseCommand):
            # If the command is already loaded, use it directly.
            klass = app_name
        else:
            klass = load_command_class(app_name, subcommand)
        return klass
    ...
만약 정상적인 Command가 구현된 커맨드를 입력하면 klass에 해당 커맨드 파일의 Command클래스 객체가 할당 되고 해당 값을 반환함으로써 fetch_command의 역할은 끝난다.
python
COPY

class ManagementUtility:
    ...
    def execute(self):
        ...
        if subcommand == "help":
            if "--commands" in args:
                ...
        # 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)
이제 ManagementUtility클래스의 excute메서드 마지막 부분인 유효한 커맨드를 입력했을 때self.fetch_command(subcommand)의 반환값을 알 수 있다.
예시로 python3 manage.py makemigrations와 같은 django.core에서 제공하는 유효한 커맨드를 실행하면 makemigrations.py에 Command클래스 객체가 self.fetch_command(subcommand)의 반환값이 된다.
그리고 해당 객체는 run_from_argv라는 메서드를 호출한다.
run_from_argv메서드는 어떤 동작을 하는걸까?
 
앱 커맨드의 기본 동작
 
run_from_argv메서드는 BaseCommand클래스에 구현되어 있다.
여기서 BaseCommand클래스는 Django앱 커맨드들의 기본적인 동작을 가진 클래스이므로 앱 커맨드들은 BaseCommand클래스를 상속받아야한다.
(물론 상속받지 않고 직접 구현할 수 있겠지만 많이 피곤해진다.)
python
COPY

class BaseCommand:
    ...
    def run_from_argv(self, argv):
        """
        Set up any environment changes requested (e.g., Python path
        and Django settings), then run this command. If the
        command raises a ``CommandError``, intercept it and print it sensibly
        to stderr. If the ``--traceback`` option is present or the raised
        ``Exception`` is not ``CommandError``, raise it.
        """
        self._called_from_command_line = True
        parser = self.create_parser(argv[0], argv[1])

        options = parser.parse_args(argv[2:])
        cmd_options = vars(options)
        # Move positional args out of options to mimic legacy optparse
        args = cmd_options.pop("args", ())
        handle_default_options(options)
        try:
            self.execute(*args, **cmd_options)
        except CommandError as e:
            if options.traceback:
                raise

            # SystemCheckError takes care of its own formatting.
            if isinstance(e, SystemCheckError):
                self.stderr.write(str(e), lambda x: x)
            else:
                self.stderr.write("%s: %s" % (e.__class__.__name__, e))
            sys.exit(e.returncode)
        finally:
            try:
                connections.close_all()
            except ImproperlyConfigured:
                # Ignore if connections aren't setup at this point (e.g. no
                # configured settings).
                pass
    ...
run_from_argv메서드는 가장 먼저 Parser를 생성하는 create_parser메서드를 실행한다.
 

create_parser()

 
python
COPY

class BaseCommand:
    ...
    def create_parser(self, prog_name, subcommand, **kwargs):
        """
        Create and return the ``ArgumentParser`` which will be used to
        parse the arguments to this command.
        """
        kwargs.setdefault("formatter_class", DjangoHelpFormatter)
        parser = CommandParser(
            prog="%s %s" % (os.path.basename(prog_name), subcommand),
            description=self.help or None,
            missing_args_message=getattr(self, "missing_args_message", None),
            called_from_command_line=getattr(self, "_called_from_command_line", None),
            **kwargs,
        )
아마 이전 포스트를 봤던 독자분들이라면 위 create_parser메서드 코드들이 익숙하게 느껴질거라고 독자는 예상한다.
가장 먼저 execute메서드에서도 생성했던 CommandParser클래스를 생성하는데
CommandParser클래스는 python 커맨드를 쉽게 만들 수 있도록 도와주는 python 표준라이브러리 argparse의 코어인ArgumentParser클래스를 상속받는다.
위 메서드의 동작을 이해하는데 가장 쉬운 방법은 실제로 커맨드를 만들어 보는 방법이라고 생각한다.
필자가 예시로 이전에 등록했던 커맨드인 test앱의 test1.py에 굉장히 간단한 커맨드클래스를 만들었다.
python
COPY

from django.core.management.base import BaseCommand

class Command(BaseCommand):
  
  def add_arguments(self, parser):
    parser.add_argument(
      "--hello",
      action="store_true",
      help=(
        "Add 'hello' to the output."
      )
    )
    parser.add_argument(
      "--world",
      action="store_true",
      help=(
        "Add 'world' to the output."
      )
    )
    
  
  def handle(self, *app_labels, **options):
    return self.stdout.write("run test1 command!!" + "\n")
해당 커맨드를 보면 add_arguments메서드와 handle메서드를 구현했고 add-arguments메서드에 "--hello"와 "--world"옵션을 추가하는걸 확인할 수 있다.
실제로 test1 커맨드에 위 옵션들이 추가되었는지 확인해보자.
python3 manage.py test1 --help 커맨드를 통해 test1커맨드의 help메시지를 출력해보면
no-img
위와 같이 "--hello", "--world"옵션이 추가되었고 이외에 "--no-color", "--traceback"등등 add_arguments메서드에서 추가하지 않은 옵션들도 추가된 걸 확인할 수 있다.
다시 create_parser메서드를 보면 add_base_argument라는 메서드를 연속적으로 호출한다.
python
COPY

class BaseCommand:
    ...
    def create_parser(self, prog_name, subcommand, **kwargs):
        """
        Create and return the ``ArgumentParser`` which will be used to
        parse the arguments to this command.
        """
        ...
        self.add_base_argument(
            parser,
            "--version",
            action="version",
            version=self.get_version(),
            help="Show program's version number and exit.",
        )
        self.add_base_argument(
            parser,
            "-v",
            "--verbosity",
            default=1,
            type=int,
            choices=[0, 1, 2, 3],
            help=(
                "Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, "
                "3=very verbose output"
            ),
        )
        self.add_base_argument(
            parser,
            "--settings",
            help=(
                "The Python path to a settings module, e.g. "
                '"myproject.settings.main". If this isn\'t provided, the '
                "DJANGO_SETTINGS_MODULE environment variable will be used."
            ),
        )
        self.add_base_argument(
            parser,
            "--pythonpath",
            help=(
                "A directory to add to the Python path, e.g. "
                '"/home/djangoprojects/myproject".'
            ),
        )
        self.add_base_argument(
            parser,
            "--traceback",
            action="store_true",
            help="Raise on CommandError exceptions.",
        )
        self.add_base_argument(
            parser,
            "--no-color",
            action="store_true",
            help="Don't colorize the command output.",
        )
        self.add_base_argument(
            parser,
            "--force-color",
            action="store_true",
            help="Force colorization of the command output.",
        )
        if self.requires_system_checks:
            parser.add_argument(
                "--skip-checks",
                action="store_true",
                help="Skip system checks.",
            )
        self.add_arguments(parser)
        return parser
add_base_argument메서드에 전달되는 인자들을 보면 위 help메시지에 전부 다 존재하는 옵션들로
create_parser메서드에서 Django 앱 커맨드들이 기본적으로 가질 옵션들을 추가하는 과정에 해당된다.
python
COPY

class BaseCommand:
    ...
    def add_base_argument(self, parser, *args, **kwargs):
        """
        Call the parser's add_argument() method, suppressing the help text
        according to BaseCommand.suppressed_base_arguments.
        """
        
        for arg in args:
            if arg in self.suppressed_base_arguments:
                kwargs["help"] = argparse.SUPPRESS
                break
        parser.add_argument(*args, **kwargs)
여기서 호출된 add_base_argument메서드는 메서드명 그대로 앱 커맨드들의 "기본"인자들을 추가하며
전달된 인자가 self.suppressed_base_arguments에 포함된다면 해당 옵션은 아무리 기본 옵션일지라도 추가되지 않는다.
python
COPY

class Command(BaseCommand):
  
  def __init__(self):
    self.suppressed_base_arguments.add('--no-color')
    self.suppressed_base_arguments.add('--force-color')
    super().__init__()
  ...
test1 커맨드에 self.suppressed_base_arguments값으로 기본으로 추가되는 옵션중 하나인 "--no-color"와 "--force-color"를 추가한 뒤 다시 test1 커맨드의 help메시지를 출력해보겠다.
no-img
이전과 달리 test1 커맨드의 help메시지에 --no-color옵션과 --force-color옵션이 보이지 않는다.
create_parser메서드의 마지막 부분을 보면 self.add_arguments(parser) --> add_arguments메서드를 호출하는데
python
COPY

class BaseCommand:
    ...
    def create_parser(self, prog_name, subcommand, **kwargs):
        """
        Create and return the ``ArgumentParser`` which will be used to
        parse the arguments to this command.
        """
        ...
        if self.requires_system_checks:
            parser.add_argument(
                "--skip-checks",
                action="store_true",
                help="Skip system checks.",
            )
        self.add_arguments(parser)
        return parser
여기서 호출되는 add_arguments메서드가 각 앱 커맨드들의 add_arguments메서드이다.
즉 각 앱 커맨드가 정의한 전용 옵션을 추가하는 과정으로 필자의 test1 커맨드를 예시로 했을때 "--hello", "--world"에 해당된다.
no-img
이렇게 create_parser메서드는 parser클래스를 생성하고 생성한 parser클래스에 Django앱 기본 옵션들과 앱 전용 옵션들을 추가한 뒤 해당 parser를 반환한다.
다음으로 호출되는 parse_args메서드는 ArgumentParser클래스의 메서드로 간단하게 파서 클래스에 추가된 옵션들과 전달한 값을 매칭한다.
하지만 ArgumenetParser를 상속한 Django의 CommandParser클래스는 parse_args를 오버라이딩 하여 추가적인 동작이 더해졌다.
python
COPY

class CommandParser(ArgumentParser):
    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)
추가된 동작은 파서 클래스 객체 missing_args_message속성에 값이 할당되어 있을때는 아무런 옵션을 전달하지 않았을때 에러가 발생하고 missing_args_message에 할당된 메시지가 출력된다.
테스트로 필자의 test1커맨드에 missing_args_message를 추가한 뒤 옵션을 사용하지 않은 python3 manage.py test1 커맨드를 실행해보겠다.
python
COPY

class Command(BaseCommand):
  
  def __init__(self):
    self.suppressed_base_arguments.add('--no-color')
    self.suppressed_base_arguments.add('--force-color')
    self.missing_args_message = "!!!!!!!!!!!!!"
    super().__init__()
  ...
no-img
출력된 결과를 확인해보면 필자가 missing_args_message에 할당한 "!"가 여러개가 출력되는걸 확인할 수 있다.
이렇게 Django의 앱 커맨드는 기본 ArgumentParser에 사용자에게 친화적인 몇 가지 기능들이 추가된걸 확인할 수 있다.
Django의 CommandParser의 parse_args메서드에 대해 알아봤다면 메서드 이름대로 인자들을 파싱하는 기능을 담은 ArgumentParser클래스의 parse_args결과값을 확인해보겠다.
테스트에 사용할 커맨드는 python3 manage.py test1 --hello --settings=test-jwt.settings으로 test1앱 커맨드가 인식할 수 있는 두 가지 옵션을 사용했다.
위 커맨드를 실행했을때 결과를 보면
no-img
위치 인자와 옵션들을 저장하는 argparse 라이브러리의 Namespace객체가 반환되며 Namespace객체에 저장된 값을 보면 키값은 전부 다 test1 커맨드가 가진 옵션들이고 --settings, --hello에는 필자가 전달한 값이 매치된걸 확인할 수 있다.
python
COPY

class BaseCommand():
    def run_from_argv(self, argv):
        ...
        options = parser.parse_args(argv[2:])
        cmd_options = vars(options)
        # Move positional args out of options to mimic legacy optparse
        args = cmd_options.pop("args", ())
        handle_default_options(options)
        try:
            self.execute(*args, **cmd_options)
이렇게 parse_args메서드는 사용자가 전달한 옵션과 매치되는 옵션에는 값을 저장하고 이외 옵션들에는 기본값을 매치한 Namespace객체를 반환한다.
이후 호출되는 부분들에 대해서는 간단하게 설명하겠다.
vars함수는 해당 객체의 __dict__을 호출하는데 Namespace객체의 __dict__메서드를 호출하면 저장한 옵션들이 딕셔너리 형태로 출력된다.
no-img
그 다음 해당 딕셔너리에서 args값만 가져오는데 필자의 test1 커맨드는 위치 인자를 추가하지 않았기 때문에 args가 존재하지 않는다.
간단하게 위치 인자를 가져오는 부분이라고 보면 된다.
그리고 handle_default_options는 필자가 이전 포스트에서도 설명했듯이 Django가 사용할 settings를 정하는 DJANGO_SETTINGS_MODULE환경변수를 설정하고 --pythonpath옵션에 값이 전달되었다면 전달된 경로를 sys.path에 추가한다.
no-img
그 다음으로 호출되는 메서드인 execute가 메서드명 그대로 "실행"을 의미하며
여기서 "실행"은 Django 앱 커맨드가 가지는 기본적인 동작을 실행하는 부분이 된다.
 

execute()

 
python
COPY

class BaseCommand:
    ...
    def execute(self, *args, **options):
        """
        Try to execute this command, performing system checks if needed (as
        controlled by the ``requires_system_checks`` attribute, except if
        force-skipped).
        """
        if options["force_color"] and options["no_color"]:
            raise CommandError(
                "The --no-color and --force-color options can't be used together."
            )
        if options["force_color"]:
            self.style = color_style(force_color=True)
        elif options["no_color"]:
            self.style = no_style()
            self.stderr.style_func = None
        if options.get("stdout"):
            self.stdout = OutputWrapper(options["stdout"])
        if options.get("stderr"):
            self.stderr = OutputWrapper(options["stderr"])
위에서 설명했듯이 execute메서드는 "실행"을 담당하는 메서드이지만 BaseCommand의 메서드라는걸 생각해야한다.
즉 모든 앱 커맨드들이 기본적으로 실행시 거치게 되는 동작이고 앱 커맨드 각자의 진짜 동작은 각 앱 커맨드의 handle메서드에서 이루어지게 된다.
일단 가장 먼저 Django앱의 기본 옵션중 하나인 force-color, no-color를 인자로 전달했을때의 로직이 담겨있다.
force-color, no-color는 굉장히 직관적으로 옵션명 그대로 이해하면 된다.
예시로 필자가 python3 manage.py migrate 명령어에 --no-color옵션을 줬을때와 안줬을때의 차이를 확인해보면
no-img
--no-color옵션을 전달했을때는 색상이 적용되지 않는걸 확인할 수 있다.
python
COPY

class BaseCommand():
    ...
    def execute(self, *args, **options):
        ...
        if self.requires_system_checks and not options["skip_checks"]:
            if self.requires_system_checks == ALL_CHECKS:
                self.check()
            else:
                self.check(tags=self.requires_system_checks)
        if self.requires_migrations_checks:
            self.check_migrations()
        output = self.handle(*args, **options)
그 다음은 check메서드를 호출하는 부분으로 self.requires_system_checks값은 따로 오버라이딩 하지 않는 이상 '__all__'이라는 문자열 값을 가진다.
만약 옵션으로 --skip-checks를 전달하지 않았다면 check메서드의 호출이 이루어지는데 ALL_CHECKS변수값도 '__all__'로 보통 따로 커스터마이징 하지 않는 이상 첫번째 조건문이 통과되어 check메서드를 호출하게 된다.
python
COPY

class BaseCommand():
    def check(
        self,
        app_configs=None,
        tags=None,
        display_num_errors=False,
        include_deployment_checks=False,
        fail_level=checks.ERROR,
        databases=None,
    ):
        """
        Use the system check framework to validate entire Django project.
        Raise CommandError for any serious message (error or critical errors).
        If there are only light messages (like warnings), print them to stderr
        and don't raise an exception.
        """
        all_issues = checks.run_checks(
            app_configs=app_configs,
            tags=tags,
            include_deployment_checks=include_deployment_checks,
            databases=databases,
        )

        header, body, footer = "", "", ""
        visible_issue_count = 0  # excludes silenced warnings

        if all_issues:
            debugs = [
                e for e in all_issues if e.level < checks.INFO and not e.is_silenced()
            ]
            infos = [
                e
                for e in all_issues
                if checks.INFO <= e.level < checks.WARNING and not e.is_silenced()
            ]
            warnings = [
                e
                for e in all_issues
                if checks.WARNING <= e.level < checks.ERROR and not e.is_silenced()
            ]
            errors = [
                e
                for e in all_issues
                if checks.ERROR <= e.level < checks.CRITICAL and not e.is_silenced()
            ]
            criticals = [
                e
                for e in all_issues
                if checks.CRITICAL <= e.level and not e.is_silenced()
            ]
            sorted_issues = [
                (criticals, "CRITICALS"),
                (errors, "ERRORS"),
                (warnings, "WARNINGS"),
                (infos, "INFOS"),
                (debugs, "DEBUGS"),
            ]

            for issues, group_name in sorted_issues:
                if issues:
                    visible_issue_count += len(issues)
                    formatted = (
                        self.style.ERROR(str(e))
                        if e.is_serious()
                        else self.style.WARNING(str(e))
                        for e in issues
                    )
                    formatted = "\n".join(sorted(formatted))
                    body += "\n%s:\n%s\n" % (group_name, formatted)

        if visible_issue_count:
            header = "System check identified some issues:\n"

        if display_num_errors:
            if visible_issue_count:
                footer += "\n"
            footer += "System check identified %s (%s silenced)." % (
                "no issues"
                if visible_issue_count == 0
                else "1 issue"
                if visible_issue_count == 1
                else "%s issues" % visible_issue_count,
                len(all_issues) - visible_issue_count,
            )

        if any(e.is_serious(fail_level) and not e.is_silenced() for e in all_issues):
            msg = self.style.ERROR("SystemCheckError: %s" % header) + body + footer
            raise SystemCheckError(msg)
        else:
            msg = header + body + footer

        if msg:
            if visible_issue_count:
                self.stderr.write(msg, lambda x: x)
            else:
                self.stdout.write(msg)
check메서드는 의미 그대로 커맨드를 수행하기 전에 체크해야할 사항들을 체크하는 부분이라고 보면된다.
누구나 check라는 메서드를 처음 접했을때 이러한 호기심이 들지 않았을까 싶다.
"과연 어떠한 부분들을 체크하는건가?"
필자또한 궁금했기 때문에 check메서드에 대해서 알아봤고 동작에 대해서 설명하고 싶은 마음이 굴뚝같지만
check과정은 커맨드와는 또 다른 하나의 부분이라고 생각하기 때문에
소스코드에 대해 설명하려면 포스트를 두 개는 더 작성해야할 거 같다.
그렇기 때문에 딱 한 가지에 대해서만 알고가자.
그 한 가지는 "어떠한 부분들을 체크하는가?"이다.
"어떠한 부분들을 체크하는가?"에 대해 알기전에 "어떻게 해야 체크과정에 포함되는 함수가 되는가?" 부터 알아야한다.
결론부터 말하면 django.core registry모듈 CheckRegistry클래스의 register메서드를 체크할 로직을 담은 함수 인자로 하여 직접 호출하거나 데코레이터로 사용하면 된다. 아래와 같이 말이다.
python
COPY

from django.core import CheckRegistry

def my_check():
    pass

registry = CheckRegistry()

1. registry.register(my_check, 'mytag', 'anothertag')
2. @registry.register('mytag', 'anothertag')
이렇게 CheckRegistry클래스의 register메서드를 사용하면 사용자또한 커스텀 체크를 추가할 수 있다.
이제 "어떻게 해야 체크과정에 포함되는 함수가 되는가?"에 대해 알았으니 해당 조건을 충족하는 함수를 Django패키지에서 찾으면 된다.
Django가 기본적으로 제공하는 체크들은 django.core.checks 하위 모듈에 존재한다.
no-img
위 함수들이 일반적인 앱 커맨드를 실행할때 수행하는 체크 함수들이다.
간단하게 설명하면 위 과정 대부분 설정값을 체크하는 부분으로 커맨드를 실행하기전에 해당 Django 프로젝트가 문제 없는 설정인가에 대해 검증한다고 생각하면 된다.
함수 이름들을 보고 자세한 검증 동작이 궁금한 부분들이 있으면 위 필자가 링크한 곳에서 찾으면 된다.
이 외에도 각 체크들은 태그가 있어 원하는 태그에 해당하는 체크만 수행할 수 있으며 deploy인자가 True인 체크는
python3 manage.py check 커맨드의 --deploy옵션을 줬을때 해당 체크들이 동작하게 된다.
python
COPY

class BaseCommand():
    ...
    def execute(self, *args, **options):
        ...
        if self.requires_system_checks and not options["skip_checks"]:
            if self.requires_system_checks == ALL_CHECKS:
                self.check()
            else:
                self.check(tags=self.requires_system_checks)
        if self.requires_migrations_checks:
            self.check_migrations()
        output = self.handle(*args, **options)
다시 BaseCommand클래스의 execute메서드로 돌아와서 마지막부분을 보면 self.handle(*args, **options) --> handle메서드를 호출한다.
여기서 handle메서드는 BaseCommand를 상속한 각 앱 커맨드 클래스인 Command클래스의 handle메서드에 해당하며
필자가 이전에 한 번 설명했듯이 handle메서드가 바로 각 앱 커맨드들의 목적을 수행하는 부분
즉 각 앱 커맨드들의 실질적인 동작이 담기는 부분이다.
필자의 test1커맨드를 예시로 들자면
python
COPY

from django.core.management.base import BaseCommand

class Command(BaseCommand):
  
  def __init__(self):
    # self.suppressed_base_arguments.add('--no-color')
    # self.suppressed_base_arguments.add('--force-color')
    # self.missing_args_message = "!!!!!!!!!!!!!"
    super().__init__()
  
  def add_arguments(self, parser):
    parser.add_argument(
      "--hello",
      action="store_true",
      help=(
        "Add 'hello' to the output."
      )
    )
    parser.add_argument(
      "--world",
      action="store_true",
      help=(
        "Add 'world' to the output."
      )
    )
    
  
  def handle(self, *app_labels, **options):
    return self.stdout.write("run test1 command!!" + "\n")
필자의 test1 커맨드는 오직 테스트 용도이기 때문에 옵션들을(hello, world) 처리하는 동작도 없을 뿐더러 호출되는 흐름만 설명하기 위해 정말 간단하게 "run test1 command!!"를 출력하고 종료된다.
no-img
이렇게 지금까지 각 App 명령어의 목적인 로직(handle)을 실행하기 전 단계인 모든 App 명령어 들이 거치게 되는 Django 커맨드 기본 로직에 대해 알아봤다.
이후 각 커맨드들의 로직에 대해 궁금하다면 각 커맨드 handle메서드 코드를 참고하면 된다.
마법같이 느껴졌던 다양한 Django 커맨드들의 진짜 원리는 handle메서드에 있다는 뜻이다.
앞으로 자주 사용하는 Django 커맨드들의 handle메서드 로직에 대해 알아보고 포스팅할 예정이다.
마법의 원리에 대해 알 수 있다니 벌써부터 설렌다.