Virtual environment、pip、pyproject.tomlをPythonで使いこなす

1. 概要

同じマシン上で複数のPythonプロジェクトを扱うようになると、バージョン競合に直面することになります。プロジェクトAはrequests 2.28が必要でも、プロジェクトBはrequests 2.32が必要という具合です。この記事では、この問題を解決するための3つのツールを紹介します。venvで各プロジェクトの環境を分離し、pipでパッケージをインストール・管理し、pyproject.tomlで最新の標準に従ってプロジェクトを宣言します。この記事を読み終わった後には、きちんと構造化されたPythonプロジェクトを構築でき、editableモードでインストールでき、pytestでテストを実行できるようになっているはずです。

2. Virtual environment

Virtual environment(venv)とは、3つのものを含むディレクトリです:独立したPythonインタープリター(通常はsymlink)、独立したsite-packagesディレクトリ、そしてアクティベーションスクリプトです。venv内にインストールされたパッケージはそのディレクトリ内にのみ存在し、システムのPythonに影響を与えません。

2.1. venvの作成とアクティベート

venvモジュールはPython 3.3以降の標準ライブラリに含まれているので、追加でインストールする必要はありません:

mkdir shopcart && cd shopcart
python3 -m venv .venv

# Linux / macOS
source .venv/bin/activate

# Windows (PowerShell)
.venv\Scripts\Activate.ps1

python3 -m venv .venvコマンドはプロジェクトに.venvディレクトリを作成します。.venvと名付けるのは一般的な慣例で、ほとんどのIDEはこれを自動的に認識します。アクティベートした後、シェルのプロンプトには(.venv)というプレフィックスがつきます。この時点で、pythonとpipコマンドはvenvを指します。

venvから抜けるには、以下を実行します:

deactivate

2.2. どのインタープリターを使っているかの確認

このステップを飛ばす人が多いため、「インストールしたはずなのにModuleNotFoundErrorが出る」といったエラーの原因になることが多いです:

which python
python -c "import sys; print(sys.prefix); print(sys.prefix != sys.base_prefix)"
/home/lan/shopcart/.venv/bin/python
/home/lan/shopcart/.venv
True

sys.prefixは現在実行している環境のルートディレクトリです。sys.base_prefixは元のPythonのディレクトリです。この2つの値が異なれば、あなたはvenvの中にいます。

注意: .venvディレクトリをGitにコミットしないでください。.venv/を.gitignoreに追加してください。venvは絶対パスとOSに依存しているため、別のマシンにコピーすると壊れることがほとんどです。正しい方法は、dependency宣言ファイルをコミットして、新しいマシンでvenvを再作成することです。

3. pipでのパッケージ管理

pipはデフォルトのパッケージインストーラーで、PyPIからパッケージを取得します。

3.1. 基本的なコマンド

python -m pip install --upgrade pip
python -m pip install "requests>=2.31,<3"
python -m pip list
python -m pip show requests
python -m pip uninstall -y requests

各コマンドの説明:

  • install --upgrade pipはpip自体をアップグレードします。古いバージョンのpipはpyproject.tomlを正しく読めないことがあります。
  • "requests>=2.31,<3"はversion specifierです。シェルが<をリダイレクトとして解釈しないよう、クォートで囲む必要があります。
  • listはインストール済みのパッケージを一覧表示します。showはバージョン、インストール位置、依存パッケージを含む詳細情報を表示します。

3.2. pipではなくpython -m pipを使うべき理由

単なるpipコマンドは、あなたが使用しているpythonとは異なるPythonを指すかもしれません。これはマシンに複数バージョンのPythonがあるときや、venvのアクティベートを忘れたときに発生しやすいです。python -m pipコマンドは常に正確なインタープリターのpipを実行するため、パッケージは確実に期待する環境にインストールされます。

3.3. requirements.txtとpip freeze

パッケージリストを共有する従来の方法はrequirements.txtを使うことです:

python -m pip freeze > requirements.txt
python -m pip install -r requirements.txt
certifi==2024.8.30
charset-normalizer==3.4.0
idna==3.10
requests==2.32.3
urllib3==2.2.3

pip freezeはvenv内のすべてのパッケージを、urllib3のような間接的な依存関係も含めて、正確なバージョンで記録します。これは実行環境を再現するのに適しています。しかし、どのパッケージが本当に必要で、どのパッケージが引き込まれているだけなのかは教えてくれません。また、プロジェクト自体の説明(名前、バージョン、Pythonの要件)も含まれていません。それはpyproject.tomlの役割です。

