<-- mermaid -->

2024年のPythonプログラミング

ソーシャル経済メディア「NewsPicks」で推薦や検索などのアルゴリズム開発をしている北内です。Pythonは頻繁に新機能や便利なライブラリが登場し、ベストプラクティスの変化が激しい言語です。そこで、2024年2月時点で利用頻度の高そうな新機能、ライブラリ、ツールなどを紹介したいと思います。

この記事では広く浅く紹介することに重点を置き、各トピックについては概要のみを紹介します。詳細な使用方法に関しては各公式サイト等での確認をおすすめします。なお、本記事ではOSとしてmacOSを前提としています。

環境構築

Pythonの環境構築はpyenvPoetryの組み合わせがもっとも標準的でしょう。

以下の手順でpyenvとPythonをインストールできます。

brew install pyenv

# Bashの場合
echo 'eval "$(pyenv init -)"' >> ~/.bash_profile
exec bash

# インストール可能なバージョン一覧を表示
pyenv install --list

# Python 3.12.1をインストール
pyenv install 3.12.1

# 実行可能なバージョン一覧を表示
pyenv versions

# デフォルトでPython 3.12.1を使用
pyenv global 3.12.1
# 以下のファイルに設定したバージョンが書き込まれる
cat ~/.pyenv/version

# 現在のプロジェクトディレクトリでPython 3.12.1を使用
pyenv local 3.12.1
# 以下のファイルに設定したバージョンが書き込まれる
cat .python-version

Poetryは以下の手順でインストールできます。

curl -sSL https://install.python-poetry.org | python3 -

# Bashの場合
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bash_profile
exec bash

公式サイトではPython CLIアプリケーションを分離された仮想環境にインストールできるpipxを使ってインストールする方法も紹介されています。私はこの方法でインストールしています。

brew install pipx
pipx install poetry

なお、poetry installを実行すると、デフォルトでは~/Library/Caches/pypoetry/virtualenvs/配下にプロジェクトごとのディレクトリ(仮想環境)が作成され、そこに各ライブラリがインストールされます。この設定だとプロジェクト自体のディレクトリを削除しても上記のディレクトリが残り、消し忘れが起きる可能性があります。以下の設定をするとプロジェクト配下の.venvディレクトリにライブラリがインストールされ、管理が楽になります。

# ライブラリをプロジェクト配下の .venv にインストールする
poetry config virtualenvs.in-project true

# 現在の設定を確認
poetry config --list

# 現在の仮想環境を確認
poetry env info

pyenvのようなPythonのバージョン管理、Poetryのようなパッケージ管理、pipxのようなグローバルツール管理のすべての機能をもつ、Rust製ツールのryeも最近注目を集めています。

また、Pythonを含む複数のプログラミング言語のバージョンをまとめて管理できるツールもあります。anyenvasdfが有名ですが、個人的なおすすめはmiseです(発音は "meez")。以前は「rtx」という名前でしたが、NVIDIAのGPUシリーズ「RTX」との混乱を避けるため「mise」に変更されました。

miseはasdfに見られるいくつかの問題点に対処し、その結果、より高速で使いやすいツールになっています。私自身も以前はasdfを利用していましたが、asdfでJavaを使うとシェルのprecmdで実行されるJAVA_HOMEの設定処理が遅く、コマンドを実行するたびに少し待たされるという問題がありました。そのため、Javaに関してはjEnvを使っていました。しかし、miseに切り替えたことでjEnvは不要となりました。

以下の手順でmiseとPythonをインストールできます。

brew install mise
echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc
exec bash

# インストール可能なバージョン一覧を表示
mise ls-remote python

# Python 3.12.1をインストール
mise install python@3.12.1

# インストール済みのバージョン一覧を表示
mise ls

# デフォルトでPython 3.12.1を使用
mise use -g python@3.12.1
# 以下のファイルに設定したバージョンが書き込まれる
cat ~/.config/mise/config.toml

# 現在のプロジェクトディレクトリでPython 3.12.1を使用
mise use python@3.12.1
# 以下のファイルに設定したバージョンが書き込まれる
cat .mise.toml

