빵공장
Pyinstaller로 만든 프로그램 자동 업데이트 적용기 본문
최근 회사에서 파이썬을 이용한 솔루션 개발을 시작하게 됐다.
해당 솔루션 개발에 몇가지 조건이 있었는데, 크게 다음 두가지가 가장 중요했다.
- 파이썬으로 개발하고, 독립실행이 가능할 것
- 여타 프로그램처럼 새로운 버전이 릴리즈되면 자동으로 업데이트될 것
우선 첫번째 조건은 pyinstaller
를 이용했기 때문에 큰 문제는 없던 것 같다.
내 경우에는 2번이 문제였는데, 사내에 별도의 업데이트 서버를 두지 않고 업데이트를 진행하는게 목표였기 때문에 여러가지 방법을 고민했던 것 같다.
내가 찾은 해결책은 다음과 같다.
- pyinstaller를 이용해 메인 프로그램 빌드
github REST API
를 이용하여 업데이트를 체크하는 파이썬 코드 작성pyinstaller
를 이용해 업데이트 체커 빌드
이렇게 하면 별도의 업데이트 서버 없이, 인터넷만 연결되어 있으면 업데이트를 진행할 수 있다!
1. pyinstaller
를 이용한 메인 프로그램 빌드
개발단계는 프로그램이 실행되는 루트 경로를 잘 잡아주는 것만 고려하면 될 것 같다.python program.py
로 실행할 때는 별 문제가 없지만, 향후 pyinstaller로 빌드를 진행하면 경로가 이상하게 잡혀서 파일 및 경로와 관련된 이슈가 생길 수 있다. 이 때 아래 코드를 추가하여 BASE_PATH를 설정할 수 있다. if문을 통해 각 상황에 맞게 path가 정해지기 때문에 유용하게 사용할 수 있다.
import os
import sys
if getattr(sys, 'frozen', False):
application_path = os.path.dirname(sys.executable)
elif __file__:
application_path = os.path.dirname(__file__)
다음으로 pyinstaller를 이용해 빌드할 때, 프로그램의 버전을 비교할 수 있도록 version
이라는 파일을 만들고 .spec
파일의 Analysis
의 datas
영역에 다음과 같이 추가해 같이 빌드될 수 있도록 했다.
datas=[
('./version', './'),
],
참고로 version
파일에는 향후 해당 버전의
2. github REST API
를 이용하여 업데이트를 체크하는 파이썬 코드 작성
내가 만든 프로그램이 자동으로 업데이트되는 로직은 다음과 같다.
- 내 프로그램이 있는 레포지토리에 접근할 수 있는 api key 생성
- api key를 통해 해당 레포지토리의 최신 릴리즈 값을 가져오기
- 최신 릴리즈값이 현재 프로그램 경로 내의
version
파일의 내용과 다르면 신버전이 존재한다고 판단 - 데이터를 내려받아 업데이트를 진행(이라고 쓰고 파일들 덮어쓰기)
디테일하게 알아보자.
2.1. 내 프로그램이 있는 레포지토리에 접근할 수 있는 api key 생성
Github에는 api token을 이용하여 접근할 수 있는 API가 있다. Personal access tokens 페이지에서 권한 및 기한을 지정하고 토큰을 생성할 수 있으며, 레포지토리의 종류에 따라 더 많거나, 적은 권한이 필요하며, 권한에 대한 범위 설명은 여기에서 확인 가능하다.
토큰 생성을 완료하면 다음과 같이 생성된 토큰을 확인할 수 있고, 해당 페이지에 다시 접속하면 토큰값을 두번 다시 확인할 수 없기 때문에, 잘 기억하거나 복사하길 바란다.
2.2. api key를 통해 해당 레포지토리의 최신 릴리즈 값을 가져오기
Github에서 제공하는 REST API와 관련된 내용은 GitHub REST API에서 확인 가능하다.
본 게시글에서는 릴리즈 내용을 확인할 것이기 때문에, Releases의 내용을 이용하도록 한다.
기본 링크는 아래와 같다.
OWNER = 'owner'
REPO = 'repo'
API_SERVER_URL = f"https://api.github.com/repos/{OWNER}/{REPO}"
MY_API_KEY = 'q1w2e3r4' # 노출되면 안됨, 각자의 방법으로 보호하자.
우리에게 필요한 기능은 최신 릴리즈를 가져오는 Get the latest release 기능과 해당 릴리즈의 에셋(업데이트할 데이터)을 가져오는 Get a release asset 기능이다.
에셋을 가져오는 API는 최신 릴리즈를 가져오면 response에서 획득이 가능해서 본 게시글에서는 최신 릴리즈를 가져오는 API만 이용하도록 하겠다.
[GET] /repos/{OWNER}/{REPO}/releases/latest
로 api를 요청하면 결과를 확인할 수 있다.
파이썬의 requests를 이용한 요청 코드는 다음과 같다.
res = requests.get(f"{API_SERVER_URL}/releases/latest", auth=(OWNER, MY_API_KEY)) #
if res.status_code != 200:
print(datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S"), "업데이트 체크 실패")
print(res.json())
다음과 같이 결과가 나오는 것을 확인할 수 있다.(Github 예시)
{
"url": "https://api.github.com/repos/octocat/Hello-World/releases/1",
"html_url": "https://github.com/octocat/Hello-World/releases/v1.0.0",
"assets_url": "https://api.github.com/repos/octocat/Hello-World/releases/1/assets",
"upload_url": "https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}",
"tarball_url": "https://api.github.com/repos/octocat/Hello-World/tarball/v1.0.0",
"zipball_url": "https://api.github.com/repos/octocat/Hello-World/zipball/v1.0.0",
"discussion_url": "https://github.com/octocat/Hello-World/discussions/90",
"id": 1,
"node_id": "MDc6UmVsZWFzZTE=",
"tag_name": "v1.0.0",
"target_commitish": "master",
"name": "v1.0.0",
"body": "Description of the release",
"draft": false,
"prerelease": false,
"created_at": "2013-02-27T19:35:32Z",
"published_at": "2013-02-27T19:35:32Z",
"author": {
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false
},
"assets": [
{
"url": "https://api.github.com/repos/octocat/Hello-World/releases/assets/1",
"browser_download_url": "https://github.com/octocat/Hello-World/releases/download/v1.0.0/example.zip",
"id": 1,
"node_id": "MDEyOlJlbGVhc2VBc3NldDE=",
"name": "example.zip",
"label": "short description",
"state": "uploaded",
"content_type": "application/zip",
"size": 1024,
"download_count": 42,
"created_at": "2013-02-27T19:35:32Z",
"updated_at": "2013-02-27T19:35:32Z",
"uploader": {
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false
}
}
]
}
2.3. 최신 릴리즈값이 현재 프로그램 경로 내의 version 파일의 내용과 다르면 신버전이 존재한다고 판단
필자는 위 결과에서 data = res.json()
기준으로, data['assets'][0]['id']
가 위에서 생성한 version
파일 내의 내용과 다르면 신버전이 존재하는 것으로 인식하기로 하고, 체크하는 코드는 다음과 같다.
with open("version", "r") as f:
now_version = f.read()
if str(res["assets"][0]["id"]) != now_version:
print("====================")
print("업데이트 가능 버전을 발견했습니다.")
print(f'''{res["name"]} / {res["tag_name"]}''') # 해당 릴리즈의 제목과 태그명을 확인할 수 있음
print(f'''{res["body"]}''') # 해당 릴리즈의 내용을 확인할 수 있음
2.4. 데이터를 내려받아 업데이트를 진행(이라고 쓰고 파일들 덮어쓰기)
이후 해당 에셋을 다운받기 위해 res['assets'][0]['url']
를 이용하며 다운받는 코드는 다음과 같다.
download_url = res["assets"][0]["url"]
contents = requests.get(download_url, auth=(username, pat), headers={'Accept': 'application/octet-stream'}, stream=True) # 헤더와 stream을 지정하여 파일을 다운받을 수 있도록 했다.
os.makedirs(os.path.join(application_path, "update"), exist_ok=True) # 업데이트할 파일이 겹치지 않도록 update 폴더 생성
# 다운받은 데이터를 태그명으로 저장
with open(os.path.join(application_path, 'update', f'''{res["tag_name"]}.zip'''), "wb") as f:
for chunk in contents.iter_content(chunk_size=1024*1024):
f.write(chunk)
필자는 신버전을 릴리즈할 때, pyinstaller
로 빌드 후 결과 폴더를 zip으로 압축후 에셋으로 올리고 있다. 따라서 다운받은 zip 파일을 압축해제할 필요가 있었다.
# 압축해제하는 함수
def extract(file_name):
with zipfile.ZipFile(file_name, 'r') as zip_ref:
zip_ref.extractall(os.path.join(application_path, 'update', 'tmp'))
extract(os.path.join(application_path, 'update', f'''{res["tag_name"]}.zip'''))
압축을 해제하고 나면 드디어 업데이트를 진행할 수 있게 된다.
업데이트를 진행하기 전에, 프로그램이 켜져있다면 프로그램을 종료해야 한다. 필자는 psutil을 이용해 메인 프로세스를 체크하고 종료시켰다.
업데이트 진행 시 업데이트 체커 프로세스가 실행중이라는 점을 명시해야 하며, 같은 경로에 존재할 시 해당 파일은 업데이트에 포함시키지 않는 편이 좋다.
shutil.copytree(os.path.join(application_path, "update", 'tmp'), application_path, ignore=shutil.ignore_patterns("update-check.exe",), dirs_exist_ok=True) # update/tmp에 압축해제된 데이터를 루트에 복사하며, update-check.exe는 복사하지 않음
# 새로운 버전을 입력해 줌
with open(os.path.join(application_path, "version"), "w") as f:
f.write(str(res["assets"][0]["id"]))
shutil.rmtree(os.path.join(application_path, "update")) # 업데이트 임시 폴더 삭제
print(datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S"), "업데이트 완료")
os.startfile(os.path.join(application_path, "MAIN.exe")) # 업데이트 완료 후 메인 프로그램을 다시 실행시켜줌
3. pyinstaller
를 이용해 업데이트 체커 빌드
딱히 특별할 건 없다..빌드하고 exe를 메인 프로그램 경로에 같이 넣고 배포하면 될 것 같다.
결론
처음엔 좀 복잡하지만 세팅을 해주면 향후 신버전 개발 시, github에 릴리즈만 등록하면 자동으로 업데이트가 진행된다. 또한 별도의 서버가 필요없기 때문에 편리한 것 같다. 특정 태그에만 반응하도록 하는 등 다양하게 사용할 수 있을 것 같다. 물론 api key 관리에 대한 부분을 고려해야 하며, 이 방법이 100% 맞다고 할 순 없다.
누군가에겐 도움이 됐길 바라며 이만.