본문 바로가기

삶은계란 (Diary)/일상

Python 코드 패키징 하기 (1)

블로그에 게시글이 없다고 몸이 쉬는 것은 아니다.

Justheme는 새 업데이트가 앱스토어에 등록 됐고, 새로운 유틸리티 앱인 TintTrobe도 성공적으로 등록돼 서비스 중이다.
최근에는 알고리즘 공부를 하던 스터디 모임에서 마일 스톤이라도 하나 놓을 겸 시작한 작은 프로젝트에 박차를 가하는 중이었다.

언어 중에 가장 문법이 단순하다고 평가받는 Python을 사용하는 모임이었는데,
이를 사용해 간단한 게임을 만들어 보자고 한 게 여기까지 왔다.

 

Coffee Burger Code

Coffee Burger Code

cbc.montaigne.io

팀 이름도 굉장히 희한하지만 모든 작업의 경과나 소식들은 위의 팀 사이트를 참고하면 된다.

좌우간 우리와 같은 이유이든, 프로그래밍을 처음 접한 사람이든 완성시켰다면 이제 이것을 배포하기 위한 방법을 찾아봐야 한다.
물론 깃헙에 소스코드 째로 올려 사용자들이 직접 빌드를 할 있게 하는 것도 방법이지만,
알겠지만 사용자들의 대부분이 일반인이 될 수 있다는 걸 생각하면 매우 조심스럽게 결정해야 하는 부분이다.
따라서 보통은 윈도우의 exe나 리눅스의 exec, 맥의 app 파일로 만들어 아이콘을 눌러 실행할 수 있도록 하는 것이 맞을 텐데,
이런 과정을 Packaging이라고 한다.

이번 글은 Python 3.X와 Pygame 2.X를 사용한 프로젝트를 pyinstaller라는 프로그램을 사용해 맥과 윈도우에서 사용할 수 있도록 Packaging 하는 방법에 대해 정리해 보고자 한다.
'정리'라는 부분에서 감 잡았겠지만 정말 많은 삽질이 있었고, 이런 삽질은 덜 해야 맞다고 생각한다. 😇

프로젝트 설정

실행파일을 만드는 방식은 exe파일 하나만 존재하는 onefile 방식과 필요한 파일들을 전부 경로의 형태로 배포하는 dir 방식이 존재한다.
만약 onfile을 방식으로 배포하고 싶다면 축하드린다. 첫 삽을 뜨게 될 운명이다.

Packaging을 위해 이리저리 찾던 중 확인된 문제는 onefile베포를 감안하고 코드가 짜여 있어야 한다는 점이다.

