2018/05/15

Python にまつわるアイデア: Python のパッケージとモジュールの違い

Python の「パッケージ」と「モジュール」の違いについて説明してみます。

本題に入る前に数点お断りです。

  • この記事は長文です。
  • 記事作成時の Python の最新バージョンは Python 3.6 です。 Python 3.6 の頃の認識にもとづいて書かれています。
  • この記事はある程度調査・確認をした上で書いていますが、私は Python の仕様や Python そのものの開発のプロではありません。あくまでも Python のいちユーザの認識であり間違っている可能性があります(とはいえ、なるべく正確に書こうというモチベーションで書いているので、詳しい方で間違いに気づいた方はご指摘いただけますと幸いです)。

Python の「パッケージ」と「モジュール」の違い


Python のパッケージとモジュールの概念は少し複雑なので、ひとことでかんたんに説明することができません。

次の 2 通りの方法をするのがよいのかなと思います。

  • a) 正確ではないがシンプルな説明
  • b) シンプルではないが(わりと)正確な説明

2 つの説明を持ち出す理由は、数学で「円周率 = 3.14 」と考えることに似ています。

円周率は本来無理数なので「円周率 = 3.14 」という説明は厳密には間違いですが、 3.14 としておいても実用上は問題のない状況がほとんどです。また、より厳密な定義を理解するには無理数等の概念を先に理解するなどの準備が必要です。そのため、円周率については次の 2 通りの説明ができます。

  • e-a) 円周率は 3.14 である
  • e-b) 円周率は 3.14159265358979... と続く無理数である

今回 2 通りの説明をする理由はこれと同じで、 a) の説明は「正確ではないがほとんどの場合はその理解で問題ない」、 b) の説明は「わかりづらいがより正確」、といった違いがあります。

順番に説明していきます。

a) 正確ではないがシンプルな説明

先に、正確ではないがシンプルな説明をしてみます。

Python におけるモジュールとパッケージの説明はそれぞれ次のとおりです。

  • モジュール = ファイル。拡張子が .py (あるいは .pyc 等)の Python ファイルのこと。
  • パッケージ = ディレクトリ__init__.py というファイルを格納したディレクトリのこと。

次のように import 文で読み込むことができる点はパッケージもモジュールも共通です。

sample_module.py:
print('これはモジュールです。')

sample_package/__init__.py:
print('これはパッケージです。')

$ python -c 'import sample_module'
これはモジュールです。
$ python -c 'import sample_package'
これはパッケージです。

この a) の説明において、パッケージとモジュールの違いは「 モジュールは単一のファイルで、パッケージはディレクトリである 」ということになります。

パッケージはディレクトリなので、他のモジュール(=ファイル)やパッケージ(=ディレクトリ)を中に格納することができます。一方のモジュールはファイルなので、他のモジュールを格納することができません。

$ # パッケージは他のパッケージを格納することができる
$ tree .
└── songohan
    ├── __init__.py
    └── songoku
        ├── __init__.py
        ├── songoten.py
        └── songohan.py
# パッケージの中のパッケージを import することができる
import songohan.songoku
import songohan.songoku.songoten
import songohan.songoku.songohan

a) の説明は以上です。 b) の説明に入る前に、 a) について少し補足をします。

a) の補足: __init__.py を中に持たないディレクトリの場合

Python 3.3 以降では __init__.py ファイルを中に持たないディレクトリも Python パッケージとして認識されるようになりました。

$ # trunks.py は `import vegeta.trunks` でインポートできる
$ tree .    
└── vegeta
    └── trunks.py

__init__.py の無いディレクトリは、パッケージはパッケージだけど少し特殊な「 ネームスペースパッケージ 」(名前空間パッケージ)として扱われます。

