プログラミング/python の履歴(No.5)

更新


公開メモ

import の仕組み

python では

  • module.py みたいな個々のファイルがモジュールと呼ばれ、
  • モジュールを複数入れたフォルダに _ _init_ _.py という空のファイルを置くと、 そのフォルダはパッケージとして扱われる(pukiwikiで表示が崩れないよう _ と _ の間にスペースを入れてます。悪しからず。)
  • import 時には package/module.py を package.module として参照可能

というのが基本なんだけど、驚くべきはここからで、

LANG:python
from some_package.some_module import xxx 

と書くとこれは感覚的には

LANG:python
from root.**.some_package.some_module import xxx

と書いたのと同じことになる。

つまり sys.path にある検索パスの配下のどこかで some_package.some_module が見つかれば、 「最初に見つかったもの」が import される(_ _init_ _.py によるパッケージ化を前提)。

これはどこで import したかにかかわらないので、
package1/package2/package3/module.py から、
package1/module.py を import pakcage1.module したりできる。

とにかく自分がどこにいるかは関係なく、つねに sys.path からの探索になる。

一方、パッケージ内では

LANG:python
from .some_file import xxx 

みたいな相対 import もできる。

ただしこれは自身がパッケージ名付きで import されていないとダメで、単体ファイルとして import された場合には「現在のモジュール」が空になってしまうため相対 import が

ImportError: attempted relative import with no known parent package

という分かりにくいエラーで落ちます。

フォルダ構造とモジュール構造がほぼ1:1なのに完全に1:1じゃないのでとてもややこしい。

_ _init_ _.py が置かれているファイルの単体 import なんて需要ゼロだろうに、 どうしてこのまま放置されているのか全く理解できない。

しかもこういうあやうい仕様なのにベストプラクティスが周知されていないのもとても不思議。

python import に関する覚書

初心者向けに、「罠」がそこらじゅうに敷き詰められてる。ほんと、えげつない。

  • パッケージの独立性を保つにはパッケージ内の内部参照に相対 import を使うことが推奨される。
  • 相対 import はパッケージ階層を前提にするので各フォルダに _ _init_ _.py を置いてパッケージとして明示する。
  • パッケージ内のファイルをパッケージ名も相対指定もなしで import すると独立ファイル扱いになってしまうので、そのファイルからの相対 import が壊れてエラーになる。
    • 起動時も、python -m package.module としてパッケージ名付きで起動しないとダメ
      • main.py を置けば python -m package だけで main.py を起動できる
    • notebook の実行にはパッケージ名を指定できないので、notebookから相対 import はできない。
      • notebook から同階層にあるモジュールをパッケージ名付きで import するには、その **親フォルダ** が sys.path の下になければならない。
      • 大抵親フォルダは参照されないので、手動でこれを追加しないと import できなくてエラーになる

コメント:

  • 相対 import じゃないと、どこかに同名のモジュールがあればバッティングする
  • がちがちにパッケージ名付けるとポータビリティが失われる
  • _ _init_ _.py 置かなくてもいい、と書かれた記事をよく見るけど、これも罠なので無視する
  • パッケージ内のモジュールをパッケージ名なしで import すると破綻する。理由が分からずとても困る。
  • notebook からの import も非常にややこしい

プロファイリング

python は普通に書くと思った以上に遅いことになる場合が多いので、 プロファイリングの結果を基に重い処理を numpy 側に移したりするのが役に立つ

いろいろ試した結果、特に並列処理を使ったバッチ処理が走るライブラリを使う場合には jupyter 上でのプロファイリングするとライブラリ関数の実行中に UI スレッドが動いて プロファイル結果が無茶苦茶になるようです。

したがって面倒でもプロファイルは Jupyter 外で行った方が間違いない。

前処理結果を dump する

jupyter 上で計算した結果を引き継ぎつつ jupyter 外で処理を回すには、前処理で得られたデータ(プロファイリングしたい処理への入力)を pickle.dump しておき、外部で動かす処理ではそれを呼んで動作を行う

Jupyter セル内で実行

LANG:python
import pickle
payload = {
    "param1": param1,
    "param2": param2,
    "param3": param3,
}
with open("payload.pkl", "wb") as f:
    pickle.dump(payload, f, protocol=pickle.HIGHEST?PROTOCOL)

で(通常の python データであれば)書き出せる

読み込んで動かす .py ファイル

do_profile.py

LANG:python
import pickle
import cProfile
import pstats

from my_module import func_to_profile

def main():
    with open("payload.pkl", "rb") as f:
        payload = pickle.load(f)

    prof = cProfile.Profile()
    prof.enable()
    refined = func_to_profile(**payload)
    prof.disable()

    prof.dump_stats("prof.out")

if __name__ == "__main__":
    main()

実行する

LANG:console
$ python do_profile.py

解析する

LANG:console
$ python -m pstats prof.out
Welcome to the profile statistics browser.
prof.out% strip        # strip directory names
prof.out% sort cumtime # sort by cumtime
prof.out% stats 40     # show first 40 items

これで、

my_module.py:1234(my_function)

みたいな関数ごとに

LANG:console
ncalls   tottime  percall  cumtime  percall filename:lineno(function)
呼び出し 自前処理 呼び出し 子も含む 呼び出し ファイル名:行番号(関数名)
回数     時間     あたり   全時間   あたり

のリストが出ます

特定の関数内から呼ばれている分だけを調べたければ、

LANG:console
prof.out% sort time
prof.out% callees my_module.py:1234(my_function)

などとします。

