English
Youngkwang Yang

기록을 위한 개인 기술 블로그

목차

pip install 한 줄이 실제로 하는 일

Tooling

pip install requests 를 치고 엔터를 누르면 몇 초 동안 로그가 스크롤된다. 그동안 pip는 인덱스에서 파일을 찾고, 어떤 버전을 깔지 정하고, 필요하면 소스를 빌드하고, 마지막으로 파일을 디스크에 배치한다. 평소엔 눈에 안 띄는 이 과정을 단계별로 느리게 돌려본다.

yesnopip install Xresolve: choose versionsprebuilt wheelfor this platform?download wheeldownload sdistbuild wheel inisolated envinstall: unpackinto site-packages

pip 문서는 이 흐름을 네 단계로 정리한다. 요구사항 식별, 의존성 해석, 휠 빌드, 설치다. 아래 섹션은 이 순서를 따라간다.

무엇을 받는가: wheel과 sdist

pip가 인덱스에서 받아오는 파일은 크게 두 종류다. wheel과 sdist다.

sdist(source distribution)는 소스 코드를 담은 tar 아카이브다. 빌드가 안 된 상태라, 설치하려면 먼저 빌드 단계를 거쳐야 한다. wheel은 이미 빌드가 끝난 zip 아카이브다. 안에는 설치될 파일과 메타데이터만 들어 있어서, 올바른 위치로 옮기기만 하면 설치가 끝난다. 그래서 다운로드가 작고 설치가 빠르다.

둘 다 PyPI에 올라와 있으면 pip는 이 플랫폼에 맞는 wheel을 우선한다. wheel이 있으면 빌드 과정을 통째로 건너뛴다. wheel이 없을 때만 sdist를 받아서 빌드한다.

순수 파이썬 패키지라도 배포자는 sdist와 wheel을 둘 다 올리는 게 권장된다. wheel이 없는 플랫폼을 위한 대비책이자, 소스 코드의 보관본 역할을 하기 때문이다.

버전을 고르는 일: 백트래킹 리졸버

받을 파일을 정하려면 먼저 어떤 버전을 깔지 정해야 한다. 이게 의존성 해석이다. 버전 20.3부터 pip는 백트래킹이 가능한 리졸버를 쓴다.1

동작은 이렇다. pip는 설치할 버전을 일단 가정한다. 이어서 그 가정이 맞는지 확인한다. 가정이 틀렸다고 판단되면 되돌아가서(백트래킹) 지금까지의 선택 일부를 버리고 다른 경로를 고른다. 이 알고리즘은 resolvelib 라이브러리 위에 올라가 있고, 제약 전파로 탐색 공간을 쳐내면서 후보를 좁힌다.

backtrackpick A==2.0A needs C<2pick B==3.0B needs C>=2:conflictdrop A==2.0,try A==1.9

설치 로그에서 같은 패키지가 여러 버전 다운로드되는 걸 본 적이 있다면, 그게 바로 백트래킹이다. pip가 후보 버전을 하나씩 시험하면서 내려받기 때문이다. 버그가 아니라 파이썬 의존성 해석이 원래 그렇게 동작하는 것이다.

sdist를 wheel로: PEP 517과 빌드 격리

받은 게 sdist라면 빌드가 필요하다. 이 빌드가 어떻게 굴러가는지는 두 개의 표준이 정한다. PEP 518과 PEP 517이다.

PEP 518은 pyproject.toml 이라는 파일과 그 안의 [build-system] 테이블을 도입했다.2 requires 키에 빌드에 필요한 도구를 적어 둔다. 이 파일은 정적인 TOML 이라, pip는 프로젝트 코드를 실행하지 않고도 무엇을 먼저 깔아야 하는지 읽을 수 있다.

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

PEP 517은 같은 테이블에 build-backend 키를 더하고, 백엔드가 노출해야 할 함수를 정의했다.3 여기서 빌드는 두 역할로 나뉜다. 개발자가 부르는 프런트엔드(pip 같은 도구)와, 실제 산출물을 만드는 백엔드(hatchling, flit_core 같은 라이브러리)다. 계약이 표준화돼 있으니, 어떤 프런트엔드든 어떤 백엔드든 조합할 수 있다.

