setup.py 멈춰!

Image not Found

파이썬 코드를 다른 사람도 사용할 수 있게 공유하거나, 불필요한 파일들을 제거하고 프로젝트를 배포하려면 패키징을 하는 것이 가장 효율적입니다. 파이썬은 나름 긴 역사와 함께 패키징 또한 여러 방식을 거쳐 발전하고 있습니다. 이 블로그에서는 파이선 패키징의 문제점 2개를 설명하고, 작성 시점에서 가장 권장되고 표준인 패키징 방식을 설명하려고 합니다.


패키징에 어떤 역사가 있었을까?

제가 알고 있던 역사를 정리하려고 찾아보니 좋은 글이 있어서 그 글을 소개하는 것으로 대체하려고 합니다.
https://ryanking13.github.io/2021/07/11/python-packaging.html


나는 지금 legacy를 사용하는 건가?

다음 사항에 포함된다면 legacy를 사용하고 있다고 볼 수 있습니다.

  • setup.py 존재
  • 최종 패키징을 python setup.py …로 시작되는 커맨드 사용 중

Poetry를 사용하고 있다면 legacy가 아닙니다. 다만 아래 부록을 참고하면 배포를 좀 더 쉽게 만들 수 있습니다.


문제 1: setup.py가 없으면 패키징이 안된다.

setup.py는 뭐가 문제인가?

대체재가 없다

파이썬에서 패키지 포맷은 Binary Distribution인 wheel과 Source Distribution으로 이미 표준이 있습니다. 파일 이름과 위치에 맞춰서 넣기만 하면 setuptools나 Poetry로 패키징한 패키지와 동일하게 설치할 수 있습니다. 하지만 패키징 자체는 표준이 없고 대신 setuptools와 setup.py가 사실상 표준으로 사용되고 있었고, setuptools는 third party library입니다. setup.py를 읽기 위해서는 setuptools 패키지가 필요하기 때문에 무슨 패키징 툴을 개발하든 setuptools를 의존해야 합니다.

build-time dependency 관리는 누가?

runtime dependency는 패키지에 적혀있기 때문에 pip이 알아서 같이 설치를 해줍니다. 만약 패키징할 때 setup.py에서 특정 라이브러리를 import 해서 버전 관리를 따로 한다면? 해당 패키지 설치 없이 python setup.py는 당연히 ImportError가 발생할 것이고, 파일을 열어 dependency를 확인한 후 직접 설치해야 합니다.


해결방법: 스크립트 대신 설정 파일

setup.py 스크립트를 사용했을 때 코드 실행 때문에 위와 같은 의존성 문제가 발생한다면, 스크립트를 없애자!

pyproject.toml

PEP-518 에서 build-time dependency를 선언적으로 관리하기 위해 추가된 파일.
최근에는 용도가 확장돼서 pytest, mypy, 다양한 linter 등 다양한 툴들의 설정 파일과 project metadata를 저장하는 용도로 사용되고 있습니다.

예시

setuptools 를 사용해서 wheel 패키지를 만들 때