ネームスペースパッケージは、異なるパスに置かれた複数のモジュールやパッケージを共通の親パッケージでまとめることができるものです 。例えば、次のように 2 つの異なる場所に chuoku というディレクトリがあった場合、それぞれの親ディレクトリをモジュールの検索パスに追加すれば、これらを共通のネームスペースパッケージとして利用できるようになります。

$ tree /tmp/tokyo/ /tmp/osaka/osaka/
/tmp/tokyo/
└── chuoku
    └── sample1.py
/tmp/osaka/osaka/
└── chuoku
    └── sample2.py

2 directories, 2 files

このことは、次の Python コードで検証することができます。

import sys

# モジュール検索パスに 2 つの chuoku ディレクトリの親ディレクトリを追加する
sys.path.append('/tmp/tokyo')
sys.path.append('/tmp/osaka/osaka')

# ネームスペースパッケージ `chuoku` の下にあるモジュール `sample1` `sample2` を 
# どちらも問題なく import することができる
import chuoku.sample1
import chuoku.sample2

逆に、上の chuoku ディレクトリのうちどちらか一方でも __init__.py ファイルを中に持っていれば、それはネームスペースパッケージではなく通常のパッケージとなります。例えば、 /tmp/tokyo/chuoku の方に __init__.py があればこれは通常のパッケージとなるので、もう一方の /tmp/osaka/osaka/chuoku はパッケージとして読み込めなくなってしまいます。逆もまた然りです。

ちなみにですが、ネームスペースパッケージではない通常のパッケージをネームスペースパッケージと区別するために、 Python の公式ドキュメントでは「レギュラーパッケージ」あるいは「トラディショナルパッケージ」ということばで通常のパッケージを呼んでいます。

この __init__.py を持たないディレクトリが自動的にネームスペースパッケージになると何がうれしいのかと言うと、 規模の大きなパッケージのサブパッケージを別々のディストリビューションパッケージとして配布するのがやりやすくなります

もうひとつちなみに、ここで「ディストリビューションパッケージ」というのは配布用にまとめられた単位のパッケージのことを指していて、通常は pip install コマンドの引数として指定するものです。

具体例をあげると、 Django REST framework というライブラリは、 pip でインストールするときには djangorestframework 、 Python コード内で利用するときは rest_framework という名前でそれぞれ参照・指定します。

pip コマンド:
$ pip install djangorestframework

Python コード内:
from django.contrib.auth.models import User
from rest_framework import serializers

class UserSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = User
        fields = ('url', 'username', 'email', 'is_staff')

Django REST framework の場合、パッケージ名が rest_framework で、ディストリビューションパッケージ名が djangorestframework です。

さらにちなみに、この「 __init__.py が無いディレクトリが自動的にネームスペースパッケージになる」という仕組みが Python 3.3 で導入される前は、ネームスペースパッケージの仕組みそのものは存在していたのですが、ネームスペースパッケージ化するには __init__.py を用意してその中に次のようなコードを書く必要があり、これがなかなか面倒臭かったようです。

__path__ = __import__('pkgutil').extend_path(__path__, __name__)
__import__('pkg_resources').declare_namespace(__name__)

このあたりが Python 3.3 で簡単化されて「 __init__.py が無いディレクトリは自動的にネームスペースパッケージになる」という仕組みが導入されました。該当する PEP は PEP 420: Implicit Namespace Packages なので、細部に興味のある方は PEP 420 のページを読んでみてください(私は読んでもよくわかりませんが・・・)。


a) の補足は以上です。続いて b) の説明を見ていきましょう。

b) シンプルではないが(わりと)正確な説明

b) における Python のモジュールとパッケージの説明は次のとおりです。

  • モジュール = import 文でインポートすることができるもの
  • パッケージ = モジュールのうち他のモジュールをサブモジュールとして格納したもの