4. pyproject.tomlでのプロジェクト宣言

pyproject.tomlはPythonプロジェクトの標準設定ファイルです。これはPEP 518、PEP 621およびその他の関連するPEPで定義されています。このファイルはsetup.pyとsetup.cfgに代わり、pytestやruffなどの複数のツール設定をまとめています。

4.1. プロジェクト構造

私たちはsrc layoutを使用します。つまり、コードをsrc/に配置します。このレイアウトにより、テストは常にインストール済みのパッケージで実行され、ワーキングディレクトリから直接インポートされるのを避けられます:

shopcart/
├── pyproject.toml
├── src/
│   └── shopcart/
│       ├── __init__.py
│       ├── order.py
│       └── cli.py
└── tests/
    └── test_order.py

4.2. 完全なpyproject.tomlファイル

[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"

[project]
name = "shopcart"
version = "0.1.0"
description = "Simple order total calculation"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.31,<3",
]

[project.optional-dependencies]
dev = [
    "pytest>=8",
    "ruff>=0.5",
]

[project.scripts]
shopcart = "shopcart.cli:main"

[tool.pytest.ini_options]
testpaths = ["tests"]

各セクションの意味:

  • [build-system]はプロジェクトをビルドするのに必要なツールをpipに伝えます。ここではsetuptoolsです。これをhatchlingまたはflit_coreに置き換えることができ、他の部分を変更する必要はありません。
  • [project]は標準メタデータを含みます。requires-pythonは、サポートされていないPythonバージョンへのプロジェクトのインストールを防ぎます。
  • dependenciesはプロジェクトを実行するために必須なパッケージです。ここではバージョン範囲(>=2.31,<3)を宣言すべきで、固定バージョン(==2.32.3)ではありません。
  • [project.optional-dependencies]はオプションのパッケージをグループ化します。devグループは、エンドユーザーが必要としない開発ツールを含みます。
  • [project.scripts]はターミナルにshopcartコマンドを作成します。このコマンドはshopcart.cliモジュール内のmain関数を呼び出します。
  • [tool.pytest.ini_options]はpytestの設定です。すべてのツールは独自の[tool.<name>]namespace を持ちます。

src layoutではsetuptoolsがsrc/内のパッケージを自動的に検出するため、追加の宣言は必要ありません。

4.3. パッケージのコード

# src/shopcart/order.py
from dataclasses import dataclass, field
from decimal import Decimal


@dataclass(frozen=True)
class OrderLine:
    product: str
    unit_price: Decimal
    quantity: int


@dataclass
class Order:
    customer: str
    lines: list[OrderLine] = field(default_factory=list)

    def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
        if quantity <= 0:
            raise ValueError("quantityは0より大きい必要があります")
        self.lines.append(OrderLine(product, Decimal(unit_price), quantity))

    def total(self) -> Decimal:
        return sum((l.unit_price * l.quantity for l in self.lines), Decimal("0"))

OrderはOrderLineのリストを保持します。金額計算の丸め誤差を避けるためDecimalを使うので、unit_priceは文字列として渡されます。メソッドtotal()は各行の小計を合算し、初期値はDecimal("0")です。

# src/shopcart/cli.py
from shopcart.order import Order


def main() -> None:
    order = Order(customer="田中太郎")
    order.add("カフェオレ", "29000", 2)
    order.add("バゲット", "25000")
    print(f"顧客: {order.customer}")
    print(f"合計: {order.total():,} VND")

src/shopcart/__init__.pyは空でもかまいません。

4.4. editableモードでのインストール

python -m pip install -e ".[dev]"
shopcart
顧客: 田中太郎
合計: 83,000 VND

フラグ-e(editable)はパッケージをソースコードにリンクし、site-packagesにコピーせずに済みます。そのため、order.pyを編集すると、再インストール不要で即座に変更が反映されます。".[dev]"の部分は、現在のディレクトリでプロジェクトをインストール時にdevグループも含める意味なので、pytestとruffも一緒にインストールされます。shopcartコマンドは[project.scripts]での宣言のおかげでターミナルで利用可能になります。

注意: zshなどの一部のシェルは[...]をglobパターンとして解釈します。そのため、常に".[dev]"をクォートで囲む必要があります。

5. テストの作成と実行

# tests/test_order.py
from decimal import Decimal

import pytest

from shopcart.order import Order


def test_total_sums_all_lines():
    order = Order(customer="鈴木花子")
    order.add("アイスティー", "35000", 2)
    order.add("カスタードプディング", "15000", 3)

    assert order.total() == Decimal("115000")


