動機
PowerShell のWindowsでのアップデートって手作業なんですが、ええかげんダルくなりました。半自動化します。
構成
スクリプト本体であるpshupdate.pyをpythonに実行させると、自動でバージョンをチェックして必要ならアップデートしてくれます~~(予定)~~。
2021-01-21 アップデート後に前のバージョンに手が加えられていたら(GitHub上でLatest Releaseタグがそちらに移ってしまっていたら)、起こらないとてっきり思っていた「最新バージョンは昔のバージョンです」例外を吐く不具合を確認しました。どないせえと。
2021-02-12 インストール時のテストおよびバグ修正が無事に終わりました。ちゃんとインストーラが立ち上がります!
なお上述の「昔の最新バージョン」問題は解決の目処が立っていません。
内容
やるべきことに沿って書いただけなんでそんな凝ったことしてないですが書いていきます。
Versionデータの格納
バージョンはだいたい"v3.2.1"とかそういう形の文字列なので、最初の余計な文字だけ取り除いた残りを文字列の3つ組にしてVersion型とします。pwshで(Get-Host).Version | Format-List *
とかやるとなんかキーが6個とか出てくるんですが、後半3つ使ってないし3つだけで充分だな!ヨシ!
後半部で大小比較を実装しています。多少柔軟性を持たせるためにMinor
とBuild
の値はNoneと0と-1を全部同じ扱いにすることにしたので、結果めんどくさくなりました。
import re
from typing import NamedTuple
class Version(NamedTuple):
"""A triplet (Major, Minor, Build) of strings.
Note that every element can have the None value.
"""
Major: str
Minor: str = '-1'
Build: str = '-1'
def formatVersion(self) ->str:
"""Return a string "<Major>.<Minor>.<Build>".\n
Note that this function returns None if Version is None.
"""
if (self.Major is None):
return None
else:
ls = [self.Major]
if (self.Minor != None) and (self.Minor != '-1'):
ls.append(self.Minor)
if (self.Build != None) and (self.Build != '-1'):
ls.append(self.Build)
return '.'.join(ls)
def __eq__(self,other) -> bool:
if(not isinstance(other,Version)):
raise TypeError("Version data cannot compare to other type data.")
if(self.Major != other.Major):
return False
elif(not self.Major):
return True
elif(self.Minor != other.Minor):
if (self.Minor != None) and (self.Minor != '0') and (self.Minor != '-1'):
return False
elif (other.Minor != None) and (other.Minor != '0') and (other.Minor != '-1'):
return False
elif(self.Build != other.Build):
if (self.Build != None) and (self.Build != '0') and (self.Build != '-1'):
return False
elif (other.Build != None) and (other.Build != '0') and (other.Build != '-1'):
return False
else:
return True
def __le__(self,other) -> bool:
if(not isinstance(other,Version)):
raise TypeError("Version data cannot compare to other type data.")
if(self.Major.isdecimal()) and (other.Major.isdecimal()):
if(int(self.Major) < int(other.Major)):
return True
else:
pass
elif(not self.Major.isdecimal()) and (not other.Major.isdecimal()):
a, b = self.Major, other.Major
mslf = re.search(r'^\d*',a)
if mslf:
anum, atxt = a[:mslf.end()], a[mslf.end():]
else:
anum, atxt = None, a
moth = re.search(r'^\d*',b)
if moth:
bnum, btxt = b[:moth.end()], b[moth.end():]
else:
bnum, btxt = None, b
if(int(anum) < int(bnum)):
return True
elif(int(anum)==int(bnum)):
if(atxt < btxt):
return True
elif(atxt == btxt):
pass
else:
return False
else:
return False
else:
raise ValueError("two Version data are not compareable.")
if(self.Minor.isdecimal()) and (other.Minor.isdecimal()):
if(int(self.Minor) < int(other.Minor)):
return True
else:
pass
elif(not self.Minor.isdecimal()) and (not other.Minor.isdecimal()):
a, b = self.Minor, other.Minor
mslf = re.search(r'^\d*',a)
if mslf:
anum, atxt = a[:mslf.end()], a[mslf.end():]
else:
anum, atxt = None, a
moth = re.search(r'^\d*',b)
if moth:
bnum, btxt = b[:moth.end()], b[moth.end():]
else:
bnum, btxt = None, b
if(int(anum) < int(bnum)):
return True
elif(int(anum)==int(bnum)):
if(atxt < btxt):
return True
elif(atxt == btxt):
pass
else:
return False
else:
return False
else:
raise ValueError("two Version data are not compareable.")
if(self.Build.isdecimal()) and (other.Build.isdecimal()):
if(int(self.Build) < int(other.Build)):
return True
else:
return False
elif(not self.Build.isdecimal()) and (other.Build.isdecimal()):
a, b = self.Build, other.Build
mslf = re.search(r'^\d*',a)
if mslf:
anum, atxt = a[:mslf.end()], a[mslf.end():]
else:
anum, atxt = None, a
moth = re.search(r'^\d*',b)
if moth:
bnum, btxt = b[:moth.end()], b[moth.end():]
else:
bnum, btxt = None, b
if(int(anum) < int(bnum)):
return True
elif(int(anum)==int(bnum)):
if(atxt < btxt):
return True
else:
return False
else:
return False
else:
raise ValueError("two Version data are not compareable.")
文字列をVersion型にパースする関数も作りました。
def parseVersion(s) ->Version:
"""input: a string or a list of strings
Parse a string like "v1.0.4"
"""
if(isinstance(s,bytes)):
s = s.decode()
if(isinstance(s,str)):
match = re.search(r'\d*\.\d*\.?',s)
if match:
token = s[match.start():].split('.',2)
if (len(token)==3):
return Version(token[0],token[1],token[2])
elif (len(token)==2):
return Version(token[0],token[1],None)
else:
return Version(token[0],None,None)
else:
match = re.search(r'[0-9]*',s)
if match:
return Version(s[match.start():],None,None)
else:
raise ValueError("function parseVersion didn't parse argument.")
elif(isinstance(s,list)):
for x in s[0:3]:
if(not isinstance(x,str)):
raise TypeError("function parseVersion(s) takes a string or a list of strings as the argument.")
try:
(major,minor,build) = s[0:3]
except ValueError:
raise
except:
print("Unexpected error in function 'parseVersion'")
raise
return Version(major,minor,build)
else:
raise TypeError("function parseVersion(s) takes a string or a list of strings as the argument.")
最新バージョンの取得
GithubのAPIを叩いて最新リリースの情報を入手します。`
import ssl, urllib.request
import json
head_accept = "application/vnd.github.v3+json"
host = "api.github.com"
key_release = "repos/PowerShell/PowerShell/releases/latest"
print("Latest version of pwsh ... ", end='', flush=True)
context = ssl.create_default_context()
url = ''.join(["https://",host,"/",key_release])
try:
q = urllib.request.Request(url,headers={'accept':head_accept},method='GET')
with urllib.request.urlopen(q, context=context) as res:
content = json.load(res)
except urllib.error.HTTPError as err:
print(err.code)
except urllib.error.URLError as err:
print(err.reason)
except json.JSONDecodeError as err:
print(err.msg)
v_latest = parseVersion(content['tag_name'])
print(v.formatVersion())
ローカルのバージョンを取得
pwsh -v
でバージョン情報を取得します。time
はウェイト処理のためだけにimportしてるので実際いらないと言われると何も言えない。
import time
import subprocess
print("Current version of pwsh ... ", end='', flush=True)
time.sleep(0.5)
cpl_pwsh = subprocess.run("pwsh -v",capture_output=True)
v_local = parseVersion(cpl_pwsh.stdout.strip())
print(v_local.formatVersion())
バージョン比較と最新版のインストール
あまり凝ったことはしていない。
2021-02-12: 特にxbモードでファイルを開く必要性がなかったことに気が付き(指示通りにFileExistsErrorを吐いた)wbモードで開くよう変更。
import ssl, urllib.request
import json
directory_download = R"path\to\directoryof\installer"
if(v_local < v_latest):
print("Later version available.")
print("Please wait...")
aslist = content['assets']
vlatest = attr_latest.getstr_version()
targetname = '-'.join(["PowerShell",vlatest,"win-x64.msi"])
targeturl = None
for asset in aslist:
if(asset['name'] == targetname):
targeturl = asset['browser_download_url']
break
if targeturl:
try:
print("Downloading installer... ",end='',flush=True)
with urllib.request.urlopen(targeturl,context=context) as res:
dat_pack = res.read()
except urllib.error.HTTPError as err:
print(err.code)
except urllib.error.URLError as err:
print(err.reason)
except json.JSONDecodeError as err:
print(err.msg)
try:
path = '\\'.join([directory_download,targetname])
f_installer = open(path,mode='wb')
f_installer.write(dat_pack)
f_installer.close()
except OSError:
print()
raise
except:
print("Unexpected error occurred.")
raise
subprocess.run(path, stderr=subprocess.STDOUT,shell=True)
else:
raise Exception("lost download url.")
elif(attr_pwsh.version == attr_latest.version):
print("Your pwsh is latest version.\n")
else:
raise Exception("unidentified exception occurred.")
実行画面
最初と最後にちょっとした装飾を付けているので、実行するとこんな感じになります。今は当然最新版なので、アップデートがちゃんとできるかのテストは次回のpwshのアップデート時までお預けです。
================================================
Powershell updater version 0.5.201114
by Lat.S (@merliborn)
Latest version of pwsh ... 7.1.0
Current version of pwsh ... 7.1.0
Your pwsh is latest version.
Push return key to quit:
================================================
感想
HTTPS使ったりAPI叩いて情報を得たり、そもそもpythonでなんか作ったり、長らくやってみたかったことを一通りやれてよかったです。
型アノテーションがお飾りが故にexpressionで書けるという事実が、普段型付きの生活を送っている身としてはとても衝撃でした。