# .python-versionも参照してくれる
rm .mise.toml
echo 3.12.1 > .python-version
python --version  # => Python 3.12.1

開発環境

ここではVS Codeの設定方法を簡単に説明します。最近のVS Codeでは機能拡張の「Python」をインストールするだけで、コードのハイライト、定義参照、コード補完、リンター、型チェック、コードフォーマットなど一通りの開発支援機能が提供されます(「Pylance」も自動的にインストールされます)。

あとは好みやチームの開発方針に応じて、以下の機能拡張もインストールするとよいでしょう。

  • Flake8(リンター)
  • Black Formatter(コードフォーマッター)
  • isort(import順のフォーマッター)

なお、動作が高速でリンターとフォーマッターの機能を持つRuffというRust製ツールも最近注目を集めています。

Python 3.5から型ヒントが導入され、関数の引数や返り値、変数に型情報を指定することで、VS Codeなどのエディタが提供する静的解析機能を通じて型の誤りを事前に検出できるようになりました。ここ数年で導入された型関連の主な新機能をいくつか紹介します。

まず、Python 3.9以降ではList[str]のようにtypingモジュールを使用する方法が非推奨となり、代わりにlist[str]のような組み込み型を使用するスタイルが推奨されています。

Python 3.10以降は Union[str, int]Optional[int] をそれぞれ str | intint | None と短く書けるようになりました。from typing import Union, Optional などのimport文の記述も不要になります。

また、3.10以降は型の別名を定義するときに TypeAlias を使って明示的に定義できるようになりました。

from typing import TypeAlias

ItemPrices: TypeAlias = list[tuple[str, int]]

Python 3.11からはSelf型が導入され、自分のクラス自身を取得できるようになりました。たとえばツリー構造のデータを定義したいときに、従来はTypeVarを使って自分のクラスを型変数で定義する必要がありました。

from typing import TypeVar

from dataclasses import dataclass

TOrganization = TypeVar("Self", bound="Spam")

@dataclass(frozen=True)
class Organization:
    name: str
    parent: TOrganization | None  # OrganizationだとNameErrorになる

org1 = Organization("Product Division", None)
org2 = Organization("Platform Team", org1)
org3 = Organization("Algorithm Unit", org2)

print(f"{org3.name=} {org3.parent.name=} {org3.parent.parent.name=}")

Self型であれば単にSelfと書くだけですみます。

from typing import Self
from dataclasses import dataclass

@dataclass(frozen=True)
class Organization:
    name: str
    parent: Self | None

最後はジェネリクスについてです。ジェネリクスを定義する際、Python 3.11まではTypeVarを使用して型変数を定義する必要がありました。

from typing import TypeVar

T =  TypeVar('T')

def get_last(arr: list[T]) -> T:
    return arr[-1]

Python 3.12以降は型パラメータの文法が導入され、関数名の直後に型変数を[T]のように記述することで、より直感的にジェネリクスを定義できるようになりました。

def get_last[T](arr: list[T]) -> T:
    return arr[-1]

Pythonにはほかにも多くの型関連の機能があります。興味がある方はドキュメント型関連のPEPをご参照ください。

データクラス

Python 3.7からデータクラスが導入されました。型ヒント付きのデータ構造を簡単に定義できます。また、frozen=True を指定することでフィールドへの代入を禁止し、オブジェクトをイミュータブル(変更不可)にできます。オブジェクトをイミュータブルにしたときに私が頻繁に利用する機能の一つにdataclasses.replaceがあります。これを使用することで、オブジェクトの一部のフィールドのみを変更した新しいオブジェクトを生成できます。

import dataclasses
from dataclasses import dataclass

@dataclass(frozen=True)
class User:
    name: str
    deactivated: bool = False

user1 = User("taro")  # User(name="taro")でもOK
user2 = User("hanako", True)
user3 = dataclasses.replace(user2, deactivated=False)

Pydanticを使うと、フィールドの型が違うときに実行時エラーを出してくれます。ただし、str, int, float, boolなどの型は可能であれば自動的に型変換が行われます。この自動型変換を避けたい場合はStrictStrのような型を指定する必要があります。Pydanticではコンストラクタに位置引数を使うことはできず、キーワード引数を使用する必要があります。