もう少し長めに言い換えます。

  • つまり、 すべてのパッケージはモジュールである 。数学的な感じで書けば「 Gp ⊂ Gm 」。
  • パッケージとモジュールの共通点は「 import 文でインポートできる 」こと。
  • パッケージとモジュールの違いは「 パッケージには __path__ アトリビュートがありそれを通じてサブモジュールを提供している一方で、モジュールには __path__ が無くサブモジュールを提供していない 」こと。

つまり、パッケージとモジュールは基本的には同じもので、異なるのは「パッケージには __path__ アトリビュートがありそれを通じてサブモジュール(この「サブモジュール」には「サブパッケージ」を含む)を提供していて、モジュールは __path__ アトリビュートが無くサブモジュールを提供していない点」だけです。

この「 __path__ というアトリビュートが重要な仕事をしている」という点は、次のような検証用コードで確認することができます。

2 つのパターンを試してみましょう。ひとつめは「パッケージとして動作する Python ファイル(≒モジュール)」のパターンです。

weird_module.py:
import os

__path__ = (os.path.dirname(__file__), )

weird_module.py ファイルがあるディレクトリに移動して、 Python インタラクティブシェルを起動して次のコードを試してみます。

# どの import 文も問題なく実行できる
import weird_module
import weird_module.weird_module
import weird_module.weird_module.weird_module

weird_module.py は単一の Python ファイルですが、 __path__ アトリビュートを持っており、その値のタプルの要素のひとつとして自分自身の親ディレクトリのパスを持っています。結果として、 weird_module は実体は単一のファイルでありながら、自分自身をサブモジュールとして再帰的に提供できるパッケージとして動作します(あくまでも検証用なので、実用性はありません)。

もう 1 つのパターンを見てみましょう。これは先ほどとは逆で、「 __init__.py ファイルを格納したディレクトリで、本来パッケージとして扱われるはずなのに、中にある .py ファイルをサブモジュールとして提供しないパッケージ」のパターンです。

次のファイル構造を用意しましょう。
$ tree weird_package
weird_package
├── __init__.py
└── child.py

0 directories, 2 files

続いて、 weird_package ディレクトリの下の __init__.py ファイルに次の 1 行のコードを書き込みます。

weird_package/__init__.py:
del __path__

準備ができたら、 weird_package の親ディレクトリに移動し、 Python インタラクティブシェルを起動して次のコードを試してみます。
# weird_package 自体は問題なく import できる
import weird_package

# weird_package/child.py は import できない
import weird_package.child
# =>
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
# ModuleNotFoundError: No module named 'weird_package.child'; 'weird_package' is not a package

weird_package のサブモジュールであるはずの child が import できません。 weird_package ディレクトリは __init__.py ファイルを持っているにもかかわらず、 __path__ が適切に定義されていないので、サブモジュール child を提供していません。

この検証用サンプルでは他にもいろんなことを確認することができますが、これ以上踏み込むことはよしておきます。

b) の説明は以上です。続いて、 a) b) 2 つの説明を比較して、ポイントを見ていきましょう。

a) b) 2 つの説明を比較して

繰り返しになりますが、 b) の説明を読むと a) の「モジュール = ファイル、パッケージ = ディレクトリ」という説明は一部正しいところがあるものの厳密には正しくないということがわかります。

ただ、 a) の説明が間違いかというとそんなことはなくて、実用上は a) の形で理解していても何ら問題がないことがほとんどではないかと思います。

ちなみに、(私はこのあたりに詳しくはないのですが) Python のインポートシステムはデフォルトで次の 3 つのインポート用クラスを提供しています。

  • BuiltinImporter: 組み込みモジュールをインポートするためのもの。
  • FrozenImporter: コンパイルされた frozen モジュールをインポートするためのもの。
  • PathFinder: モジュール検索パス上にあるモジュールをインポートするためのもの。

a) の説明は、このうちの PathFinder の挙動の一部を説明したものです。 a) の説明は PathFinder 以外のインポート用クラスにはあてはまりませんし、さらに PathFinder の場合でも __path__ に手が加えられた特殊なモジュールにはあてはまりません。

