[python] pwshをアップデートしてくれる(はずの)スクリプト

# 動機
[PowerShell](http://aka.ms/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を全部同じ扱いにすることにしたので、結果めんどくさくなりました。

```python
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型にパースする関数も作りました。

```python
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を叩いて最新リリースの情報を入手します。`

```python
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してるので実際いらないと言われると何も言えない。

```python
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モードで開くよう変更。

```python
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で書けるという事実が、普段型付きの生活を送っている身としてはとても衝撃でした。