また、Pydanticは各フィールドに独自のバリデーターを定義できるなど、dataclassにはないいくつかの機能も提供しています。

from pydantic import BaseModel, StrictStr

class User(BaseModel, frozen=True):
    name: StrictStr
    deactivated: bool = False

user1 = User(name="taro")  # User("taro")だとエラー
user2 = User(name="hanako", deactivated=True)
user3 = user2.copy(update={"deactivated": False})

f文字列

Python 3.6から導入されたf文字列(フォーマット済み文字列リテラル)により、文字列内で直接変数や式を埋め込み、その結果を文字列に組み込むことが可能になりました。例えば、name = "Alice"という変数がある場合、f"Hello, {name}!"と記述するだけで"Hello, Alice!"という文字列を簡単に生成できます。

従来は"Hello, {}!".format(name)"Hello, %s!" % nameといった書式を用いていましたが、f文字列を利用することでより簡潔かつ直感的に文字列を構築できるようになります。f"score={score:.4f}"といった書式指定も可能です。

Python 3.8 からはf"name={name}"f"{name=}"と書けるようになり、変数名とその値を自動で文字列に組み込むことができるようになりました。f"{score=:.4f}"のような書式指定も可能です。ログ出力などで役に立ちます。

さらに、Python 3.12 からはf"name={user["name"]}"のようにf文字列全体の引用符と同じ引用符を式の中でも使用できるようになりました。以前はf"name={user['name']}"のように異なる引用符を使う必要がありましたが、そのような配慮は不要になりました。

CLI

PythonでCLIアプリケーションを作るとき、コマンドライン引数の解析のために標準ではargparseライブラリが用意されています。

import argparse

def main():
    parser = argparse.ArgumentParser(description="サンプルのCLIアプリケーションです。")
    parser.add_argument('name', type=str, help="名前を入力してください")
    parser.add_argument("--greeting", type=str, default="こんにちは", help="挨拶の言葉を入力してください (デフォルト: こんにちは)")
    
    args = parser.parse_args()
    
    print(f"{args.greeting}, {args.name}!")

if __name__ == "__main__":
    main()

このスクリプトをgreeting.pyとして保存し、コマンドラインから以下のように実行できます。

% python greeting.py "太郎" --greeting "こんばんは"
こんばんは, 太郎!

Clickを使うと以下のように短く書くことができ、コマンドライン引数の解析を簡単かつ直感的に行うことができます。

import click

@click.command()
@click.argument("name")
@click.option("--greeting", default="こんにちは", help="挨拶の言葉(デフォルト: こんにちは)")
def main(name, greeting):
    print(f"{greeting}, {name}!")

if __name__ == "__main__":
    main()

コマンドラインから以下のように実行できます。ヘルプも表示できます。

% python greeting.py "太郎" --greeting "こんばんは"
こんばんは, 太郎!

% python greeting.py --help
Usage: greeting.py [OPTIONS] NAME

Options:
  --greeting TEXT  挨拶の言葉(デフォルト: こんにちは)
  --help           Show this message and exit.

さらにTyperは型ヒントを利用して、より簡潔で読みやすいコードを実現します。Typerは内部でClickを利用しています。

import typer

app = typer.Typer()

@app.command()
def main(name: str, greeting: str = "こんにちは"):
    print(f"{greeting}, {name}!")

if __name__ == "__main__":
    app()

コマンドラインから以下のように実行できます。Click同様ヘルプも表示できます。

% python greeting.py Taro --greeting "こんばんは"
こんばんは, Taro!

% python greeting.py --help
Usage: greeting.py [OPTIONS] NAME

Arguments:
  NAME  [required]

Options:
  --greeting TEXT                 [default: こんにちは]
  --install-completion [bash|zsh|fish|powershell|pwsh]
                                  Install completion for the specified shell.
  --show-completion [bash|zsh|fish|powershell|pwsh]
                                  Show completion for the specified shell, to
                                  copy it or customize the installation.
  --help                          Show this message and exit.

