つよいシェルスクリプトとしてのPython

# はじめに
最近CI/CD用に書いていたシェルスクリプトをちまちまとPythonに移植しています。なんでかと言うと単純にシェルスクリプトで扱うにはいささか複雑な処理やデータが増えてきたからですね。
じゃあなんでPythonなの？って話なんですが

- 標準ライブラリだけでわりと色々できる
- OSの機能にアクセスしやすい
- 型を扱えつつ雑にもできる
- 開発環境の構築が楽
- アレルギー持ちが少ない

この辺りが理由です。あとAmazon LinuxやGitHub-hosted runnerにプリインストールなのもベネ。
ただし全てのシェルスクリプトをPythonに移植することが正しいかと言われるとそれは**場合による**としか言いようがないのでここでは事の是非については触れません。
この記事はシェルでよく書くアレをPythonではこう書きますよという言わば互換表のようなものだと思ってください。

# 環境構築
本筋ではありませんが冒頭で環境構築について触れたので軽くご紹介しておきます。
とりあえずPythonをスクリプトとして書き始めたい場合に必要な工程は3つです。
1. pyenv経由でPythonをインストール
1. VS Codeに拡張機能を入れる
1. VS Codeの設定を書く

## Pythonインストール
pyenvはPythonのバージョンマネージャーです。特にこだわりがなければ開発環境にはpyenv経由でPythonをインストールすることをおすすめします。

### pyenvインストール
```shell:windows
Invoke-WebRequest -UseBasicParsing -Uri "https://raw.githubusercontent.com/pyenv-win/pyenv-win/master/pyenv-win/install-pyenv-win.ps1" -OutFile "./install-pyenv-win.ps1"; &"./install-pyenv-win.ps1"
```
参考: https://github.com/pyenv-win/pyenv-win#quick-start
```bash:windows以外
curl https://pyenv.run | bash
```
参考: https://github.com/pyenv/pyenv#automatic-installer

### Pythonインストール
```
pyenv instlal 3.11.0
```
`pyenv install -l`でインストール可能なバージョンの一覧が表示されるのでお好みのものをインストールしてください。

## VS Codeの拡張機能
### インストール
基本機能(型検査含む)

https://marketplace.visualstudio.com/items?itemName=ms-python.python

リンター

https://marketplace.visualstudio.com/items?itemName=ms-python.flake8

フォーマッター

https://marketplace.visualstudio.com/items?itemName=ms-python.black-formatter

フォーマッターその2

https://marketplace.visualstudio.com/items?itemName=ms-python.isort

### 設定
setting.jsonに以下の設定を入れてください。

```json:line-lengthはお好みで
{
  "[python]": {
    "editor.defaultFormatter": "ms-python.black-formatter",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.organizeImports": true
    },
    "editor.formatOnType": true
  },
  "flake8.args": ["--max-line-length=120"],
  "black-formatter.args": ["--line-length=120"],
  "isort.args": ["--profile", "black"],
}
```

これでどのディレクトリで書いても大体のことはできる/自動でやってくれるようになりました。