他方の b) の説明は、 PathFinder を実際の挙動も含めてより厳密に説明したものです。ただ、こちらもあくまでも PathFinder の説明であり、 BuiltinImporterFrozenImporter にはそのままあてはまりません。

(私の経験上)標準ライブラリ以外のモジュールを利用する場合、そのほとんどは PathFinder を利用する形になるので、実用上は b) のところまで押さえられていれば十二分ですが、 b) は他のインポートシステムのパターンはカバーできていないため、より厳密に言うならこれも不正確ということになるでしょう。

さらに、 Python のこのインポートシステム自体拡張することができるようなので、開発者が独自に「パッケージとモジュール」の形を決めて実装することもできるようです。独自に拡張されたインポートシステムのことも含めて考えると、 Python の「パッケージとモジュール」の定義・違いをわかりやすく説明するのはさらに困難になります。

まとめ


最後に、ここまでの内容をかんたんにまとめてみます。おおよそ次のような感じになるでしょうか。

  • シンプルに Python を利用する場合だけなら、 a) の理解で十分
  • 規模の大きなパッケージを開発・管理するような人は、 b) までは理解しておいた方がよさそう
  • Python そのものの開発に関わりたいような人にとっては、 b) の理解でも(おそらく)不十分

世の中のほとんどの人にとって「 円周率 = 3.14 」以上の説明が必要ないのと同じように(本人が知らなくてもその恩恵を受けているという点については無視するとして)、この「 Python のパッケージとモジュールの定義・違い」についても b) 以上の理解を必要とする人はどちらかというと少数派でしょう。

ですので、多くの Python ユーザにとっては、「 厳密に言うなら a) は間違いだけど、ふだんは a) で覚えておいて差し支えない 」程度に認識しておいて、 b) 以上の理解が必要になったときにはその都度調べて確認できるようにしておく、ぐらいがちょうどよいのではないかと思います(もちろん「とにかく使えさえしたら原理はどうだっていい」という人の場合は、理解する必要はありません)。

ちなみに最後にもうひとつ余談ですが、今回 Wikipedia で「円周率」を引いて驚いたのですが、 Wikipedia によると円周率は 2016 年現在で 22 兆桁(!)まで計算されているそうです。


以上、「 Python のパッケージとモジュールの違い」についての説明でした。「わかりやすかったよー」「役に立ったよー」「間違っているよー」という方はコメント等でお知らせいただけるとうれしいです。

以下、参考ページです。いずれも英語のページですが、 Python のパッケージとモジュールについての理解を深めたい方には一読の価値があります。


参考

2018/04/30

Python Tips: JSON を整形して表示したい

今回は JSON 形式の文字列を Python で整形して表示する方法をご紹介します。

今回は次の 2 つのパターンを取り上げてみます。

  • シンタックスハイライトなし
  • シンタックスハイライトあり

早速見ていきましょう。

シンタックスハイライトなし


シンタックスハイライトが特に要らない場合は、標準ライブラリの json だけを使えば OK です。

JSON が Python コード内で文字列として取得できている場合

JSON 形式の文字列が Python コードの中で取得できている場合は、いったん loads() で読み込んだ後に dumps() で再度ダンプし直すとよいでしょう。その際に dumps() のオプション引数 indent を指定することでインデントの大きさを決めることができます。

import json

JSON_SAMPLE = '{"_meta": {"hash": {"sha256": "hash"}, "pipfile-spec": 6, "requires": {"python_version": "3.6"}, "sources": [{"name": "pypi", "url": "https://pypi.python.org/simple", "verify_ssl": true } ] } }'
 
data = json.loads(JSON_SAMPLE)  
print(json.dumps(data, indent=2))

このコードの出力結果は次のとおりになります。