def load_json(path):
    try:
        with open(resource_path(path, 'r') as temp:
            return json.load(temp)
    except:
        raise Error.DataLoadFailedError

예를 들면 위의 코드는 파라미터로 전달된 경로를 사용해 json 파일을 불러오는 역할을 하는 메서드의 모습이다.
만약 전달되는 path를 임시로 설정한다면

def load_json(path):
    try:
        with open(resource_path('Data/Client_data.json', 'r') as temp:
            return json.load(temp)
    except:
        raise Error.DataLoadFailedError

이런 형태가 될 텐데, 혹시라도 아쉽게도 이런 식으로 코드를 짰다면 해당 부분을 사용하는 부분은 전부 다시 작업해 줘야 한다.
이유는 onefile로 packaging을 하면서 우리가 적어놓은 경로는 의미가 없어지기 때문인데 이를 해결하는 방법은 다음과 같다.

import sys
import os

def resource_path(relative_path):
    """ Get absolute path to resource, works for dev and for PyInstaller """
    try:
        # PyInstaller creates a temp folder and stores path in _MEIPASS
        base_path = sys._MEIPASS
    except Exception:
        base_path = os.path.abspath(".")

    return os.path.join(base_path, relative_path)

바로 위의 함수를 거쳐 사용하는 방법이다.
실행 시 임시로 생성되는 경로에 대응하기 위한 함수로 이를 의미하는 '절대경로(absolute path)'를 앞에 붙여주는 역할을 한다.

def load_json(path):
    try:
        with open(resource_path(f"./{path}"), 'r') as temp:
            return json.load(temp)
    except:
        raise Error.DataLoadFailedError

실제로 사용할 때는 위와 같이 사용하면 된다.
파라미터로 전달된 경로를 절대경로로 변환해 사용하게 된다.
만약 지금처럼 모듈화가 안돼있다면 코드 전체를 훑어 전부 바꿔줘야 한다.

Packaging

준비

기본적으로 pyinstaller는 멀티플랫폼을 지원하지 않는다.
따라서 맥과 윈도우를 동시에 지원하기 위해서는 각각에 맞도록 맥과 윈도우에서 두 번 빌드해야 한다.
다만 이것에도 조건이 있다.

  1. 애플 개발자 계정이 필요하다.
    • 정확히는 '배포'를 원한다면 개발자 계정이 필요하다.
      내 기억이 맞다면 무료 플랜에서는 인증서에 관련된 기능을 지원하지 않으므로 유료 플랜을 사용해야 한다.
      유료플랜은 연간 플랜으로 12만 원 상당의 비용이 들어가니 반드시 필요한 게 아니라면 윈도우만 배포하는 것도 좋은 생각이다.
    • 만약 꼭 맥용 빌드를 시도해야 하겠다면 윈도우보다는 조금 더 복잡하고 단계가 많으니 각오를 좀 하자.
  2. 당연히 맥이 필요하다.
  3. 윈도우도 인증서가 필요하다. 문제는 이쪽은 더 발급이 어렵다.

조건을 만족했거나 결심이 섰다면 pyinstaller를 설치하면 된다.

명령어는 맥이나 윈도우나 동일하다.
'어디다 입력해요?'라고 묻는다면 IDE의 콘솔이 될 수도 있고, 맥의 Terminal이 될 수도 있고, 윈도우의 PowerShell이 될 수도 있다.

공통

다음으로 입력할 명령어는 위와 같다.

 

pyinstaller — PyInstaller 5.13.2 documentation

DESCRIPTION PyInstaller is a program that freezes (packages) Python programs into stand-alone executables, under Windows, GNU/Linux, macOS, FreeBSD, OpenBSD, Solaris and AIX. Its main advantages over similar tools are that PyInstaller works with Python 3.7

pyinstaller.org

다른 많은 명령어나 뜻이 궁금하면 위의 문서를 참고하는 것이 좋고,
간단히 말하면 one-folder, one-file 실행파일을 만들겠다는 의미이다.

이 명령어로 만든 실행파일이 동작하든 말든 상관없다.
우리의 목표는 더 세부적인 설정을 위한 spec 파일이 목적이기 때문이다.

spec 파일

pyinstaller는 명령어 자체에 여러 옵션을 부여해 콘솔만으로도 작업이 가능하지만 조금 더 편하게 spec 파일을 이용하는 방법이 존재한다.

 

Using Spec Files — PyInstaller 5.13.2 documentation

the first thing PyInstaller does is to build a spec (specification) file myscript.spec. That file is stored in the --specpath directory, by default the current directory. The spec file tells PyInstaller how to process your script. It encodes the script nam

pyinstaller.org

pyinstaller가 해당 프로젝트를 packaging 할 때 사용하는 설정 파일과 다름이 없는데,
이것이 필요한 이유는 

우리 프로젝트의 구조가 그리 단순하지 않았기 때문이다.
Terminal이나 Console이 타이핑하기 그리 편한 환경도 아니고, 이걸 다 적고 옵션까지 붙이자니 명령어 가독성이 너무 떨어졌다.

우선 전 단계의 명령어를 잘 입력했다면 프로젝트 폴더에는 '.spec' 파일이 하나 생성된다.
이 파일을 텍스트 뷰어 등으로 열어 수정하기만 하면 된다.

# -*- mode: python ; coding: utf-8 -*-


block_cipher = None


a = Analysis(
    ['main.py'],
    pathex=[],
    binaries=[],
    datas=[('./Assets/*','./Assets'), ('./Data/*', './Data'), ('./Fonts/*', './Fonts'), ('./Module/*', './Module'), ('./Scene/*', './Scene')],
    hiddenimports=['pygame'],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=block_cipher,
    noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name='Tower of Babel',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=False,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
)

완성된 spec 파일은 위와 같다.
신경 써야 할 부분을 짚어 보면

  • Analysis의 첫 번째 파라미터인 ['main.py']는 Packaging 대상이 될 파일이다.
  • datas=[('./Assets/*','./Assets'), ('./Data/*', './Data'), ('./Fonts/*', './Fonts'), ('./Module/*', './Module'), ('./Scene/*', './Scene')]는 필요한 파일들의 내부 경로이다.
    이들은 쌍으로 이루어져 있는데, ('./Assets/*','./Assets')의 앞쪽인 './Assets/*'은 파일의 경로가 되며, 마지막 *은 해당 경로의 모든 파일을 지정하게 된다. 뒤쪽인 './Assets'은 생성할 위치가 된다.
    이 부분으로 프로젝트 내의 모든 모듈파일, 데이터파일, Assets들을 프로그램에서 인식할 수 있게 된다.
  • hiddenimports=['pygame']는 프로그램 실행에 필요한 모듈에 해당한다. 해달 모듈을 실행파일 자체에 탑재하게 되기 때문에 사용자는 해당 모듈을 따로 설치할 필요 없이 실행할 수 있다.

EXE의 경우 실행 파일에 대한 옵션들을 표시한다.

  • name='Tower of Babel'은 실행파일의 이름에 해당한다.
  • console=False은 프로그램을 실행할 때 콘솔을 표시할지에 대한 옵션이다.
  • 이외에 icon에 이미지파일 경로를 전달해 프로그램의 아이콘을 지정할 수 있다.

Packaging (Win)

윈도우는 작업이 벌써 끝나버렸다.

다시 명령어를 실행하기 전에 프로젝트 파일에 이전에 생성된 dist와 build 파일을 삭제해 주고
Packaging에 spec파일을 쓰도록 명령어만 작성해 주면 되는데 다음과 같다.

명령어를 작성하면 dist파일에 exe파일이 생성된 것을 확인할 수 있다.
이 파일은 구글드라이브, 원드라이브, 메일, 드랍박스 그 어느 것이든 전부 바이러스로 탐지하고,
윈도우 방화벽, McAfee, Avast등의 모든 백신이 발광을 하겠지만 사용자가 허용만 한다면 타인의 PC에서 구동 가능하다.

개인을 기준으로 이 이상 할 수 있는 게 없다.
타인의 인증서를 사용해 서명하는 것은 분명한 치팅 행위이며, 개인 인증의 경우엔 추후 해결하는 대로 내용 추가하도록 하겠다.

Packaging (Mac)

굳이 어려운 길을 선택한 그대에게... 🥂

맥도 spec의 작성부터만 조금 다를 뿐, 같은 과정으로 진행하면 된다.
다만 가상환경을 사용하고 있어서인지 맥의 pycharm에서 실행하면 pip나 기타 설치된 모듈을 인식하지 못하는 경우가 많아 Terminal에서 직접 작업한 부분만 달랐다.

알겠지만 프로젝트 폴더에 우클릭해서 쉽게 해당 경로의 터미널을 열 수 있다.

작업에 참고한 자료는 다음의 링크이다

 

OS X Code Signing Pyinstaller.md

GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

 

GitHub - The-Nicholas-R-Barrow-Company-LLC/python3-pyinstaller-base-app-codesigning: Base project example to create an applicati

Base project example to create an application with Python3 and PyInstaller, including all necessary build scripts. - GitHub - The-Nicholas-R-Barrow-Company-LLC/python3-pyinstaller-base-app-codesign...

github.com

대충 요약하자면

  1. Xcode를 설치하고
  2. 애플 개발자 계정을 연결한 뒤
  3. appleid.apple.com로 이동해 App-Specific password를 생성한 뒤
  4. 이걸 사용해 Keychain Access에서 Developer-altool라는 인증서를 생성한다.
  5. 그리고 다시 개발자 계정의 설정으로 이동해 앱의 인증에 사용할 Bundle identifier를 생성한 뒤
  6. 프로젝트 폴더에 entitlements.plist라는 파일을 하나 생성하고
  7. 이들을 사용해 Bundle을 만드는 게 전부다.

어느 하나로는 절대 이해가 안 되니 두 링크를 모두 참고하여 준비작업을 잘해 놓도록 하자.
전부 설명하자니 번역 밖에는 안되기 때문에 하나씩 천천히 따라 하는 걸 추천한다.

# -*- mode: python ; coding: utf-8 -*-


block_cipher = None


a = Analysis(
    ['main.py'],
    pathex=[],
    binaries=[],
    datas=[('./Assets/*','./Assets'), ('./Data/*', './Data'), ('./Fonts/*', './Fonts'), ('./Module/*', './Module'), ('./Scene/*', './Scene')],
    hiddenimports=['pygame'],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=block_cipher,
    noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name='Tower of Babel',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=False,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
)
app = BUNDLE(exe,
             name='TowerOfBabel.app',
             icon=None,
             bundle_identifier=''
           )

맥의 spec은 위와 같다.
맥에서 실행하기 위해서는 실행파일을 Bundle로 바꿔야 하는데, 보통인 .app의 형태의 확장자를 갖는다.
생성된 exe 파일을 사용해서 TowerOfBabel.app으로 Bundle을 만들게 되는데, 이 과정을 정상적으로 끝내기 위해서는 bundle_identifier가 필요하다.

앞에서의 준비작업을 잘 끝냈다면 reverse-url 형태의 bundle identifier가 존재할 것이다.
그걸 문자열의 형태로 추가해 저장해 주면 된다.

이제 윈도우와 동일한 'pyinstaller main.spec'을 입력하면 해당 spec파일을 사용해 앱을 packaging 하면
프로젝트의 dist 폴더에는 두 가지 파일이 생긴다.
우리는 이 중 .app 파일을 사용할 예정이다.

다시 Terminal에 'security find-identity -p basic -v'를 입력하면 다음과 같은 출력이 표시된다.

1) ABC123 "Apple Development: aaronciuffonl@gmail.com ()"
2) XYZ234 "Developer ID Installer: Aaron Ciuffo ()"
3) QRS333 "Developer ID Application: Aaron Ciuffo ()"
4) LMN343 "Developer ID Application: Aaron Ciuffo ()"
5) ZPQ234 "Apple Development: aaron.ciuffo@gmail.com ()"
6) ASD234 "Developer ID Application: Aaron Ciuffo ()"
7) 01010A "Developer ID Application: Aaron Ciuffo ()"
   7 valid identities found