def test_empty_order_has_zero_total():
    assert Order(customer="田中太郎").total() == Decimal("0")


def test_add_rejects_non_positive_quantity():
    order = Order(customer="山田美咲")
    with pytest.raises(ValueError, match="quantity"):
        order.add("ミネラルウォーター", "10000", 0)
python -m pytest -q
...                                                     [100%]
3 passed in 0.02s

テストはshopcart.orderをインストール済みのパッケージとしてインポートします。これは上のステップでpip install -eが必要な理由です。まだインストールしていなければ、src layoutはModuleNotFoundErrorを報告します。このエラーは意図的なもので、ユーザーがインストールする実際のものをテストするよう強制しています。3番目のテストはpytest.raisesを使って、無効な数量が“quantity”を含むメッセージで拒否されることを確認します。

6. バージョン固定: pyproject.tomlかrequirements.txtか?

これら2つのファイルは互いに排他的ではありません。異なる目的に役立ちます:

pyproject.toml ロックファイル (ピン止めされたrequirements.txt)
目的 プロジェクトが必要なものを説明 環境を正確に再現
バージョン 範囲 (>=2.31,<3) 固定 (==2.32.3)
用途 ライブラリとアプリケーション デプロイ対象のアプリケーション (サーバー、Docker)

PyPIに公開されるライブラリの場合、pyproject.tomlだけが必要です。バージョンを固定すると、ライブラリのユーザーが競合に遭遇します。

サーバーで実行するアプリケーションの場合、ロックファイルを持つべきです。そうすれば、デプロイのたびに同じバージョンセットがインストールされます:

python -m pip install -e .
python -m pip freeze --exclude-editable > requirements.lock

デプロイ時には、ロックファイルから最初にインストールした後、プロジェクトをインストールします。pipが依存パッケージを自動的にアップグレードしないようにするためです:

python -m pip install -r requirements.lock
python -m pip install --no-deps .

ロックプロセスをもっとシンプルにしたい場合は、pip-tools(コマンドpip-compile pyproject.toml)またはuvが使えます。どちらもpyproject.tomlを直接読み込んでハッシュ付きのロックファイルを生成します。

7. よくあるエラー

  • sudo pip installでパッケージをインストール。 このコマンドはOSのパッケージを上書きし、システムツールを壊す可能性があります。多くの最新のLinuxディストリビューションはexternally-managed-environmentエラーで明示的にブロックしています。解決方法は常にvenvを使うことです。
  • venvのアクティベートを忘れたまま、インストールまたは実行する。セクション2.2のようにwhich pythonで確認するか、.venv/bin/python -m pip ...を直接実行して確認できます。
  • 異なるPythonでvenvを作成・実行。 venvは作成したインタープリターに厳密に結合されています。Pythonをアップグレードするとき(例えば3.11から3.12へ)、.venvを削除して再作成してください。
  • プロジェクトディレクトリを移動または名前変更。 .venv/bin内のスクリプトは絶対パスを含むため、移動後は壊れます。venvを再作成する必要があります。
  • **古いsetup.pyとpyproject.tomlを混在させ、**両方の場所に依存関係を宣言する。唯一の信頼できる情報源を選んでください。新しいプロジェクトでは、それは[project]テーブルです。
  • 開発ツールをdependenciesに入れる。 pytestやruffはoptional-dependencies内に置くべきです(または、ツールがサポートしている場合は[dependency-groups])。エンドユーザーがそれらをインストールする必要がないようにするためです。

注意: blackやhttpieのような独立したCLIツールを単にマシン全体で実行する場合は、venvを使うべきではありません。この場合、pipxがより適切です。各ツール用に独立したvenvを自動作成し、コマンドをPATHに追加します。

8. まとめ

この記事ではPythonプロジェクトの基盤となる3つのツールを説明してきました:

  • python -m venv .venvで各プロジェクトの依存関係を分離。
  • python -m pipで使用中のインタープリターに正確にパッケージをインストール。
  • pyproject.tomlでメタデータ、依存関係、オプショングループ、CLIコマンド、ツール設定を1つのファイルで宣言。

src layoutとpip install -e ".[dev]"とpytestを組み合わせることで、クリーンで、テストしやすく、共有しやすいプロジェクトが実現できます。安定したデプロイメントが必要なアプリケーションの場合は、pyproject.tomlの傍らにロックファイルを追加して、すべての環境が同じバージョンセットをインストールするようにしてください。

pythonpipvenvpyprojectpackaging