{
  "_meta": {
    "hash": {
      "sha256": "hash"
    },
    "pipfile-spec": 6,
    "requires": {
      "python_version": "3.6"
    },
    "sources": [
      {
        "name": "pypi",
        "url": "https://pypi.python.org/simple",
        "verify_ssl": true
      }
    ]
  }
}

JSON がファイルに格納されている場合

JSON 文字列がファイルに格納されている場合は、上の loads() の部分を load() に変えて、さらに、引数を文字列からファイルオブジェクトに変更すれば OK です。

また、 Python プログラムの中ではなくターミナル上で処理できればそれで十分な場合は、 json.tool モジュールを使う方法がお手軽でおすすめです。 json.tool は次の形で利用することができます。

$ python -m json.tool < data.json
{
  "_meta": {
    "hash": {
      "sha256": "hash"
    },
    "pipfile-spec": 6,
    "requires": {
      "python_version": "3.6"
    },
    "sources": [
      {
        "name": "pypi",
        "url": "https://pypi.python.org/simple",
        "verify_ssl": true
      }
    ]
  }
}

python -m json.tool に JSON 文字列を渡す方法には、上の「標準入力で渡す方法」の他に「ファイル名を引数として渡す方法」もあります。

$ python -m json.tool data.json

出力結果はどちらも同じです。

ちなみに、 python -m json.tool のヘルプは次のとおりになっています。

python -m json.tool -h
usage: python -m json.tool [-h] [--sort-keys] [infile] [outfile]

A simple command line interface for json module to validate and pretty-print JSON objects.

positional arguments:
  infile       a JSON file to be validated or pretty-printed
  outfile      write the output of infile to outfile

optional arguments:
  -h, --help   show this help message and exit
  --sort-keys  sort the output of dictionaries alphabetically by key

私は使ったことはありませんが、 dict 型のキーをアルファベット順(辞書順)に並べられる --sort-keys というオプションがあるようです。

標準ライブラリの json を使った方法については以上です。続いて シンタックスハイライトありの方法を見ていきましょう。

シンタックスハイライトあり

シンタックスハイライトを施して JSON を表示する機能は(私が知るかぎり) Python の標準ライブラリには無いので、何らかの非標準のライブラリを使用するか自分で書くかのどちらかになります。おすすめなのは pygments というライブラリを使用する方法です。

シンタックスハイライトなしの場合と同様に JSON が Python コード内で文字列として取得できている場合から見ていきましょう。

JSON が Python コード内で文字列として取得できている場合
pygments.highlight() 関数にコードの文字列を渡すと、シンタックスハイライトを施した形で文字列を返してくれるので、それを利用します。

import json

from pygments import highlight
from pygments.lexers import JsonLexer
from pygments.formatters import TerminalFormatter

JSON_SAMPLE = '{"_meta": {"hash": {"sha256": "hash"}, "pipfile-spec": 6, "requires": {"python_version": "3.6"}, "sources": [{"name": "pypi", "url": "https://pypi.python.org/simple", "verify_ssl": true } ] } }'

data = json.loads(JSON_SAMPLE)
formatted_data = json.dumps(data, indent=2)
print(highlight(formatted_data, JsonLexer(), TerminalFormatter()))

このコードの出力は次のとおりとなります。ターミナル上ではきれいにハイライトが行われて表示されます。

{
  "_meta": {
    "hash": {
      "sha256": "hash"
    },
    "pipfile-spec": 6,
    "requires": {
      "python_version": "3.6"
    },
    "sources": [
      {
        "name": "pypi",
        "url": "https://pypi.python.org/simple",
        "verify_ssl": true
      }
    ]
  }
}

出力をブラウザで利用したい場合等は、 TerminalFormatter の代わりに HtmlFormatter を利用することが利用できます。

import json

from pygments import highlight
from pygments.lexers import JsonLexer
from pygments.formatters import TerminalFormatter