[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

poetry를 사용할 때

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

위의 예시 내용이 pyproject.toml에 적혀있고 PEP-517 를 만족하는 패키징 툴이 있다면, setuptools나 poetry가 없는 환경에서도 필요한 build system과 dependency들을 설치하고 패키징하는 것을 자동화할 수 있습니다.
찾아보기론 현재 PEP-517 만족하는 툴은 buildpep517 인데, 후자는 deprecate 되었고 전자를 대부분 사용하는 것 같습니다. Poetry 또한 PEP-517을 지원하는데, 테스트해보니 backend가 poetry인 경우만 동작하는 것으로 보입니다.

build 의 경우 아래의 두 줄로 패키징할 수 있습니다. (poetry 또는 setuptools 등 backend의 사전 설치 불필요)

pip install build
python -m build .

문제 2: 메타데이터에 대한 표준이 없다.

패키지 메타데이터는?

위의 설정만으로 패키징이 끝나는 것은 아닙니다. 필수 메타데이터인 패키지 이름, 버전, dependency 이외에도 파이썬 패키지에는 readme, tag, entrypoint 등 다양한 메타데이터를 심을 수 있습니다. 이 부분 또한 표준화되어있지만, 아쉽게도 비교적 최근에 표준화되어서 (PEP-621: 2021/3/1에 Final로 변경) 아직은 주요 빌드툴에서 지원되지 않습니다.

build system status link
setuptools 논의 중 https://github.com/pypa/setuptools/issues/1688
Poetry 논의 중 https://github.com/python-poetry/poetry/issues/3332
Flit 지원 https://flit.readthedocs.io/en/latest/pyproject_toml.html#pyproject-toml-project

그 때문에 각 빌드툴 specific한 포맷을 따라서 메타데이터를 작성해야 합니다. (setuptools - setup.cfg, Poetry - pyproject.toml[tool.poetry])

아래는 각 독자 규격 샘플과 문서를 나열했고, 마지막은 미래에 모든 빌드툴 공통으로 쓰일 표준 포맷 샘플을 작성했습니다.

setuptools

공식 문서: https://setuptools.readthedocs.io/en/latest/userguide/declarative_config.html#declarative-config

setup.py에 있던 모든 필드를 ini 포맷의 setup.cfg에 선언적인 방식으로 기록합니다.

[metadata]
name = my_package
version = attr: src.VERSION
description = My package description
long_description = file: README.rst, CHANGELOG.rst, LICENSE.rst
keywords = one, two
license = BSD 3-Clause License
classifiers =
    Framework :: Django
    License :: OSI Approved :: BSD License
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5

[options]
zip_safe = False
include_package_data = True
packages = find:
scripts =
    bin/first.py
    bin/second.py
install_requires =
    requests
    importlib; python_version == "2.6"

[options.package_data]
* = *.txt, *.rst
hello = *.msg

[options.entry_points]
console_scripts =
    executable-name = package.module:function

[options.extras_require]
pdf = ReportLab>=1.2; RXP
rest = docutils>=0.3; pack ==1.1, ==1.3

[options.packages.find]
exclude =
    src.subpackage1
    src.subpackage2

[options.data_files]
/etc/my_package =
    site.d/00_default.conf
    host.d/00_default.conf
data = data/img/logo.png, data/svg/icon.svg
fonts = data/fonts/*.ttf, data/fonts/*.otf

Poetry

공식 문서: https://python-poetry.org/docs/pyproject/

setuptools와 다르게 최근에 개발됐기 때문에 처음부터 setup.py대신 pyproject.toml을 사용했습니다. 다만 PEP-621가 accept 되기 전에 개발됐기 때문에 [project]대신 [tool.poetry]에 PEP-621과는 약간 다른 독자 포맷으로 기록합니다.

[tool.poetry]
name = "poetry"
version = "1.2.0a2"
description = "Python dependency management and packaging made easy."
authors = [
    "Sébastien Eustace <sebastien@eustace.io>"
]
license = "MIT"

readme = "README.md"

include = [
    { path = "tests", format = "sdist" }
]

homepage = "https://python-poetry.org/"
repository = "https://github.com/python-poetry/poetry"
documentation = "https://python-poetry.org/docs"

keywords = ["packaging", "dependency", "poetry"]

classifiers = [
    "Topic :: Software Development :: Build Tools",
    "Topic :: Software Development :: Libraries :: Python Modules"
]

[tool.poetry.build]
generate-setup-file = false

# Requirements
[tool.poetry.dependencies]
python = "^3.6"

poetry-core = "^1.1.0a6"
cleo = "^1.0.0a4"
crashtest = "^0.3.0"
requests = "^2.18"
cachy = "^0.3.0"
requests-toolbelt = "^0.9.1"
cachecontrol = { version = "^0.12.4", extras = ["filecache"] }
pkginfo = "^1.5"
html5lib = "^1.0"
shellingham = "^1.1"
tomlkit = ">=0.7.0,<1.0.0"
pexpect = "^4.7.0"
packaging = "^20.4"
# temporarily clamped due to https://github.com/pypa/pip/issues/9953
virtualenv = ">=20.4.3,<20.4.5"
keyring = ">=21.2.0"
entrypoints = "^0.3"
importlib-metadata = {version = "^1.6.0", python = "<3.8"}
dataclasses = {version = "^0.8", python = "~3.6"}

[tool.poetry.dev-dependencies]
pytest = "^6.2"
pytest-cov = "^2.8"
pytest-mock = "^3.5"
pre-commit = { version = "^2.6", python = "^3.6.1" }
tox = "^3.0"
pytest-sugar = "^0.9"
httpretty = "^1.0"
zipp = { version = "^3.4", python = "<3.8"}
deepdiff = "^5.0"

[tool.poetry.scripts]
poetry = "poetry.console.application:main"

미래: PEP-621과 Flit

공식 문서

setuptools와 Poetry가 PEP-621를 지원하면 아래 포맷으로 메타데이터를 작성하면 어떤 툴을 쓰더라도 상호 교차적으로 사용할 수 있습니다. 하지만 위의 표처럼 setuptools와 Poetry는 아직 표준을 따르도록 구현되지 않았기 때문에 아쉽게도 지금은 사용할 수 없습니다. 지금 아래의 포맷으로 패키징을 하고싶다면 유일한 선택지는 Flit 입니다.

[project]
name = "spam"
version = "2020.0.0"
description = "Lovely Spam! Wonderful Spam!"
readme = "README.rst"
requires-python = ">=3.8"
license = {file = "LICENSE.txt"}
keywords = ["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
authors = [
  {email = "hi@pradyunsg.me"},
  {name = "Tzu-Ping Chung"}
]
maintainers = [
  {name = "Brett Cannon", email = "brett@python.org"}
]
classifiers = [
  "Development Status :: 4 - Beta",
  "Programming Language :: Python"
]

dependencies = [
  "httpx",
  "gidgethub[httpx]>4.0.0",
  "django>2.1; os_name != 'nt'",
  "django>2.0; os_name == 'nt'"
]

[project.optional-dependencies]
test = [
  "pytest < 5.0.0",
  "pytest-cov[all]"
]

[project.urls]
homepage = "example.com"
documentation = "readthedocs.org"
repository = "github.com"
changelog = "github.com/me/spam/blob/master/CHANGELOG.md"

[project.scripts]
spam-cli = "spam:main_cli"

[project.gui-scripts]
spam-gui = "spam:main_gui"

[project.entry-points."spam.magical"]
tomatoes = "spam:main_tomatoes"

부록: Poetry 프로젝트에서 배포를 좀 더 쉽게하기

TL;DR

Dockerfile이나 CI pipeline에서는 potery 커맨드 대신 python -m build 를 사용하자


Poetry 를 사용하면 패키징 이외에도 virtual env 관리, dependency resolving, 패키지 업로드 등 개발에 필요한 기능들을 한 번에 해결할 수 있습니다. 다만 Dockerfile이나 CI pipeline에서 패키징을 위해 poetry 커맨드를 사용하려고 하면 위의 기능을 모두 설치해야 합니다. 여기서 사소한 문제는 poetry 패키지의 dependency가 많아서 poetry 패키지를 설치하는 데만 시간이 좀 걸린다는 것입니다.
특히 alpine 이미지를 사용할 경우 복잡해지는데, dependency 중에는 C 코드를 포함하는 패키지가 (cryptography) 있어서 각종 빌드 프로그램들을 설치하고 컴파일도 해줘야 합니다 (현재 alpine은 wheel 포맷을 지원하지 않기 때문). 그 때문에 로컬의 호스트 머신에서 사용할 게 아니라면 Poetry로 관리한다고 하더라도 위에 설명한 build 커맨드를 사용하는 것이 가장 편리합니다.

아래는 Dockerfile을 예시로 작성했습니다.

Before

FROM python:3.9-alpine
WORKDIR /src
COPY . .
RUN apk --update add gcc musl-dev openssl-dev libffi-dev cargo
# 아래가 rust, c 코드 컴파일로 몇분이 소요됨
RUN pip install poetry
RUN poetry build

After

FROM python:3.9-alpine
WORKDIR /src
COPY . .
RUN pip install build
RUN python -m build

You May Also Like

버즈빌, 아마도 당신이 원하던 회사!

지원하기