ipynb 向けのスニペット

必要なモジュールを確実に見つけられるように検索パスを広げる

LANG:python
from pathlib import Path
import sys

# 必要なモジュールを確実に見つけられるように検索パスを広げる
#
# from some_module.some_file import xxx は実質的に
# from root.**.some_module.some_file import xxx のように働くので、
# root の配下のどこかに some_module があれば確実に import できる
# しかし some_module と同列に置かれた ipynb では root が
# some_module になって、その下で some_module を探してエラーになる
# ここは root を十分上に持っていくための処理

# どれかが見つかった場所をルート扱いにする
MARKERS = ("pyproject.toml", ".git", ".vscode")
p = Path.cwd().resolve()
root = None
for parent in (p, *p.parents):
    if any((parent / m).exists() for m in MARKERS):
        root = parent
        break
if root is None:
    # 見つからなければさしあたり親ディレクトリにしておく
    root = Path.cwd().resolve().parents[0]

# 先頭がrootでなければ、既存のrootエントリを全部消してから先頭に入れる
print(f"Adding this folder to sys.path: {root}")
root_s = str(root)
if not sys.path or sys.path[0] != root_s:
    sys.path[:] = [p for p in sys.path if p != root_s]
    sys.path.insert(0, root_s)

オートリロード

ソースファイルが日本語を含む可能性があるならこれをまず実行

LANG: python
import builtins

# デフォルトでファイルは UTF-8 で読むように open を置き換える
# 日本語コメント入りの .py ファイルを autoreload しようとして落ちるのを避けるため
if "_original_open" not in globals():
    _original_open = builtins.open

    def open_utf8_default(path, mode="r", *args, **kwargs):
        # binary モードや明示指定がある場合は素通し
        if "b" not in mode and "encoding" not in kwargs:
            kwargs["encoding"] = "utf-8"
        return _original_open(path, mode, *args, **kwargs)

    builtins.open = open_utf8_default

で、これが自動リロードの設定。

LANG: python
# 自動リロードを設定
%reload_ext autoreload
%autoreload 2

必要に応じて依存モジュールを pip install する

LANG:python
ensure_package("trimesh")

みたいにしてモジュールがない場合に限って pip install できるようになる。

LANG:python
# 必要に応じて必要なモジュールをインストールする
import sys
import subprocess
import importlib.util

print(sys.executable)
print(f"sys.version: {sys.version}")

def _get_installed_version(dist_name: str) -> str | None:
    """
    Return installed distribution version (e.g., 'numpy' -> '1.26.4').
    Prefer importlib.metadata; fallback to pkg_resources for older Pythons.
    """
    try:
        # Python 3.8+
        from importlib.metadata import version, PackageNotFoundError  # type: ignore
        try:
            return version(dist_name)
        except PackageNotFoundError:
            return None
    except Exception:
        # Older Python or environment without importlib.metadata
        try:
            import pkg_resources  # type: ignore
            try:
                return pkg_resources.get_distribution(dist_name).version
            except Exception:
                return None
        except Exception:
            return None

def _get_module_dunder_version(import_name: str) -> str | None:
    """Try module.__version__ as a last resort."""
    try:
        import importlib
        m = importlib.import_module(import_name)
        v = getattr(m, "__version__", None)
        return v if isinstance(v, str) and v else None
    except Exception:
        return None

def ensure_package(import_name: str, dist_name: str | None = None, pip_args: list[str] | None = None) -> None:
    """
    Ensure a module is importable. If not, install it via pip.
    - import_name: the name used in `import ...`
    - dist_name  : the name used in `pip install ...` / metadata lookup (often same)
    """
    dist = dist_name or import_name

    if importlib.util.find_spec(import_name) is None:
        print(f"{import_name} not found. Installing {dist}...")
        args = [sys.executable, "-m", "pip", "install", dist]
        if pip_args:
            args.extend(pip_args)
        subprocess.check_call(args)
        # install後の表示(取れなければunknown)
        ver = _get_installed_version(dist) or _get_module_dunder_version(import_name) or "unknown"
        print(f"{import_name} installed. version={ver}")
    else:
        ver = _get_installed_version(dist) or _get_module_dunder_version(import_name) or "unknown"
        print(f"{import_name} already importable. version={ver}")

# ここに必要なパッケージを並べる
ensure_package("scipy")
ensure_package("trimesh")
ensure_package("embreex") # for raycast
ensure_package("fast_simplification", dist_name = "fast-simplification")

vscode 下で uv で python 使うおまじない

$ cat >pyproject.toml
# 必要ない lint エラーを非表示
[tool.ruff.lint]
ignore = ["E741", "E731"]

# バージョン指定
[dependency-groups]
dev = [
    "ipykernel>=7.2.0",
]

# 新しすぎるパッケージは入れない
[tool.uv]
exclude-newer = "1 week"

$ cat >.gitignore
__pycache__/
*.pyc
*.pyo
*.pyd
env/
output/

$ git init
$ uv python list     # 入手可能な python のバージョンを確認
$ uv python pin 3.14 # 適当なものを選んで固定
$ uv sync --dev      # 仮想環境と依存を同期
                     # Notebook カーネルを登録
$ uv run python -m ipykernel install --user --name tmdc-band --display-name "Python (tmdc-band)"

そのままだと .venv 環境が vscode に認識されないことがあるので、 必要に応じて Reload Window すると、.py や .ipynb で .venv/Scripts/python.exe をインタプリタとして開発を始められる。


Counter: 672 (from 2010/06/03), today: 2, yesterday: 3