setup.py 멈춰!

Image not Found

파이썬 코드를 다른 사람도 사용할 수 있게 공유하거나, 불필요한 파일들을 제거하고 프로젝트를 배포하려면 패키징을 하는것이 가장 효율적입니다.

파이썬은 나름 긴 역사와함께 패키징 또한 여러 방식을 거쳐 발전하고있습니다. 이 블로그에서는 작성 시점에서 가장 권장되고 표준인 패키징 방식을 설명하려고합니다.


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

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


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

setup.py 가 존재한다면 legacy.
최종 패키징을 python setup.py ...로 시작되는 커맨드를 사용하고있다면 legacy.
Poetry를 사용하고있다면 legacy가 아닙니다. 다만 부록 을 참고하면 배포를 좀 더 쉽게 만들 수 있습니다.


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가 발생할것이고, 파일을 열어 depdendency를 확인한 후 직접 설치해야합니다.


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

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 .

패키지 메타데이터는?

위의 설정만으로 패키징이 끝나는것은 아닙니다. 필수 메타데이터인 패키지 이름, 버전, 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를 지원하면 아래 포맷으로 메타데이터를 작성하면 어떤 툴을 쓰더라도 상호 교차적으로 사용할 수 있다.

[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

post-thumb

그리디 알고리즘을 이용한 중복 콘텐츠 클러스터링

버즈빌이 제공하는 서비스 중 하나는 버즈빌과 제휴를 맺은 여러 콘텐츠 퍼블리셔들의 콘텐츠를 크롤링하여 유저들에게 서빙하는 것이 있습니다. 이렇게 크롤링한 콘텐츠들은 비슷한 내용을 가지는 중복 콘텐츠가 있기 마련입니다. 특히 뉴스 타입의 콘텐츠를 제공하는 퍼블리셔들은 그 …

Read Article
post-thumb

우리 팀의 OKR은 괜찮을까요

존 도어의 OKR 혹은 그외 유명한 OKR 책들을 읽어보면 왠지 쉽게 적용할 수 있을 것 같은 느낌이 듭니다. 간혹 Objective와 Key Result 라는 개념을 가지고 무슨 책까지 쓰나 생각하시는 분들도 있을 것 같습니다. 헌데 배운대로 열심히 적용하다보면 비슷 …

Read Article