시작

테크세미나 2기를 마치며 지난 django-extensions 버그 수정하기 작업에 이어 같은 방식으로 버그 수정에 도전했습니다. 저장소 메인테이너가 붙인 Easy Pickings 태그의 이슈를 중심으로 살펴보았고 그 중 아래 이슈를 선택했습니다. 이슈 링크와 패키지 저장소 링크를 함께 올렸으니 직접 가서 보셔도 좋을 것 같습니다.

자세한 이슈 선정 기준과 django-extensions 패키지에 대한 설명은 첫 번째 작업 포스트 링크를 참고해주시기 바랍니다.

issue

저는 django-extensions 패키지를 사용하면서 써본 적이 없었지만 설명을 읽어보니 유용해보이는 커맨드인 dumpscript에 대한 오류였습니다. 이 커맨드는 MySQL의 mysqldump 명령어와 같이 데이터베이스 백업을 처리하는 커맨드입니다. 데이터베이스에서 제공하는 기능이 있는데 굳이 이 커맨드를 써야만 하는지에 대해서 의문점이 들 수도 있는데요, 예를 들어 데이터베이스 덤프를 했지만 약간의 수정을 가하고 싶을 때 입맛대로 가공하기 쉽다는 장점이 있습니다. dumpscript 커맨드의 결과로 파이썬 코드를 얻을 수 있기 때문입니다. 하지만 저도 아직 실제 사용해본 적은 없네요.

이슈를 살펴보니 에러 메시지는 아래와 같았습니다.

  File "out.txt", line 136
    pictures_picture_3.owner_user =  importer.locate_object(User, "id", User, "id", 1, {'id': 1, 'password': 'XXXX', 'last_login': datetime.datetime(2018, 7, 30, 3, 19, 37, 885214, tzinfo=<UTC>), 'is_superuser': True, 'first_name': 'Martín', 'last_name': 'Volpe', 'is_staff': True, 'is_active': True, 'date_joined': datetime.datetime(2010, 1, 11, 21, 45, 36, tzinfo=<UTC>), 'email': 'XX@gmail.com', 'slug': 'martinvol'} )
                                                                                                                                                   ^
SyntaxError: invalid syntax

dumpscript 커맨드의 결과물1인 out.py 파일을 runscript 커맨드로 실행시켰는데요. 중간에 나오는 tzinfo=<UTC> 코드에서 에러가 발생했습니다. tzinfo 패러미터는 시간의 세계 시간대를 지정하는 패러미터인데요. 보통 pytz.utc 객체 등을 사용하기 마련입니다. 그런데 <UTC>라는 코드는 문법에 맞지 않는 것이죠. 그래서 문법 에러가 발생하고 말았습니다.

bug patch

시간대 맞추기

간단하게 pytz.utc 코드가 나오도록 만들면 될 것 같았지만 실제 작업은 그렇게 순탄하지 않았습니다.

datetime.datetime(2010, 1, 11, 21, 45, 36, tzinfo=<UTC>)

기존 django-extensions 패키지 방식으로 만들어낸 위 코드는 문법 에러2를 발생시켜 사용할 수 없는 상태입니다. 차선책으로는 datetime.datetime 객체를 리턴하는 datetime.parser.parse() 함수를 사용하는 것입니다. 이 함수의 인자로 데이터베이스의 일시 데이터를 문자열로 넣어주면 시간대가 반영된 datetime.datetime 객체를 얻을 수 있기 때문입니다.

# datetime.datetime 객체 리턴
# 시간대 정보(+09:00)를 입력해주면 tzinfo 역시 설정됨
dateutil.parser.parse("2019-05-20T03:32:27.144586+09:00")

하지만 이를 위해서는 데이터베이스의 일시 데이터를 시간대에 맞춘 데이터로 바꿔야 하는 과정이 필요합니다. 데이터베이스에 저장된 데이터는 UTC 기준이라 현재 프로젝트의 시간대를 알 수 없기 때문입니다. 만약 이를 그대로 사용3한다면 데이터베이스를 다른 시간대의 서버가 활용할 때 데이터의 시간 왜곡을 가져올 수 있습니다. 즉, 지금은 데이터베이스의 UTC 기준 시간(naive datetime)을 프로젝트의 시간대로 변환시키는 함수가 필요한데요, 그것은 바로 django.utils.timezone 패키지의 make_aware() 함수입니다. 이 함수는 쉽게 말해서 naive datetimeaware datetime으로 바꿔주는 함수입니다.

위 내용을 토대로 코드를 문자열 타입으로 만들면 아래와 같습니다.

v = timezone.make_aware(v)  # v(datetime.datetime)
code = 'dateutil.parser.parse("%s")' % v.isoformat())

v 변수를 aware datetime으로 만들어주고 문자열로 바꿔주는 isoformat() 함수를 이용해 문자열로 변환시켜 사용하는 것입니다.

코드를 집어넣기

여기까지도 쉽지 않았지만 문제는 이것을 코드로 변환시키는 것에 있었습니다. 만약 위와 같이 code라는 변수에 담긴 문자열을 가지고 dumpscript 커맨드를 사용하면 그냥 문자열이 생성됩니다. 즉, 아래와 같이 일반 CharField처럼 되어버리는 것입니다.

# Good
'date_joined': dateutil.parser.parse("2019-05-20T03:32:27.144586+09:00")

# Bad
'date_joined': 'dateutil.parser.parse("2019-05-20T03:32:27.144586+09:00")'

