Notice
Recent Posts
Recent Comments
Link
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

빵공장

Pyinstaller로 만든 프로그램 자동 업데이트 적용기 본문

Python

Pyinstaller로 만든 프로그램 자동 업데이트 적용기

sucream 2022. 4. 23. 00:06

최근 회사에서 파이썬을 이용한 솔루션 개발을 시작하게 됐다.
해당 솔루션 개발에 몇가지 조건이 있었는데, 크게 다음 두가지가 가장 중요했다.

  1. 파이썬으로 개발하고, 독립실행이 가능할 것
  2. 여타 프로그램처럼 새로운 버전이 릴리즈되면 자동으로 업데이트될 것

우선 첫번째 조건은 pyinstaller를 이용했기 때문에 큰 문제는 없던 것 같다.

내 경우에는 2번이 문제였는데, 사내에 별도의 업데이트 서버를 두지 않고 업데이트를 진행하는게 목표였기 때문에 여러가지 방법을 고민했던 것 같다.

내가 찾은 해결책은 다음과 같다.

  1. pyinstaller를 이용해 메인 프로그램 빌드
  2. github REST API를 이용하여 업데이트를 체크하는 파이썬 코드 작성
  3. 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 파일의 Analysisdatas 영역에 다음과 같이 추가해 같이 빌드될 수 있도록 했다.

datas=[
    ('./version', './'),
],

참고로 version 파일에는 향후 해당 버전의

2. github REST API를 이용하여 업데이트를 체크하는 파이썬 코드 작성

내가 만든 프로그램이 자동으로 업데이트되는 로직은 다음과 같다.

  1. 내 프로그램이 있는 레포지토리에 접근할 수 있는 api key 생성
  2. api key를 통해 해당 레포지토리의 최신 릴리즈 값을 가져오기
  3. 최신 릴리즈값이 현재 프로그램 경로 내의 version 파일의 내용과 다르면 신버전이 존재한다고 판단
  4. 데이터를 내려받아 업데이트를 진행(이라고 쓰고 파일들 덮어쓰기)

디테일하게 알아보자.

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% 맞다고 할 순 없다.

누군가에겐 도움이 됐길 바라며 이만.

Comments