시작

피플펀드 테크스터디를 마무리하는 시점에서 마지막 프로젝트로 오픈소스 기여 프로젝트를 선택하였습니다. 피플펀드 역시 오픈소스의 힘을 빌리고 있고 그렇기 때문에 오픈소스 생태계의 구성원으로서 직접 기여하는 작업을 해보면 어떨까 하는 생각이 들었기 때문입니다.

이왕이면 지금 사용하고 있는 django 프레임워크와 관련된 프로젝트면 좋겠다는 생각이 들어서 github에서 django 관련 프로젝트에 대한 이슈 헌팅을 진행했습니다. 이슈 헌팅의 기준은 다음과 같이 생각해두었습니다.

  • 처음으로 기여하는 프로젝트이기 때문에 간단한 이슈들을 별도로 정리하고 있는 프로젝트
  • 현재도 활발하게 운영 중인 프로젝트
  • 기여 방법에 대한 문서가 있음
  • github의 pull request 방식으로 기여할수 있는 프로젝트

django-extensions

제가 지금까지 본 오픈소스 프로젝트 중에서 위 기준을 가장 만족하고 있었던 프로젝트는 pandas였습니다. 하지만 django 관련 프로젝트를 해보고 싶었기 때문에 검색 끝에 django-extensions를 목표로 잡았습니다. django-extensions는 django 개발에 도움을 주기 위한 패키지입니다. 아주 유명하죠. django 개발에 쓸모있는 커맨드들을 제공하기 때문에 개발 속도 향상에 큰 기여를 합니다. 예를 들어 아래와 같은 커맨드들이 있습니다.

  • shell_plus: django shell 커맨드를 실행할 때 데이터베이스 모델들을 자동으로 로딩합니다.
  • generate_secret_key: django secret key를 새로 생성합니다.
  • reset_db: 데이터베이스를 삭제(drop)하고 새로 생성합니다.

django-extensions는 github에 저장소가 있습니다. 저장소에 올라온 이슈 중에서 Easy Pickings 태그를 찾아보았고 비교적 최근의 것을 작업해보기로 결심했습니다.

issue

이슈의 내용은 간단했습니다. pipchecker 커맨드는 django 프로젝트의 requirements 패키지 중에 새로운 버전이 있는 패키지들을 찾아서 알려줍니다. pip list -o와 유사하지만 가상 환경의 패키지가 아닌 requirements의 패키지라는 점이 다릅니다. 현재의 pipchecker 커맨드는 requirements 내 패키지 이름에 대한 기능은 문제가 없지만 github 저장소에 대한 새로운 버전 찾기는 예외를 발생했습니다.

Package                       Version
----------------------------- ----------
django-extensions             2.1.3

$ cat r.txt
git+https://github.com/jmrivas86/django-json-widget

$ venv/bin/python -B manage.py pipchecker -r r.txt
Traceback (most recent call last):
  File "manage.py", line 22, in <module>
    execute_from_command_line(sys.argv)
  File ".../venv/lib/python3.6/site-packages/django/core/management/__init__.py", line 381, in execute_from_command_line
    utility.execute()
  File ".../venv/lib/python3.6/site-packages/django/core/management/__init__.py", line 375, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File ".../venv/lib/python3.6/site-packages/django/core/management/base.py", line 316, in run_from_argv
    self.execute(*args, **cmd_options)
  File ".../venv/lib/python3.6/site-packages/django/core/management/base.py", line 353, in execute
    output = self.handle(*args, **options)
  File ".../venv/lib/python3.6/site-packages/django_extensions/management/utils.py", line 59, in inner
    ret = func(self, *args, **kwargs)
  File ".../venv/lib/python3.6/site-packages/django_extensions/management/commands/pipchecker.py", line 109, in handle
    self.check_github()
  File ".../venv/lib/python3.6/site-packages/django_extensions/management/commands/pipchecker.py", line 294, in check_github
    print("{pkg_info:40} {msg}".format(pkg_info=pkg_info, msg=msg))
TypeError: unsupported format string passed to NoneType.__format__

특이하게도 Traceback을 보니 문자열에 None이 존재한다는 내용이었습니다. 어떤 변수가 None인 채로 넘어왔는지 확인해보아야만 합니다.

bug patch