# 記法あれこれ
## ディレクトリ/ファイル操作
Pythonにおけるディレクトリやファイルの操作は基本的に[`pathlib.Path`](https://docs.python.org/ja/3/library/pathlib.html#concrete-paths)を介して行います。

```py
from pathlib import Path

# 相対パスでも絶対パスでも
r = Path("piyo.txt")
a = Path("/home/hoge/fuga/piyo.txt")

# 相互変換も可能
r.absolute()
>>> PosixPath('/home/hoge/fuga/piyo.txt')

# カレントディレクトリ
Path.cwd()
>>> PosixPath('/home/hoge/fuga')
Path()
>>> PosixPath('.')

# ホームディレクトリ
Path.home()
>>> PosixPath('/home/hoge')

# スクリプト自身のパス
Path(__file__)
>>> PosixPath('/home/hoge/fuga/piyo.py')
```

### 移動/検索
```py
from pathlib import Path

cwd = Path()

# 移動
child = cwd / "hoge"
child
>>> PosixPath('./hoge')
child.parent
>>> PosixPath('.')
child.joinpath("fuga", "piyo")
>>> PosixPath('./hoge/fuga/piyo')

# 検索
[*cwd.glob("*.py")]
>>> [PosixPath('./fuga.py'), PosixPath('./hoge.py')]
# サブディレクトリ内も含めて検索
[*cwd.glob("**/*.py")]
>>> [PosixPath('./fuga.py'), PosixPath('./hoge.py'), PosixPath('./piyo/piyo.py')]
# ls
[*cwd.glob("*")]
>>> [PosixPath('./fuga.py'), PosixPath('./hoge.py'), PosixPath('./piyo')]
```

### 属性
```py
from pathlib import Path

hoge = Path("hoge.txt")

# ステータス(ls -lとかstatで見るようなやつ)
hoge.stat()
>>> os.stat_result(st_mode=33206, st_ino=20829148276990953, st_dev=105550640, st_nlink=1, st_uid=0, st_gid=0, st_size=3039, st_atime=1680457867, st_mtime=1679661130, st_ctime=1680457867)
# 表示されるもの以外も見れるので詳しくは↓
# https://docs.python.org/ja/3/library/os.html#os.stat_result

# モード(パーミッション)変更
hoge.chmod(0o664)  # プレフィックス"0o"のついた数値は8進数と見なされます
oct(hoge.stat().st_mode)
>>> 0o100664

# 存在確認
hoge.exists()
>>> True

# 名前系
hoge.name
>>> hoge.txt
hoge.suffix
>>> .txt
hoge.stem
>>> hoge

# is_hoge()
hoge.is_dir()
>>> False
hoge.is_file()
>>> True
# 他にも色々
```

`glob`と属性の取得を組み合わせて`ls`にオプションを指定したような感じにすることもできます。
```py
[*Path().glob("*")]
>>> [PosixPath('./fuga.py'), PosixPath('./hoge.py'), PosixPath('./piyo')]

[p for p in Path().glob("*") if p.is_dir()]
>>> [PosixPath('./piyo')]
```

### 読み書き
```py
from pathlib import Path

hoge = Path("hoge.txt")
hoge.write_text("hoge!!")
hoge.read_text()
>>> 'hoge!!'
```
なおテキストのエンコード/デコードにはシステムロケールが適用されるのでWindows環境で開発する場合などは`encoding="utf-8"`を忘れないようにしましょう[^utf8]。
```py:windowsの場合
from pathlib import Path

hoge = Path("hoge.txt")
hoge.write_text("ほげ！！", encoding="utf-8")
hoge.read_text(encoding="utf-8")
>>> 'ほげ！！'
```

### 移動/リネーム
```py
from pathlib import Path

hoge = Path("hoge/hoge.txt")

# 移動
hoge.replace(f"fuga/{hoge.name}")

# リネーム
hoge.replace(hoge.with_name("piyo.txt"))
```

移動/リネーム後のパスに既にディレクトリ/ファイルが存在する場合は権限があれば上書き、権限がなければ怒られます。

### リンク
```py
from pathlib import Path

# シンボリックリンク
Path("./fuga").symlink_to("./hoge/fuga")
# "./hoge/fuga"へのリンクが"./fuga"に作られる

# ハードリンク
Path("./piyo").hardlink_to("./hoge/piyo")
```

### 作成/削除
```py
from pathlib import Path

# ディレクトリ
d = Path("hoge/fuga")
d.mkdir(parents=True, exist_ok=True)  # 途中のディレクトリがない場合全部作る、既に存在する場合もエラーにならない
d.rmdir()

# ファイル
f = Path("piyo.txt")
f.touch()  # mkdir()はexist_okがデフォルトFalseだがtouch()はTrue
f.unlink()
```

### コピー及びディレクトリの削除は`shutil`モジュールを使う
上記`Path.rmdir()`には3.11現在オプションが存在しません。そのため削除したくとも**空のディレクトリにしか使えない**というなんともな性能をしています。俺がやりたいのは`rm -rf`なんだよ！という方は併せて[`shutil`モジュール](https://docs.python.org/ja/3/library/shutil.html)をご利用ください。
また`Path`にはコピー系のメソッドが存在しないので(なんで？)こちらも`shutil`で解決します。
```py
from pathlib import Path
import shutil

# ディレクトリコピー
hoge = Path("./hoge")
hoge_ = shutil.copytree(hoge, hoge.with_name("hoge_"))
hoge_
>>> PosixPath('./hoge_')

# ディレクトリ削除
shutil.rmtree(hoge_)

# ファイルコピー
fuga = Path("./fuga.txt")
fuga_ = shutil.copy2(fuga, fuga.with_stem("fuga_"))
fuga_
>>> PosixPath('./fuga_.txt')
```

ちなみに`shutil`の各メソッドに渡す`src`, `dst`引数にはパスを表す文字列でも`Path`オブジェクトでもどちらを使用してもいいのですが**渡したものと同じ型のオブジェクトが返ってくる**ため個人的には`Path`を使った方がおすすめです。

### 一時ディレクトリ/ファイル
使用モジュール: [tempfile](https://docs.python.org/ja/3/library/tempfile.html)

```py
from pathlib import Path
import tempfile

# 一時ディレクトリ
with tempfile.TemporaryDirectory() as tempdir_name:
    tempdir = Path(tempdir_name)
    ...

# 一時ファイル
with tempfile.TemporaryFile() as fp:
    fp.write(b'hoge!')
    ...
```
`TemporaryDirectory()`は作成された一時ディレクトリの名前を返しますが`TemporaryFile()`から返ってくるものはopenされた[file-like object](https://docs.python.org/ja/3/glossary.html#term-file-like-object)そのものなので注意が必要です。

## 環境変数
使用モジュール: [os](https://docs.python.org/ja/3/library/os.html)
```py
import os

# 取得
hoge = os.environ["HOGE"]

# 更新
os.environ["HOGE"] = "hoge!"

# 削除
del os.environ["HOGE"]
```
[getenv](https://docs.python.org/ja/3/library/os.html#os.getenv), [putenv](https://docs.python.org/ja/3/library/os.html#os.putenv), [unsetenv](https://docs.python.org/ja/3/library/os.html#os.unsetenv)に関しては

> Assignments to items in os.environ are automatically translated into corresponding calls to putenv(); however, calls to putenv() don't update os.environ, so it is actually preferable to assign to items of os.environ. This also applies to getenv() and getenvb(), which respectively use os.environ and os.environb in their implementations.

となんだかよく分からないことを言っているので使わない方がよさそうです。

## 正規表現
使用モジュール: [re](https://docs.python.org/ja/3/library/re.html)
```py
import re

# 基本の使い方
commit_msg_ptn = re.compile(r"^(?P<prefix>[a-z]+): (?P<description>.*) #(?P<issue>[0-9]+)$")
if m := commit_msg_ptn.search("update: hoge to fuga #0000"):
    m["prefix"]
    m["description"]
    m["issue"]
>>> 'update'
>>> 'hoge to fuga'
>>> '0000'

# 置換
re.sub(r"\s", "", "hoge fuga piyo\n")
>>> 'hogefugapiyo'

# マッチした部分文字列を参照して置換
re.sub(r"(?P<desc>[a-z]+)(?P<num>[0-9]+)", r"\g<desc>_\g<num>", "hoge2.png")
>>> 'hoge_2.png'
```

参考: [**正規表現のシンタックス**](https://docs.python.org/ja/3/library/re.html#regular-expression-syntax)

## HTTP通信/JSON
使用モジュール: [urllib](https://docs.python.org/ja/3/library/urllib.html), [json](https://docs.python.org/ja/3/library/json.html)

https://qiita.com/hoto17296/items/8fcf55cc6cd823a18217

@hoto17296 さんのこの記事が全てです。

## 日時
使用モジュール: [datetime](https://docs.python.org/ja/3/library/datetime.html)
```py
from datetime import datetime, date, timedelta

# 取得
datetime.fromisoformat("2020-01-01T01:01:01+09:00")
>>> 2020-01-01 01:01:01+09:00
datetime.fromtimestamp(1680546010)
>>> 2023-04-04 03:20:10
datetime.today()
>>> 2023-04-04 03:26:35.380565
date.today()
>>> 2023-04-04

# 出力
today = datetime.today()
today.strftime("%Y年%m月%d日")
>>> '2023年04月04日'
today.timestamp()
>>> 1680547503.322209

# 計算
today + timedelta(days=1)
>>> 2023-04-05 03:45:03.322209
```

## アーカイブ
使用モジュール: [shutil](https://docs.python.org/ja/3/library/shutil.html#archiving-operations)
デフォルトでzip、tar、gztar、bztar、xztarが[扱えます](https://docs.python.org/ja/3/library/shutil.html#shutil.get_archive_formats)。
地味にとても助かる。
```py
import shutil
from pathlib import Path

Path.cwd()
>>> PosixPath('/home/hoge')

# "./fuga/fuga"ディレクトリの中身を"./fuga.tar.gz"として圧縮
fuga_tar = Path(shutil.make_archive("./fuga", "gztar", "./fuga/fuga"))
fuga_tar
>>> PosixPath('/home/hoge/fuga.tar.gz')

# "fuga.tar.gz"を"./piyo"ディレクトリの中に解凍
piyo = Path("./piyo")
shutil.unpack_archive(fuga_tar, piyo)
```

## 他のプログラムを動かす
使用モジュール: [subprocess](https://docs.python.org/ja/3/library/subprocess.html)
```py
from subprocess import run, Popen, PIPE

# 基本の使い方
cpl_proc = run(["git", "log", "-1"], capture_output=True, text=True)  # Windowsはencoding="utf-8"も必要
cpl_proc.returncode
>>> 0
print(cpl_proc.stdout)
>>> commit aaaaaaaaa...
>>> Author: nicco_mirai <hoge@hoge.com>
>>> Date:   Sun Apr 2 15:35:29 2023 +0900
>>> 
>>> hoge!
>>>
cpl_proc.stderr
>>> 

# 通常のコマンドも(OS依存)
cpl_proc = run(["ps", "aux"], capture_output=True, text=True)
print(cpl_proc.stdout)
>>> USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
>>> ec2-user  258807  0.1  1.0 230460 10168 pts/0    S+   01:47   0:00 python3
>>> ec2-user  258809  0.0  0.2 232520  2712 pts/0    R+   01:48   0:00 ps aux

# バックグラウンドで実行
proc = Popen(["npx", "nuxt", "build"], stdout=PIPE, text=True, cwd="nuxt_project_dir")
do_something()
proc.wait()  # 終了するまで待機
```

## 終了
使用モジュール: [sys](https://docs.python.org/ja/3/library/sys.html)
```py
from sys import exit

# 正常終了
exit()
# 異常終了
exit("エラーメッセージ")  # 終了コードは1になる
```

# おわりに
一通り私が使うものは紹介しきれたと思います。
が、ここで紹介したものはPythonの標準ライブラリ全体から見れば1/100にも満たない程度ですので気になる方はぜひ公式ドキュメントにも目を通してみてください。
[**Python 標準ライブラリ**](https://docs.python.org/ja/3/library/index.html)
今後また使ってみて便利だったものがあれば随時更新していきます。

[^utf8]: [UTF-8 Mode](https://docs.python.org/ja/3/library/os.html#python-utf-8-mode)という手もあります。