サブコマンドを持つCLIアプリケーションも簡単に作れます。

import typer

app = typer.Typer()

@app.command()
def hello(name: str):
    print(f"こんにちは, {name}!")

@app.command()
def good_morning(name: str):
    print(f"おはよう, {name}!")

if __name__ == "__main__":
    app()

コマンドラインから以下のように実行できます。サブコマンドの_-に置き換えられます。また、サブコマンドのヘルプも表示できます。

% python greeting.py good-morning "太郎"
おはよう, 太郎!

% python greeting.py --help
Usage: greeting.py [OPTIONS] COMMAND [ARGS]...

Options:
  --install-completion [bash|zsh|fish|powershell|pwsh]
                                  Install completion for the specified shell.
  --show-completion [bash|zsh|fish|powershell|pwsh]
                                  Show completion for the specified shell, to
                                  copy it or customize the installation.
  --help                          Show this message and exit.

Commands:
  good-morning
  hello

% python greeting.py hello --help
Usage: greeting.py hello [OPTIONS] NAME

Arguments:
  NAME  [required]

Options:
  --help  Show this message and exit.

ロギング

標準ライブラリのloggingは、デフォルトでは INFO:root:user_id=123 のような出力形式で、ログレベル、ロガー名、メッセージという簡素な情報しか出力されません。

Loguruを使うとデフォルトでタイムスタンプ、ログレベル、ファイル名、関数名、行番号、メッセージが出力され大変便利です。

from loguru import logger

def run():
    user_id = 123
    logger.info(f"{user_id=}")

run()

出力例:

2024-01-31 22:49:33.827 | INFO     | __main__:run:5 - user_id=123

構造化ロギングなどより高い柔軟性、拡張性をもつsturctlogというライブラリもあります。

その他

ここ数年のバージョンで利用できるようになったPythonの新機能をいくつか紹介します。

セイウチ演算子

Python 3.8からセイウチ演算子が使えるようになりました。たとえばif文の条件部分で値を計算し、その値を条件ブロック内で再利用することができます。

if (n := len(a)) > 10:
    print(f"List is too long ({n} elements, expected <= 10)")

パターンマッチング

Python 3.10からパターンマッチング機能が使えるようになりました。この機能については様々なところで紹介されているので、簡単な例を示すにとどめます。たとえばdictのキーの値を条件に使い、別のキーの値を使って別の計算する場合などに便利です。

def process_shape(shape):
    match shape:
        case {"type": "circle", "radius": radius}:
            return 3.14 * radius ** 2
        case {"type": "rectangle", "length": length, "width": width}:
            return length * width
        case _:
            return "Invalid shape"

zip()関数のstrictオプション

Pythonのzip()関数は複数のイテラブルを同時にループする際に使用されますが、引数として渡される各イテラブルの要素数が異なる場合、デフォルトでは最短のイテラブルに長さを合わせ、余った要素は無視されます。通常は要素数が同じであることを想定していることが多く、要素数が違う場合はエラーにしてほしいことが多いのではないでしょうか。

>>> list(zip([1, 2, 3, 4], ["a", "b", "c"]))
[(1, 'a'), (2, 'b'), (3, 'c')]

このような不一致を防ぐため、Python 3.10ではstrictオプションが追加されました。strict=Trueを指定すると、すべてのイテラブルの長さが同じでない場合にValueErrorが発生します。この機能を利用することで、データの不整合によるバグを早期に発見し、より堅牢なコードを書くことができます。

>>> list(zip([1, 2, 3, 4], ["a", "b", "c"], strict=True))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: zip() argument 2 is shorter than argument 1

dictのマージ

2つのdictをマージしたいとき、従来は{**d1, **d2}という形式を使用するのが一般的でした。しかし、Python 3.9以降では、|演算子を使ってd1 | d2と記述することで、より直感的に辞書をマージできるようになりました。

なお、list型の場合はa1 + a2で、set型の場合はs1 | s2と書くことで、それぞれの型の要素を結合できます。

参考情報

最後に、Pythonの最新情報を知るのに役立つサイトをいくつか紹介します。

Page top