django-extensions 저장소를 로컬로 가져와 디버깅을 해보니 위 예외는 아래 코드에서 발생하고 있었습니다. 아래 코드는 pip 세션을 만들어 패키지들의 이름과 저장소 링크를 생성하는 코드입니다.

# django_extensions/management/commands/pipchecker.py
with PipSession() as session:
    for filename in req_files:
        for req in parse_requirements(filename, session=session):
            # url attribute changed to link in pip version 6.1.0 and above
            if LooseVersion(pip.__version__) > LooseVersion('6.0.8'):
                self.reqs[req.name] = {  # 버그 발생
                    "pip_req": req,
                    "url": req.link,
                }

버그는 패키지가 github 저장소인 경우에 req.name 값이 None이기 때문에 일어나고 있었습니다. python에서 None은 딕셔너리의 키로도 사용할 수 있기 때문에 바로 예외가 발생하지는 않았고 이후 정보를 출력할 때 예외가 발생하는 상황이었습니다. req를 디버깅한 결과 req.link.filename에서 패키지 이름을 알 수 있었기 때문에 아래와 같이 수정하였습니다. 수정 후 로컬에서 실행해보니 버그가 발생하지 않았습니다!

# django_extensions/management/commands/pipchecker.py
with PipSession() as session:
    for filename in req_files:
        for req in parse_requirements(filename, session=session):
            # 버그 수정 코드
            name = req.name if req.name else req.link.filename
            # url attribute changed to link in pip version 6.1.0 and above
            if LooseVersion(pip.__version__) > LooseVersion('6.0.8'):
                self.reqs[name] = {  # 버그 수정에 따른 변경
                    "pip_req": req,
                    "url": req.link,
                }

test case

이 버그가 다시 발생하지 않도록 테스트 케이스를 만들었습니다. 이슈에도 need test case 태그가 있었기 때문이기도 하고요. 테스트 케이스는 django에서 하는 방식과는 조금 달랐는데 django 내부 로직이 아니라 패키지를 관리하는 pip에 대한 테스트 케이스이기 때문이었습니다. subprocess를 이용해 직접 pip를 실행하는 등 django 외부에서 처리하는 작업에 django 커맨드를 적용한 후 어떤 결과가 나오는지 확인하는 로직이 되었습니다.

def test_pipchecker_with_github_url_requirement(self):
    requirements_path = './requirements.txt'
    out = StringIO()

    # 임의로 requirements 생성
    f = open(requirements_path, 'wt')
    f.write('git+https://github.com/jmrivas86/django-json-widget')
    f.close()

    # pip install
    subprocess.call([sys.executable, '-m', 'pip', 'install', 'django-json-widget'])
    if sys.version_info.major == 3:
        pip._vendor.pkg_resources = importlib.reload(pip._vendor.pkg_resources)
    else:
        # Python 2.7에서는 importlib를 쓰지 않음
        pip._vendor.pkg_resources = reload(pip._vendor.pkg_resources)  # noqa

    # pipchecker 실행
    # 출력은 StringIO() 객체에 저장
    call_command('pipchecker', '-r', requirements_path, stdout=out)

    value = out.getvalue()

    # pip uninstall
    subprocess.call([sys.executable, '-m', 'pip', 'uninstall', '--yes', '-r', requirements_path])
    os.remove(requirements_path)

    self.assertTrue(value.endswith('repo is not frozen\n'))

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

이 pull request는 리뷰 후 병합되어 django-extensions 2.1.5 버전부터 적용되어 있습니다. 만약 2.1.5 버전의 django-extensions를 사용하고 있으시다면 피플펀드에서 기여한 코드를 확인하실 수 있을 것입니다. 때때로 저장소 운영자의 혹독한(?) 리뷰를 받아야 할 경우도 있지만 이번 경우는 비교적 순조로웠네요.

마치며

오픈소스는 전세계의 개발자들의 참여로 이루어지고 있습니다. 수많은 공헌자들이 없었더라면 django와 같은 웹 프레임워크도, django-extensions와 같은 편리한 도구도 나오지 않았을 것입니다. 모두가 각자의 버그로 너무나 힘든 시간을 보내고 있었겠지요. 하지만 제 코드를 보시면 알 수 있듯이 작은 노력으로도 충분히 도움이 될 수 있습니다. 혹시 패키지를 사용하다가 버그나 불편한 점을 발견했다면 직접 기여해보시는 것이 어떨까요? :)