# Problem
'date_joined': datetime.datetime(2010, 1, 11, 21, 45, 36, tzinfo=<UTC>)  # parse() 리턴값, error

그렇다고 해서 문자열이 아닌 코드를 직접 입력해버리면 커맨드의 결과물에 코드가 아닌 코드의 리턴값(datetime.datetime-현재 오류를 발생시키는)이 나와버리고 맙니다.

어떻게 하면 좋을까요?

여기서부터는 보편적이지 않은 코딩인데요. 파이썬의 매직 메소드 중에서 __repr__ 메소드를 이용하면 문제를 해결할 수 있습니다.

간단한 예를 들어보겠습니다.

>>> from datetime import datetime
>>> now = 'datetime.now()'
>>> now
'datetime.now()'
>>> now = datetime.now()
>>> now
datetime.datetime(2019, 6, 30, 13, 35, 33, 723939)

now 변수를 문자열로 입력할 경우 __repr__ 메소드 호출 시 따옴표가 붙어 있지만 객체일 경우에는 그것이 없고 마치 코드 자체를 출력하는 것처럼 보입니다. 이것에 착안하여 간단한 클래스를 만들었습니다.

class StrToCodeChanger:

      def __init__(self, string):
         self.repr = string

      def __repr__(self):
         return self.repr

StrToCodeChanger 클래스를 이용하면 생성자에서 문자열로서의 코드를 받아 따옴표 없이 출력할 수 있습니다. 이 클래스 정의를 포함해 결과적으로 아래와 같은 코드를 만들었습니다.

for key in clean_dict:
    v = clean_dict[key]

    # my code
    if v is not None:
        if isinstance(v, datetime.datetime):
            v = timezone.make_aware(v)
            clean_dict[key] = StrToCodeChanger('dateutil.parser.parse("%s")' % v.isoformat())
        elif not isinstance(v, (six.string_types, six.integer_types, float)):
            clean_dict[key] = six.u("%s" % v)

    output = """ importer.locate_object(%s, "%s", %s, "%s", %s, %s ) """ % (
        original_class, original_pk_name,
        the_class, pk_name, pk_value, clean_dict
    )

clean_dict 딕셔너리에는 StrToCodeChanger 클래스가 값으로 들어가 있는데요. 이를 output 변수에서 문자열로 바뀔 때 __repr__ 메소드가 호출되어 코드가 그대로 삽입됨을 알 수 있습니다.

test case

이 버그 역시 다시 발생하지 않도록 테스트 케이스를 만들었습니다. django-extension 패키지에는 시간대가 UTC 기준이라 별도의 시간대를 설정해야 테스트가 가능했습니다. 그래서 서울로 시간대를 설정하고 진행했습니다. 이외에는 별도로 어려운 내용이 없어 보이네요.

@override_settings(TIME_ZONE='Asia/Seoul')
def test_with_datetimefield(self):
    django = Club.objects.create(name='Club Django')
    Note.objects.create(
        note='Django Tips',
        club=django,
    )

    dumpscript_path = './django_extensions/scripts'

    os.mkdir(dumpscript_path)
    open(dumpscript_path + '/__init__.py', 'w').close()  # for python 2.7

    # This script will have a dateutil codes.
    # e.g. importer.locate_object(...,
    # 'date_joined': dateutil.parser.parse("2019-05-20T03:32:27.144586+09:00")
    with open(dumpscript_path + '/test.py', 'wt') as test:
        call_command('dumpscript', 'django_extensions', stdout=test)

    # Check dumpscript without exception
    call_command('runscript', 'test')

    # Delete dumpscript
    shutil.rmtree(dumpscript_path)

    # Check if Note is duplicated
    self.assertEqual(Note.objects.filter(note='Django Tips').count(), 2)

버그 패치 코드와 함께 테스트 케이스를 만들어 pull request를 만들었습니다. (모든 코드는 아래 링크에서 확인 가능합니다.)

이 pull request는 리뷰 후 병합되었고 아직 정식 버전으로 릴리즈되지는 않았습니다. 현재(2019년 6월 30일) 버전인 2.1.9 다음 버전에 포함될 예정입니다.

마치며

사실 이 PR은 상당히 오래 전부터 작업이 진행되었습니다. 설 연휴 때 시작했거든요. ^^; 피플펀드의 업무와 개인적인 게으름으로 차일피일 미루다 6월이 다 되어서야 병합이 되었습니다. 처음에는 버그 수정의 방향을 잘 잡지 못 하고 갈팡질팡하기도 했거든요. 그래도 그대로 닫아버리지 않고 마무리한 것이 다행이라고 생각합니다. 오픈소스 생태계의 도움이 없었으면 불가능했을 것입니다.

끝까지 리뷰해주며 마무리되기까지 도움을 준 trbs, 자기 PR이 아님에도 코드에 도움을 준 MRigal에게도 감사의 말씀을 전합니다.

  1. dumpscript 커맨드의 코드는 파이썬 코드를 직접 생성해내는 코드가 주된 내용입니다. 상당히 흥미로우니 읽어보는 것을 추천합니다. 

  2. 이것은 django-extensions 코드의 버그라기보다 pytz 라이브러리에서 __repr__ 메소드를 더 세심하게 구현했으면 어땠을까 하는 아쉬움이 있습니다. 

  3. 만약 django settingsUSE_TZ=False 설정이 되어있다면 UTC 변환 시각이 아닌 프로젝트의 naive datetime이 그대로 저장됩니다.