빌드는 격리된 환경에서 일어난다. pip는 임시 디렉터리에 빌드용 의존성만 설치하고 그걸 sys.path 에 얹어 빌드를 돌린다. 이렇게 하면 빌드에만 필요한 도구가 실제 런타임 환경을 오염시키지 않고, 빌드가 끝나면 함께 사라진다.

wheel이 풀리는 순간: dist-info와 콘솔 스크립트

이제 손에 wheel이 있다. 설치는 사실상 이 zip을 올바른 자리에 푸는 일이다.

wheel 안에는 실제 코드가 담긴 패키지 디렉터리와, 메타데이터를 담은 이름-버전.dist-info/ 디렉터리가 들어 있다. pip는 이 트리를 site-packages 아래로 옮긴다.

mypkg-1.0-py3-none-any.whl(a zip)mypkg/ (*.py)mypkg-1.0.dist-info/site-packages/mypkg/site-packages/mypkg-1.0.dist-info/entry_points.txtbin/mycli (wrapper)

dist-info 디렉터리에는 최소한 세 파일이 있다. 패키지 정보를 담은 METADATA, wheel 포맷 버전을 적은 WHEEL, 그리고 RECORD 다. RECORD 는 설치된 거의 모든 파일의 목록과 각 파일의 해시를 담는다. 자기 자신을 뺀 모든 파일이 해시와 함께 기록되므로, 나중에 무결성 확인이나 삭제에 이 목록이 그대로 쓰인다.4

명령줄 도구를 제공하는 패키지는 dist-info 안의 entry_points.txtconsole_scripts 를 적어 둔다. 각 항목은 실행할 함수를 모듈:함수 형태로 가리킨다.

[console_scripts]
mycli = mypkg.cli:main

이걸 아는 설치 도구는 항목마다 명령줄 래퍼를 하나씩 만들어 준다. 그래서 설치 후에 셸에서 mycli 를 바로 칠 수 있게 된다. 스크립트 본문이 wheel 안에 통째로 들어 있는 게 아니라, 함수를 가리키는 문자열로만 적혀 있고 래퍼는 설치 시점에 생성된다.

두 번 돌려도 같게: 캐시와 해시와 재현성

같은 명령을 두 번 돌리면 두 번째는 훨씬 빠르다. pip가 빌드한 wheel과 받은 파일을 캐시하기 때문이다. 다음 설치는 네트워크나 빌드를 건너뛰고 캐시에서 바로 가져온다.5

하지만 빠른 것과 재현되는 것은 다르다. pip install requests 는 오늘과 내일 서로 다른 버전을 깔 수 있다. 그 사이 새 버전이 올라오면 그렇다. 재현 가능한 설치를 원하면 버전을 못 박아야 한다. pip freeze 는 최상위 패키지뿐 아니라 그 아래 전이 의존성까지 전부 고정한 requirements 파일을 만들어 준다.

한 걸음 더 나가면 해시가 있다. requirements 파일의 각 줄에 --hash 를 붙이면, pip는 내려받은 파일의 해시가 적힌 값과 일치하는지 검사한다. 원격 변조나 전송 중 손상을 막기 위한 장치다.

requests==2.32.3 \
  --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6

마무리

pip install 한 줄은 사실 작은 파이프라인이다. 버전을 고르고(해석), 무엇을 받을지 정하고(wheel이냐 sdist냐), 필요하면 격리된 환경에서 굽고(PEP 517), 마지막으로 zip을 site-packages에 푼다(설치). 로그가 빠르게 지나가서 안 보일 뿐, 매번 이 네 단계가 순서대로 돈다.

이 구조를 알아 두면 실전에서 바로 쓸모가 있다. 설치가 느리고 같은 패키지가 여러 번 받아지면 리졸버가 백트래킹 중인 것이고, 빌드에서 터지면 sdist를 굽다 실패한 것이며, 명령이 안 잡히면 console_scripts 래퍼가 안 만들어진 것이다. 로그의 각 줄이 어느 단계인지 짚이기 시작한다.

참고

Footnotes

  1. pip 문서, Dependency Resolution

  2. PEP 518, Specifying Minimum Build System Requirements

  3. PEP 517, A build-system independent format for source trees

  4. Python Packaging User Guide, Binary distribution format

  5. pip 문서, Caching