Coverage for graphica/gui/updater.py: 97.7%

73 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""アプリの中からの更新。配布の形を見分け、Windows のインストーラー版はインストーラーを落として確かめ、実行する。 

2 

3ほかの形(zip・macOS・pip・ソース)は、それぞれに合った案内だけにする(署名していない .app を自分で差し替えると壊れやすい)。 

4""" 

5import hashlib 

6import json 

7import os 

8import subprocess 

9import sys 

10import tempfile 

11import urllib.request 

12 

13# アプリの中からの更新でインストーラーに渡す引数。/RELAUNCH=1 のときだけ、入れ終わったら起動し直す(installer/graphica.iss)。 

14# 閉じるのは Graphica 自身が先に済ませる(未保存の確認のため)。/CLOSEAPPLICATIONS は終わりきる前に始まったときの保険 

15INSTALLER_UPDATE_ARGS = ('/SILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/RELAUNCH=1') 

16_CHUNK_BYTES = 1 << 16 

17_DOWNLOAD_TIMEOUT_SEC = 30 

18 

19 

20class DownloadCancelled(Exception): 

21 pass 

22 

23 

24def install_kind(): 

25 """'installer'(Windows のインストーラー版)/ 'windows_zip' / 'macos' / 'pip' / 'source'。""" 

26 if getattr(sys, 'frozen', False): 

27 if sys.platform == 'darwin': 

28 return 'macos' 

29 # インストーラーで入れたフォルダには、Inno Setup のアンインストーラーがある 

30 app_dir = os.path.dirname(sys.executable) 

31 return 'installer' if os.path.exists(os.path.join(app_dir, 'unins000.exe')) else 'windows_zip' 

32 return 'source' if _is_editable_install() else 'pip' 

33 

34 

35def _is_editable_install(): 

36 """入れていない(ソースのまま)か、pip install -e で入れたなら True。PyPI から入れたものには direct_url.json が無い。""" 

37 from importlib.metadata import PackageNotFoundError, distribution 

38 try: 

39 direct_url = distribution('graphica-plot').read_text('direct_url.json') or '' 

40 except PackageNotFoundError: 

41 return True 

42 try: 

43 info = json.loads(direct_url) if direct_url else {} 

44 except ValueError: 

45 return False 

46 return bool((info.get('dir_info') or {}).get('editable')) 

47 

48 

49def update_download_dir(): 

50 path = os.path.join(tempfile.gettempdir(), 'Graphica-update') 

51 os.makedirs(path, exist_ok=True) 

52 return path 

53 

54 

55def download_file(url, dest_path, expected_size=0, expected_sha256='', report_progress=None, is_cancelled=None): 

56 """url を dest_path に落とす。大きさと SHA-256 が分かっていれば確かめ、合わなければ消して例外。TaskRunner 用。 

57 

58 途中の内容は .part に書き、確かめ終えてから名前を変える(壊れたインストーラーを実行しないように)。 

59 キャンセルされたら None。進み具合は KB 単位(Qt の int に収める)。 

60 """ 

61 partial_path = dest_path + '.part' 

62 digest = hashlib.sha256() 

63 received = 0 

64 request = urllib.request.Request(url, headers={'User-Agent': 'Graphica-updater'}) 

65 try: 

66 with urllib.request.urlopen(request, timeout=_DOWNLOAD_TIMEOUT_SEC) as response, \ 

67 open(partial_path, 'wb') as out: 

68 total = int(response.headers.get('Content-Length') or expected_size or 0) 

69 while True: 

70 if is_cancelled is not None and is_cancelled(): 

71 raise DownloadCancelled() 

72 block = response.read(_CHUNK_BYTES) 

73 if not block: 

74 break 

75 out.write(block) 

76 digest.update(block) 

77 received += len(block) 

78 if report_progress is not None: 

79 report_progress(received // 1024, total // 1024) 

80 if expected_size and received != expected_size: 

81 raise ValueError(f"ダウンロードした大きさ({received:,} バイト)が、公開されている大きさ({expected_size:,} バイト)と違います。") 

82 if expected_sha256 and digest.hexdigest() != expected_sha256.lower(): 

83 raise ValueError("ダウンロードしたファイルの SHA-256 が、公開されている値と一致しません。") 

84 os.replace(partial_path, dest_path) 

85 return dest_path 

86 except DownloadCancelled: 

87 return None 

88 finally: 

89 # 名前を変えたあとは .part は無い。失敗・キャンセルで残った途中のファイルだけ消える 

90 _remove_quietly(partial_path) 

91 

92 

93def _remove_quietly(path): 

94 try: 

95 os.remove(path) 

96 except OSError: 

97 pass 

98 

99 

100def launch_installer(installer_path): 

101 """インストーラーを Graphica と切り離して起動する。ログは同じフォルダに残す(更新に失敗したときの調べ用)。""" 

102 log_path = os.path.join(os.path.dirname(installer_path), 'Graphica-update.log') 

103 args = [installer_path, *INSTALLER_UPDATE_ARGS, f'/LOG={log_path}'] 

104 flags = getattr(subprocess, 'DETACHED_PROCESS', 0) | getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) 

105 subprocess.Popen(args, creationflags=flags, close_fds=True) 

106 return args