JSON_SAMPLE = '{"_meta": {"hash": {"sha256": "hash"}, "pipfile-spec": 6, "requires": {"python_version": "3.6"}, "sources": [{"name": "pypi", "url": "https://pypi.python.org/simple", "verify_ssl": true } ] } }'

data = json.loads(JSON_SAMPLE)
formatted_data = json.dumps(data, indent=2)
print(highlight(formatted_data, JsonLexer(), TerminalFormatter()))

JSON がファイルに格納されている場合
JSON がファイルに格納されている場合は、もしターミナル上で扱えればよいだけであれば、 pygments が提供するコマンドラインツール pygmentize を使う方法がシンプルでおすすめです。

改行やインデントを保ったままハイライトだけができればよいのであれば、 pygmentize コマンドをそのまま使用すれば OK です。

$ pygmentize data.json
{"_meta": {"hash": {"sha256": "hash"}, "pipfile-spec": 6, "requires": {"python_version": "3.6"}, "sources": [{"name": "pypi", "url": "https://pypi.python.org/simple", "verify_ssl": true } ] } }

ファイルの拡張子が json の場合は自動で JSON と認識してくれるようです。拡張子が json 以外の場合は -l (--lexer) オプションでフォーマットが JSON であることを伝えれば OK です。

$ pygmentize -l json Pipfile.lock

改行やインデントをよきように調整してなおかつハイライトしてほしい場合は、標準ライブラリ json と組み合わせて使うとよいかと思います。 pygmentize コマンドも、対象のファイルが指定されなければ代わりに標準入力の文字列を処理してくれます。

$ python -m json.tool json_data.json | pygmentize -l json 
{
    "_meta": {
        "hash": {
            "sha256": "hash"
        },
        "pipfile-spec": 6,
        "requires": {
            "python_version": "3.6"
        },
        "sources": [
            {
                "name": "pypi",
                "url": "https://pypi.python.org/simple",
                "verify_ssl": true
            }
        ]
    }
}

ヘルプドキュメントの分量が多いのでここには掲載しませんが、 pygmentize には豊富なオプションがあるので、使ってみたい方は一度確認してから使ってみることをおすすめします。

$ pygmentize --help

以上です。

参考

2018/04/02

Python Tips:組み込みの名前を上書きしてしまったのを元に戻したい

Python で組み込み関数等の名前を上書きしてしまったときに元に戻す方法をご紹介します。

Python には、モジュールを import しなくても利用できる組み込みの関数があります。
例えば、 Python 3.6 の場合だと、その数は 60 以上にもなります。

abs() all() any() ascii() bin() bool() bytearray() bytes() callable() chr() classmethod() compile() complex() delattr()
dict() dir() divmod() enumerate() eval() exec() filter() float() format() frozenset() getattr() globals() hasattr() hash()
help() hex() id() input() int() isinstance() issubclass() iter() len() list() locals() map() max() memoryview()
min() next() object() oct() open() ord() pow() print() property() range() repr() reversed() round() set()
setattr() slice() sorted() staticmethod() str() sum() super() tuple() type() vars() zip() __import__()


これらの関数の名前を誤って上書きしてしまうと、元々あった関数の機能を使うことができません。多くの場合は変数名を変えればそれで済むのですが、 REPL や Jupyter を使っていると、 Python プロセスを起動し直す手間が大きく、稀に困ることがあります。

私の場合は、 idmapmax 等のシンプルな名前を変数名として使いたくなって、うっかり上書きしてしまうことがよくあります(他の言語のコードと Python コードを並行で触っているときによくやります)。

max = 10
max(10, 20, 5)
# => TypeError: 'int' object is not callable

結論としては、キーワード del を使って変数を削除すれば OK です。 del した後は元々の名前をまたそのまま利用できるようになります。

max = 10

del max

max(10, 20, 5)
# => 20

組み込みの名前は上書きしないように気をつけることが第一ですが、万が一上書きしてしまったときにはこういう方法があるということを覚えておくと便利です。

参考