내용은 전부 다를 테니 신경 쓰지 말고,
우리가 추가한 Developer ID Applicaion의 뒤쪽 괄호 안에 표시되는 것이 Bundle identifier의 hash 값이고, 우린 이것을 사용하면 된다.

다시 프로젝트 폴더로 돌아와 새로운 파일을 하나 생성한다.

이름은 entitlements.plist로, 내용은 다음과 같다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <!-- These are required for binaries built by PyInstaller -->
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
        <true/>
        <key>com.apple.security.cs.disable-library-validation</key>
        <true/>
</dict>
</plist>

내용은 전혀 건드릴 필요가 없다.

다시 Terminal로 돌아와

codesign --deep --force --options=runtime --entitlements ./entitlements.plist --sign "HASH_OF_DEVELOPER_ID APPLICATION" --timestamp ./dist/foo.app

를 입력한다. 중간의 HASH_'OF_DEVELOPER_ID APPLICATION'을 위에서 확인한 hash 값으로 대체하고,
맨 마지막 ./dist/foo.app을 우리가 만든 .app 파일로 바꿔준다.
내 경우 ./dist/TowerOfBabel.app이 되겠다.

출력 내용은 replacing exsisting signature이고, 작업은 굉장히 빠르게 끝난다.

자, 이제 맥용도 Packaging이 끝났다.
다른 맥에서 실행하려면 설정의 개인정보 보호 '확인되지 않은 개발자'를 허용해 주면 시간이 조금 걸릴지는 몰라도 실행이 잘 된다.
윈도우에서 exe파일을 실행할 때도 보안 경고가 나타나긴 하지만 문제없이 실행된다.
python 파일을 윈도우용이 아닌 mac용으로 packaging 하고 싶은 사람들에게 도움이 됐으면 한다.

(다시 찾아 올 미래의 나에게도...)


Log

2023.09.16.
윈도우 인증 문제에 관한 코멘트 추가