nikkie-ftnextの日記

イベントレポートや読書メモを発信

nikkieと「ほげ言語」のパラドックス 〜"Pythonで考えていた"からインターフェースの理解が難しかった説〜

はじめに

にゃんにゃんにゃ〜ん!nikkieです。

先日のPHPカンファレンス「ほげ言語」のパラドックスを知りました。
自分の経験が「ほげ言語」のパラドックスと呼べるものだったんじゃないかと気づき、その思考ログを記事に残します。

目次

「ほげ言語」のパラドックス

「ほげ言語にあってPHPにない機能」(PHPカンファレンス関西2024)

知ったきっかけは、PHPカンファレンス関西のひさてるさんのトークです。

いろいろな言語の言語機能のうち、PHPにまだないものを挙げます。
で、ないのはしょうがないので、どうするかと考えていきます。

ポール・グレアム「普通のやつらの上を行け」

より詳しく知りたく、検索したところ以下が見つかりました。
http://practical-scheme.net/trans/beating-the-averages-j.html
「ほげ言語」のパラドックス

「ほげ」よりも力の弱い言語は、 明らかに力が弱い。彼が慣れ親しんだ機能が無いからだ。

しかし、このプログラマ氏が反対の方向に目を転じた時、彼は自分が見上げているのだということに 気付かないのだ。彼が目にするのは、変てこりんな言語ばかり。

「ほげ」プログラマは「ほげ」より力の強い言語の力の強さが認識できないわけです。
へんてこりん、おまけがついている言語という認識になってしまう。

彼にとっては「ほげ」で十分なのだ。何故なら彼は「ほげ」で考えているから。

パラドックスの部分は以下です:

帰納的に、全てのプログラミング言語の力の違いを見分けることが出来るのは、 最もパワフルな言語を理解するプログラマのみであるということになる

この「ほげ」のパラドックスのために、他の人の意見は参考にならない。

パラドックスの例:Pythonでインターフェースが分からなかった

ポール・グレアムまであたって、「ほげ言語」のパラドックス、私も経験していたことに気づきました。
それは、インターフェースという概念の理解です。

Pythonでのインターフェース(の1つのやり方)は、抽象基底クラスを定義して継承  

Pythonの文法にインターフェースはありません
この点に対して『エキスパートPythonプログラミング 改訂3版』では2つの解決策を示しています(17.3.1)1

後者の1案が抽象基底クラスです。
標準ライブラリのabcで提供されます。
https://github.com/asciidwango/ExpertPython3_Source/blob/887b151bc5f20b6b6e742eff1f18d4ab6e86d872/chapter17/interfaces_abc.py

from abc import (
    ABCMeta,
    abstractmethod
)


class IRectangle(metaclass=ABCMeta):
    @abstractmethod
    def area(self):
        """ 長方形の面積を返す
        """

IRectangleというインターフェース(実体はクラス)を継承して、インターフェースを実装します

class Rectangle(IRectangle):
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    def area(self):
        return self._width * self._height

インターフェースという概念を長らくつかめてこなかった

このように抽象基底クラスをインターフェースとして使うことをやってきたわけですが、振り返るとインターフェースを全然理解できていなかったと感じます。
書いているのはクラスの継承なので、クラスの継承として考えがちでした。
どこまでいってもインターフェースをクラスから切り離せない感覚です。

転機が訪れたのは、ちょうぜつ本(『ちょうぜつソフトウェア設計入門』)です。
3章や5章を読んで、インターフェースという概念がようやく飲み込めました。

3章では、インターフェース(抽象)をまず作ることで、具象があることにできているという効果を学びます。

そして5章では、SOLIDのI(インターフェース分離原則)でインターフェースは利用時の概念と学びます。
どう使えるかを表すだけで、どんな仕組みで提供されるかはインターフェースを実装する具象任せ

ちょうぜつ本によるブレイクスルーは大きくて、インターフェースという概念を押さえたことで、抽象基底クラスをインターフェースとして正しく使ったり、typing.Protocolを試したりと素振りが捗っています。

インターフェースが言語仕様にないPythonで考えているために、インターフェースの理解を深められていなかったんじゃないかな〜

言語(道具)がもたらすバイアス

「ほげ言語」のパラドックスの全文、また私が経験した例から、プログラミング言語がもたらすバイアスのようなものがあるように思われます。

「ほげ言語」のパラドックス(普通のやつらの上を行け)より

誰であれ、プログラムについて考える時には 自分が使うことになった言語に思考が支配されているから、その言語に満足してしまうんだ。

ひさてるさんの発表にはこんなスライドもありました。

前後左右といった相対的な向きを表す単語を持たない言語の部族もいる。
彼らの生き方には相対方向の概念が登場しない。

思うに、プログラミング言語が仕様としてサポートしない機能は、その言語を使う限り概念として理解するのが難しいのだと思います。
再度引きましょう

彼にとっては「ほげ」で十分なのだ。何故なら彼は「ほげ」で考えているから。

毎年言語を1つ学ぶ(『達人プログラマー』)

ここまで考えて、『達人プログラマー』の教えは的を射ているなと思い出しました。

毎年少なくとも言語を1つ学習する。(第2版 Kindle版 p.67)

これは知識ポートフォリオを充実させる方法の1つとして提案されます。

いつも使っている言語とは別の言語を学ぶことで、「ほげ言語」で考えるを相対化できるんじゃないかと私は期待しています。
例えば先日のYAPCでは伊藤直也さんから関数型プログラミングと型の話を聞き、非常に興味深かったです。

興味ベースでいいと思うので、「ほげ」以外にも他の言語も知っておくと、「ほげ」自体を相対化できて(=優れた点も限界も認識できて)、より使いこなせそうですよね。

終わりに

「ほげ言語」のパラドックスについて思索を展開してきました。

  • 「ほげ言語」のパラドックスは、プログラミング言語について他の人の意見は参考にならないというもの(と理解)
  • 「ほげ言語」を使っていると、「ほげ」で考えてしまい「ほげ」で十分(他の言語のパワフルさを認識できない)
    • 道具(言語)がもたらすバイアス
  • 過去の私がインターフェースという概念が理解できなかったのは、インターフェースが言語仕様にないPythonで考えていたからではないか
  • 「ほげ言語」(がもたらすバイアス)を相対化していきたい:例えば、新しい言語を学ぶ

今回の施策を通じて、ポール・グレアムから『達人プログラマー』までつながり、新しい言語を学ぶって大事なことだなと再確認できました。

思索のきっかけツイート


  1. 最新は改訂4版です。目次だけ見たところ、構成は大きく変わってそう!

ツライ時に聞きたくなる「たとえ夢をなくしたとしても未来がなくなるわけじゃない」

今日は現実の厳しさに打ちのめされており、いつもとテイストが違います

目次

ツライ😭

Act-4 Day-2の現地チケットが、取れませんでした。

ツアーの最後を締めくくるAct-4は、本ツアー最多人数でお届けいたします!
DAY2ではなんと、39人が全員出演いたします!

そんな時に思い出す『白い砂のアクアトープ』11話

とにかくツラくて、ヤサグレモードになって、気持ちも沈むんですが、聞きたくなるセリフがあります2

ここがなくなっても、終わりなんてことはないよ!絶対に

たとえ夢をなくしたとしても、未来がなくなるわけじゃない!

しみる...😭

祖父が経営している水族館3
くくるは小さい頃からそこが大好きで大好きで、でもそんな水族館が閉館せざるを得ない。
その現実を受け入れられなくてくくるは水族館に閉じこもり、駆けつけた風花に心情を吐露します。

そんなくくるに風花が言ったのが上記の台詞(抜粋なので気になる方は本編をどうぞ)。
これ、今日の私にも言ってるよね?

ちなみにくくるの声は、ミリオンの七尾百合子ちゃん4図書室の暴走特急)です(伊藤美来さん)

現実を生き抜け

現実は厳しいです。
打ちのめされることもあります。
ただ、私の場合は、再び向き合うためにアニメの力を借ります

この現実を生き抜くための力の一部として、俺の作品を必要としてくれるんだったら (略)

ありがとう、白い砂のアクアトープ

たとえ夢をなくしたとしても、未来がなくなるわけじゃない

(GPT-4に画像化してもらいました)

P.S.

未来は、ある。ありがとう、ユナイテッド・シネマ豊洲


  1. 2023年夏のアニメ先行上映以来、めちゃめちゃハマっています
  2. Amazon Prime Videoなどにあります
  3. 『白い砂のアクアトープ』は、過去にも取り上げています
  4. 思い入れがあります

続・Ryeのworkspaceで複数のパッケージを同時に開発している時に、workspaceのルートでmypyを流す(strictモードで流すための対応案)

はじめに

START THE DREAM😭😭😭 nikkieです。

先日のRyeのworkspaceとmypy(pytest)の記事のアップデートをお届けします。
mypyをstrictモードで流すために対処せねばならぬ点がありました。

目次

前回までの、Ryeのworkspaceのルートで流すシリーズ

mypy --explicit-package-bases .
カレントディレクトリからのモジュール名とすることで、testsというモジュール名の重複を解消します

pytest --import-mode importlib
sys.pathを変更しないimportlibモードにより、2つあるtestsモジュールそれぞれをimportしてテストを実行できます

workspaceのルートでmypyをstrictモードで流したい

実装を追加

クラスを継承する実装を追加しました(awesome.diagrams

.  # rye-workspace-example ディレクトリ
├── .venv
├── pyproject.toml
├── src/
│   └── rye_workspace_example/
│       └── __init__.py
├── awesome/
│   ├── pyproject.toml
│   ├── src/
│   │   └── awesome/
+│   │       ├── diagrams/
+│   │       │   ├── __init__.py
+│   │       │   ├── base.py
+│   │       │   └── core.py
│   │       └── __init__.py
│   └── tests/
│       ├── __init__.py
│       └── test_awesome.py
└── fabulous/
    ├── pyproject.toml
    ├── src/
    │   └── fabulous/
    │       └── __init__.py
    └── tests/
        ├── __init__.py
        └── test_fabulous.py

awesome/src/awesome/diagrams/base.py

from abc import ABCMeta, abstractmethod


class ShapeInterface(metaclass=ABCMeta):
    @abstractmethod
    def area(self) -> int:
        """Calculate rectangle's area."""

awesome/src/awesome/diagrams/core.py

from awesome.diagrams.base import ShapeInterface


class AwesomeRectangle(ShapeInterface):
    def __init__(self, width: int, height: int) -> None:
        self._width = width
        self._height = height

    def area(self) -> int:
        return self._width * self._height

mypyをstrictモードで流すと「Class cannot subclass」エラー

リポジトリルート(=カレントディレクトリ)のpyproject.tomlではmypyを以下のように設定しています

[tool.mypy]
ignore_missing_imports = true
explicit_package_bases = true
% rye run mypy . --strict
src/rye_workspace_example/__init__.py:1: error: Function is missing a return type annotation  [no-untyped-def]
fabulous/src/fabulous/__init__.py:1: error: Function is missing a return type annotation  [no-untyped-def]
awesome/src/awesome/diagrams/core.py:4: error: Class cannot subclass "ShapeInterface" (has type "Any")  [misc]
awesome/src/awesome/__init__.py:1: error: Function is missing a return type annotation  [no-untyped-def]
Found 4 errors in 4 files (checked 14 source files)

「Function is missing a return type annotation」はrye initで生成されたコードに型ヒントを書けば解消するので、ここでは取り上げません。

以下を解決したいです

awesome/src/awesome/diagrams/core.py:4: error: Class cannot subclass "ShapeInterface" (has type "Any") [misc]

結論:このようにして解決しました

.  # rye-workspace-example ディレクトリ
├── .venv
├── pyproject.toml
├── src/
│   └── rye_workspace_example/
│       └── __init__.py
├── awesome/
│   ├── pyproject.toml
│   ├── src/
│   │   └── awesome/
│   │       ├── diagrams/
│   │       │   ├── __init__.py
│   │       │   ├── base.py
│   │       │   └── core.py
│   │       └── __init__.py
-│   └── tests/
+│   └── tests_awesome/
│       ├── __init__.py
│       └── test_awesome.py
└── fabulous/
    ├── pyproject.toml
    ├── src/
    │   └── fabulous/
    │       └── __init__.py
-    └── tests/
+    └── tests_fabulous/
        ├── __init__.py
        └── test_fabulous.py
  • mypyに--explicit-package-basesを指定するのをやめました
  • pytestはtestsという名前のディレクトリを見ているわけではないので影響はありません
    • pytestが見ているのはtest_*.pyまたは*_test.pyという名前のファイルだけです
    • testsディレクトリを区別したので--import-mode importlibを外したprependモードでも動きます

なぜmypyは「Class cannot subclass "ShapeInterface" (has type "Any")」というエラーを出したのか

mypyは型チェックでimport文を見つけたとき、importされるモジュールまで追従(follow)しようとします。
https://mypy.readthedocs.io/en/stable/running_mypy.html#how-mypy-handles-imports
結果、追従できる場合(follow)と、追従できない場合(unfollow)があります。

追従できない場合、そのモジュールをAny型として扱います。
https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports

If you get any of these errors on an import, mypy will assume the type of that module is Any, the dynamic type.

上記のエラーは、from awesome.diagrams.base import ShapeInterfaceとしてimportしたShapeInterfaceがAny型であるために、Any型をサブクラスにする実装に「それはできない」と言っているわけです。

ではなぜ、ShapeInterfaceがAny型として扱われるのでしょうか?
言い換えると、なぜShapeInterfaceのimportが追従できないのでしょう?

ここに--explicit-package-basesの指定の副作用があると理解しました。
このフラグを指定すると、カレントディレクトリからのモジュール名となるので、ShapeInterfaceのあるファイルはawesome.src.awesome.diagrams.baseというモジュール名になります(カレントディレクトリはリポジトリルート=workspaceのルート)。
一方importするのはawesome.diagrams.baseですから、モジュール名が一致しません
そのため、mypyはimportを追従できず、Any型として扱っていると考えています(これも事象を説明できている仮説にすぎず、反証可能性は残っています)

試しにフラグを変えると、ShapeInterfaceのimportで追従できていないことが分かります。

% rye run mypy . --disallow-any-unimported  
awesome/src/awesome/diagrams/core.py:4: error: Base type ShapeInterface becomes "Any" due to an unfollowed import  [no-any-unimported]
Found 1 error in 1 file (checked 14 source files)

なお、ShapeInterfaceをAwesomeRectangleと同じファイルに定義した場合は、(importがなく、Any型としても扱われないので)上記エラーは発生しません

どう解決したか

--explicit-package-basesは、awesomeとfabulousで同名のtestsディレクトリ(モジュール)を区別するためのものでした。
しかしながら、これが原因でShapeInterfaceのimportを追従できていません。
そこで私は--explicit-package-basesを外すことにしました。
外すことでShapeInterfaceのあるファイルのモジュール名はawesome.diagrams.baseとなり、実装のimportと一致します(src下のawesome__init__.pyがあるためにこのようになります。詳しくは前回のmypy編記事の「ケース2:デフォルトの場合」を参照ください)

外した場合、当初のawesomeとfabulousで同名のtestsディレクトリが区別できないというエラーを解決する必要があります。
ここまでの対応での学びから、testsディレクトリの名前を変えて区別することにしました。

これを決断した理由として、pytestはtestsという名前のディレクトリを見ていないことがあります。
https://docs.pytest.org/en/8.0.x/how-to/usage.html

This will execute all tests in all files whose names follow the form test_*.py or \*_test.py in the current directory and its subdirectories.

test_で始まる、または、_testで終わる名前のPythonファイルを見ているだけです。

この設定値python_filesについて言及するドキュメントもあります。
https://docs.pytest.org/en/8.0.x/example/pythoncollection.html#changing-naming-conventions

以上、--explicit-package-basesをやめ、testsディレクトリの名前を変えて区別することで、strictなmypyも(pytestも)workspaceのルートで流せるようになりました。

testsディレクトリのrenameについては、mypyの設定を利用して__init__.pyを置く方法も考えられます。
しかし私は、パッケージとしては不要な__init__.pyを置きたくなかったので、testsディレクトリの名前を変えて対処しています。

終わりに

Ryeのworkspaceに関しては進んでは戻るという状態になりましたが、一通り対処できたと思います。
mypyやpytestがモジュールをどう扱っているか、理解が深まりました。
一方Ryeはrye sync簡単に環境構築できることが喧伝されますが、モノレポで使う際のハマりどころなど、全てにわたってeasyかと考えると私には期待外れな部分もあるという印象です(界隈でハイプになってるのかなと思います)

strictのmypyとpytestが動くようになったので私の中では解決ですが、今後対処が必要なエラーが発生したらこのシリーズを更新していきます。

Ryeのworkspaceで複数のパッケージを同時に開発している時に、workspaceのルートでpytestを流す(ModuleNotFoundErrorを--import-mode importlibで解消)

はじめに

郁原ゆうさん1、お誕生日おめでとうございます。nikkieです。

Ryeを使った開発をきっかけに、pytestのドキュメントに当たりました。
なぜpytestがModuleNotFoundErrorを送出するのか、少し理解が深まったように感じています(sys.pathが絡んでるんだ!)

目次

Ryeのworkspaceの例

Ryeの機能のworkspaceとは、開発中の複数のパッケージを1つの仮想環境で管理できる仕組みです(詳しくは姉妹編の記事に譲ります)。
例として、モノレポでawesomeパッケージとfabulousパッケージを同時に開発している状況を用意しました。

.  # rye-workspace-example ディレクトリ
├── .venv
├── pyproject.toml
├── src/
│   └── rye_workspace_example/
│       └── __init__.py
├── awesome/
│   ├── pyproject.toml
│   ├── src/
│   │   └── awesome/
│   │       └── __init__.py
│   └── tests/
│       ├── __init__.py
│       └── test_awesome.py
└── fabulous/
    ├── pyproject.toml
    ├── src/
    │   └── fabulous/
    │       └── __init__.py
    └── tests/
        ├── __init__.py
        └── test_fabulous.py

workspaceのルートでpytestを流したい

ここでは workspaceのルート=リポジトリルート です。
pytestコマンド2を流したいのですが、単に実行するとエラーが送出されます。

% rye run pytest
================================== test session starts ==================================
platform darwin -- Python 3.12.0, pytest-8.0.1, pluggy-1.4.0
rootdir: /.../rye-workspace-example
collected 1 item / 1 error                                                              

======================================== ERRORS =========================================
___________________ ERROR collecting fabulous/tests/test_fabulous.py ____________________
ImportError while importing test module '/.../rye-workspace-example/fabulous/tests/test_fabulous.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
.../.rye/py/cpython@3.12.0/install/lib/python3.12/importlib/__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
E   ModuleNotFoundError: No module named 'tests.test_fabulous'
================================ short test summary info ================================
ERROR fabulous/tests/test_fabulous.py
!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!
=================================== 1 error in 0.04s ====================================

それぞれ個別に指定すると何も問題なく流れます

% rye run pytest awesome 

collected 1 item                                                                        

awesome/tests/test_awesome.py .                                                   [100%]

=================================== 1 passed in 0.00s ===================================

% rye run pytest fabulous

collected 1 item                                                                        

fabulous/tests/test_fabulous.py .                                                 [100%]

=================================== 1 passed in 0.00s ===================================

pytestと実行したときのModuleNotFoundErrorの解決法を調べていきます。
モジュールが見つからないってどういうこと?

結論:ModuleNotFoundErrorを解決するには

--import-mode importlibの指定を追加します。

% rye run pytest --import-mode importlib
================================== test session starts ==================================
platform darwin -- Python 3.12.0, pytest-8.0.1, pluggy-1.4.0
rootdir: /.../rye-workspace-example
collected 2 items                                                                       

awesome/tests/test_awesome.py .                                                   [ 50%]
fabulous/tests/test_fabulous.py .                                                 [100%]

=================================== 2 passed in 0.01s ===================================

pytestのcollectの様子を見てみる

今回の事象はググってもなかなか合致する事象が見つからなかったのですが、pytestの動きを見て分かってきたことがありました(姉妹編のmypyで同様の対応をしていたというのも大きいです)。

pytestコマンドがテストのファイルを収集(collect)する様子を眺めます。
「Finding out what is collected」
https://docs.pytest.org/en/8.0.x/example/pythoncollection.html#finding-out-what-is-collected

% rye run pytest --collect-only         
================================== test session starts ==================================
platform darwin -- Python 3.12.0, pytest-8.0.1, pluggy-1.4.0
rootdir: /.../rye-workspace-example
collected 1 item / 1 error                                                              

<Dir rye-workspace-example>
  <Dir awesome>
    <Package tests>
      <Module test_awesome.py>
        <Function test_awesome>

(送出されるエラーは共通なので省略しています)

これを見て仮説として思ったのは、testsというPackageが共通だからではないかということです。
awesomeディレクトリの下のtestsと、fabulousディレクトリの下のtestsがあります。
この仮説と--import-modeの存在が結び付き、この事象は解決できました

なお、より詳しい情報が取得できる調査方法をご存じの方はぜひ教えてください!

Import modes(ドキュメント「pytest import mechanisms and sys.path/PYTHONPATH」)

https://docs.pytest.org/en/8.0.x/explanation/pythonpath.html#import-modes を見ていきましょう。
--import-modeに指定できる値は3つあります

  1. prepend(デフォルト)
  2. append
  3. importlib

1. prepend(デフォルト)

  • 各モジュール(test_foo.pyなどのファイル)を含むディレクトリのパスを、まだなければsys.pathの先頭に挿入する3
  • (sys.pathを変更してから)importlib.import_module関数でimportする

sys.pathとは
https://docs.python.org/ja/3/library/sys.html#sys.path

モジュールを検索するパスを示す文字列のリスト。

ディレクトリのパスをsys.pathに追加するから、pytestはモジュールをimportできているんですね!

prepend(=sys.pathの先頭に追加)モードは、テストディレクトリツリーがパッケージによって整えられていないとき、テストモジュールの名前が一意であることを要求する4とあります。

2. append

こちらもsys.pathを変更するモードです。
prependと違って(先頭ではなく)末尾に追加します。

  • 各モジュールを含むディレクトリのパスを、まだなければsys.pathの末尾に挿入する5
  • (sys.pathを変更してから)importlib.import_module関数でimportする

このモードのメリットとして、テスト対象のパッケージ(pkg_under_test)はソースコードでなく、インストールされたバージョンで実行できる6と説かれていました。
sys.pathの末尾に追加なので、それよりも先にあるsite-packagesへのパスを見てインストールされたバージョンをテストできるという理解です(詳しくはドキュメントの例をどうぞ)。

3. importlib

sys.pathを変更しません7
pytest 6.0から導入されたそうです。

sys.pathを変更しないので、テストモジュールの名前も一意でなくてよくなります8

--import-mode importlibがRyeのworkspaceに効く理由

collectの動きはprependモードで見ていました。
私の理解ですが、sys.pathの先頭にawesomeのtestsディレクトリをimportするためのパスが追加されたと思われます。
このprependによりtestsモジュールはawesome/testsからimportという挙動となり、fabulous側のtestsディレクトリでもこれが参照されます。
しかし、awesome/tests以下にtest_fabulous.pyはないため、ModuleNotFoundErrorが送出されたと考えています。

注意事項ですが、ここで述べたのは仮説にすぎず、printデバッグなどで確認できていないため、反証される可能性があります

--import-mode importlibで今回の事象は解決できました。
また、pytestのドキュメントにもimportlibの指定をオススメする箇所9があります。
「じゃあimportlibモードの指定が完全上位互換じゃん!」と思っちゃいそうですが、「Import modes」の箇所によればそうではなく欠点もあるそうです。
このあたりはまたの機会に。

P.S. PyCon APAC 2023よりModuleNotFoundErrorのトーク

このトークアーカイブで確認していたおかげで解決できたフシもあります。

P.S. 姉妹編 workspaceのルートでmypyを流したい

変更履歴


  1. エミリーちゃんのキャストさんです
  2. 単にpytestとだけ叩くと、カレントディレクトリから下のtest_*.py*_test.pyのテストが実行されます 「This will execute all tests in all files whose names follow the form test*.py or *test.py in the current directory and its subdirectories.https://docs.pytest.org/en/8.0.x/how-to/usage.html
  3. 原文 the directory path containing each module will be inserted into the beginning of sys.path if not already there, and then imported with the importlib.import_module function.
  4. 原文 This requires test module names to be unique when the test directory tree is not arranged in packages, because the modules will put in sys.modules after importing.
  5. the directory containing each module is appended to the end of sys.path if not already there, and imported with importlib.import_module.
  6. ref: 「the tests will run against the installed version of pkg_under_test when --import-mode=append is used」(なお、src layoutを使うことにも言及されています。これはまたの機会に)
  7. ref: 「doesn’t require changing sys.path」
  8. 原文 For this reason this doesn’t require test module names to be unique.
  9. 「For historical reasons, pytest defaults to the prepend import mode instead of the importlib import mode we recommend for new projects.」 https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-an-import-mode

Ryeのworkspaceで複数のパッケージを同時に開発している時に、workspaceのルートでmypyを流す(error: Duplicate module named "..."を--explicit-package-basesで解消)

はじめに

ごみけついきたい、nikkieです。

Ryeを使った開発をきっかけに、mypyのドキュメントにあたりました。
mypyにディレクトリのパスを渡した時にどう動いているか、少し理解が深まった感覚です1

目次

Ryeのworkspace

https://rye-up.com/guide/workspaces/

Ryeにはworkspaceという概念があります。
複数のパッケージを開発しているときに選択肢に入ると認識しています。

Afterwards all projects within that workspace share a singular virtualenv.

workspaceとして設定すると、Ryeは1つの仮想環境で開発環境を管理してくれます2

workspaceの例

百聞は一見に如かず!

.  # rye-workspace-example ディレクトリ
├── .venv
├── pyproject.toml
├── src/
│   └── rye_workspace_example/
│       └── __init__.py
├── awesome/
│   ├── pyproject.toml
│   ├── src/
│   │   └── awesome/
│   │       └── __init__.py
│   └── tests/
│       ├── __init__.py
│       └── test_awesome.py
└── fabulous/
    ├── pyproject.toml
    ├── src/
    │   └── fabulous/
    │       └── __init__.py
    └── tests/
        ├── __init__.py
        └── test_fabulous.py

モノレポでawesomeパッケージとfabulousパッケージを同時に開発しています。
それぞれディレクトリを作ってrye initしました。

リポジトリルートのpyproject.tomlの設定

[tool.rye.workspace]
members = ["awesome", "fabulous"]

rye syncを叩くと、リポジトリルートの.venvにawesomeもfabulousも全部インストールされます(editable installです)

requirements.lock

-e file:awesome
-e file:fabulous
-e file:.

インストールされたことの確認
(rye initでできたpyproject.tomlのscriptのところを少しいじっています)

% rye run hello
Hello from rye-workspace-example!
% rye run ahello
Hello from awesome!
% rye run fhello
Hello from fabulous!

リポジトリルートからmypyを流したい

このworkspaceで、リポジトリルートからmypy .と実行したいです3

単に実行するとエラーが送出されます。

% rye run mypy .
fabulous/tests/__init__.py: error: Duplicate module named "tests" (also at "./awesome/tests/__init__.py")
fabulous/tests/__init__.py: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules for more info
fabulous/tests/__init__.py: note: Common resolutions include: a) using `--exclude` to avoid checking one of them, b) adding `__init__.py` somewhere, c) using `--explicit-package-bases` or adjusting MYPYPATH
Found 1 error in 1 file (errors prevented further checking)

それぞれ個別に指定すると何も問題なく流れるんですよ!

% rye run mypy awesome
Success: no issues found in 3 source files
% rye run mypy fabulous
Success: no issues found in 3 source files

mypy .と実行したときの「Duplicate module named "tests"」の解決法を調べました。
そもそもなんでうまくいかないのかがワカンナイヨー

結論:Duplicate module namedを解決するには

mypy --explicit-package-bases .とフラグを指定します。
ちょうどnoteとして案内されていますね(c)

% rye run mypy --explicit-package-bases .
Success: no issues found in 7 source files

エラーメッセージ中のリンク https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules を見た末に「--explicit-package-bases」を指定する必要があると理解しました。

「Mapping file paths to modules」(mypyがファイルとモジュールの対応を付けるやり方)

mypy [files ...]とファイルパスを渡した時に、どのようにファイルをモジュールに対応付けるのかが述べられている箇所です。

mypyに渡されたファイルの扱い

  • コマンドラインで与えられた、ファイルに対応するパスをmypyはすべてチェックする4
  • --exclude引数を考慮した後で、与えられたディレクトリパスの中の.pyまたは.pyiで終わる全てのファイルを再帰的に見つけ、チェックする5
  • チェックする各ファイルについて、mypyはファイルとモジュールの完全修飾名を関連させようとする6

3点目について、完全修飾名とは、用語集より
https://docs.python.org/ja/3/glossary.html#term-qualified-name

モジュールへの参照で使われると、完全修飾名 (fully qualified name) はすべての親パッケージを含む全体のドット名表記

ここでの例は、ファイルがproject/foo/bar/baz.pyのとき

  • 完全修飾名はfoo.bar.baz
  • ディレクトリprojectがmypyのモジュール検索パスに追加されます

モジュールの完全修飾名の決め方

mypyのオプション--no-namespace-packages--explicit-package-basesによって決まるとあります。

ケース1:--no-namespace-packagesが指定された場合

__init__.pyまたは__init__.pyiの有無によります。
__init__.pyまたは__init__.pyiを見つける限り、mypyはディレクトリツリーを上にたどり続けます7

ここで挙げられている例は以下です。

.
└── pkg/
    ├── __init__.py
    └── subpkg/
        ├── __init__.py
        └── mod.py

mod.pyに対応する完全修飾名ですが、__init__.pyがあるので subpkg -> pkg とたどっていきます。
結果、完全修飾名はpkg.subpkg.modとなります。

ケース2:デフォルトの場合(--namespace-packagesが有効で、--explicit-package-basesは無効)

デフォルトでは、__init__.py__init__.pyiも持たないディレクトリもパッケージと見なします。
上位(原語はtop-level)のパッケージを決めるために、mypyはファイルの親のディレクトリをすべて見て、__init__.pyまたは__init__.pyiを持つ最上位のディレクトを用います8

例を見てみましょう。

pkg/
 __init__.py
 a/
  b/
   c/
    d/
     mod.py

mod.pyに対応する完全修飾名ですが、上位のパッケージは__init__.pyを持つpkgと決まります。
それにより、pkg.a.b.c.d.modとなります。

ケース3:--explicit-package-bases__init__.pyを配置できない場合)

--explicit-package-basesが指定されると、mypyは以下3つのうちから、最も近い親ディレクトを見つけ出します9

例はこちら(__init__.pyがありませんね)

.
└── src/
    └── namespace_pkg/
        └── mod.py

MYPYPATHで指定したsrcが使われます(カレントディレクトリよりもモジュールに近いと理解しました)。
srcからの相対パスで完全修飾名になるので、mod.pyに対応する完全修飾名はnamespace_pkg.modとなります。

ケース3の--explicit-package-basesがRyeのworkspaceに効く理由

そもそもなぜ「Duplicate module named "tests"」というエラーになっているかですが、デフォルトのケース2で動いたことで完全修飾名の重複が発生しています。
これは-vを指定することで確認できます

LOG:  Found source:           BuildSource(path='./awesome/tests/__init__.py', module='tests', has_text=False, base_dir='/.../rye-workspace-example/awesome', followed=False)
LOG:  Found source:           BuildSource(path='./fabulous/tests/__init__.py', module='tests', has_text=False, base_dir='/.../rye-workspace-example/fabulous', followed=False)

testsというモジュール名が重複していますね(testsが__init__.pyを持つ最上位ディレクトリに当たります)

ケース2の説明からリポジトリルートに__init__.pyを配置すれば、rye-workspace-example.awesome.testsrye-workspace-example.fabulous.tests となって区別できそうですよね。
ところがこのアイデアは、rye-workspace-exampleがハイフンを含んでいるためにモジュール名として使えず、実現できません10

ケース3をそのまま(MYPYPATHやmypy_pathを設定せずに)実行すると、カレントディレクト(=リポジトリのルート)からの相対パスになります。
これは__init__.py配置案と結果的には同様に、awesome.tests と fabulous.tests とを区別できます!

LOG:  Found source:           BuildSource(path='./awesome/tests/__init__.py', module='awesome.tests', has_text=False, base_dir='/.../rye-workspace-example', followed=False)
LOG:  Found source:           BuildSource(path='./fabulous/tests/__init__.py', module='fabulous.tests', has_text=False, base_dir='/.../rye-workspace-example', followed=False)

こうしてエラーを解決できました🙌

なお、完全修飾名の基点が変わるので、srcの方も完全修飾名は変わります

-LOG:  Found source:           BuildSource(path='./awesome/src/awesome/__init__.py', module='awesome', has_text=False, base_dir='/.../rye-workspace-example/awesome/src', followed=False)
+LOG:  Found source:           BuildSource(path='./awesome/src/awesome/__init__.py', module='awesome.src.awesome', has_text=False, base_dir='/.../rye-workspace-example', followed=False)

P.S. 名前空間パッケージ

ケース1・2のフラグについて少しだけ

Pythonのパッケージには2種類あります
https://docs.python.org/ja/3/glossary.html#term-package

ケース1で指定した--no-namespace-packagesは、名前空間パッケージなし(=regular packageオンリー)と言っているわけですね。
なので、regular packageが持つ__init__.pyが関心事です。

ケース2は--namespace-packagesが有効ですから、regular packageもnamespace packageも両方ありな状況です。
regular packageを見つけ、その中はnamespace packageもありとして扱っているように思われます

変更履歴


  1. このエントリの元ツイートです
  2. 以前書いたモノレポでのpipの悩みは解消していますね
  3. リポジトリルートのpyproject.tomlに[tool.rye.scripts]でショートカットコマンドを用意したいという動機もあります ref: https://rye-up.com/guide/pyproject/#toolryescripts
  4. 原文 Mypy will check all paths provided that correspond to files.
  5. 原文 Mypy will recursively discover and check all files ending in .py or .pyi in directory paths provided, after accounting for --exclude.
  6. 原文 For each file to be checked, mypy will attempt to associate the file (e.g. project/foo/bar/baz.py) with a fully qualified module name (e.g. foo.bar.baz). The directory the package is in (project) is then added to mypy’s module search paths.
  7. 原文 That is, mypy will crawl up the directory tree for as long as it continues to find __init__.py (or __init__.pyi) files.
  8. 原文 Specifically, mypy will look at all parent directories of the file and use the location of the highest __init__.py[i] in the directory tree to determine the top-level package.
  9. 原文 With --explicit-package-bases, mypy will locate the nearest parent directory that is a member of the MYPYPATH environment variable, the mypy_path config or is the current working directory.
  10. renameするという道もありますが、そこまでするならケース3の方がよいと思います

Ryeで環境構築して開発中の自作Pythonライブラリを含んだDockerイメージをビルドする

はじめに

ミリシタ新イベ、転天革命じゃん...😇 nikkieです。

今回はRyeに慣れるために素振りしたクソライブラリをクソDockerイメージにしていきます!

目次

前提:Ryeに慣れるために素振りしたクソライブラリ

からあげ皇帝のunkoの系譜を汲み、先日Ryeバージョンを爆誕させました

このクソライブラリunkoをインストール済みのDockerイメージを作るのがこの記事のゴールとなります。

結論

  • requirements.lockからeditable installの行を除き、依存ライブラリを先にインストール
  • ソースコードをCOPYして、インストール
  • マルチステージビルドで、site-packages以下をCOPY!
% docker build -f docker/Dockerfile -t unko-by-rye:example .
% docker run --rm -it unko-by-rye:example hello
puripuri
% docker run --rm -it unko-by-rye:example python -q
>>> from unko import deru
>>> deru()
puripuri

Dockerイメージビルドのための戦略を練る

まずRyeが生成したファイルのうち、Dockerイメージのビルドに関連するものを洗い出します(太字で示します)

  • requirements.lock
    • 現状のunkoは依存がないトイプロジェクトですが、実プロジェクトで使える方法を知りたいので、適当に行を追加することにします
  • requirements-dev.lock
    • こちらは開発環境をsyncさせるのにとても役立つファイルです。開発で使う依存は今回作るDockerイメージには不要なので扱いません
  • src
    • ここにクソライブラリの実体があります
    • pyproject.tomlにプロジェクトのメタデータがありますね

requirements.lockにある依存をDockerイメージにインストール

Ryeはrequirements.lockやrequirements-dev.lockを元に、魔法のように開発環境を再現させます(rye sync)。
魔法の仕掛けは以下

-e file:.
kojo-fan-art==0.1.1
  • -eの行はeditable installです(--editableとも指定できます)
  • 依存ライブラリについてはバージョン固定して表されています1

editable install

https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs

Editable installs allow you to install your project without copying any files.

通常のpip installPyPIからライブラリの実装をダウンロードし、site-packages以下に保存します。
例えば、仮想環境を使っていたら、.venv/lib/python3.12/site-packages2のようになります

開発中のライブラリも同様にsite-packages以下にコピーする運用だと、ソースコードをいじった後毎回pip installが必要です。
これは面倒ですし、たまたまpip installを忘れてしまって期待通り動かず、開発中に混乱してしまうかもしれません。

そこでeditable installの出番となります!
開発中のライブラリのソースコード一度だけeditable installしておけば、いくら編集してもそれが反映されます
文字通り、ソースコードを編集可能なインストールです!

Dockerイメージのマルチステージビルドとeditable install

私はDockerイメージを作る以上はサイズが小さいものを作りたいので、マルチステージビルドしていきます(取り回しやすいので、小さいは、正義!)。

マルチステージビルドではDockerイメージ間でsite-packages以下をコピーします。
ですが、requirements.lockの記載(editable install)とは噛み合いません。
そこで、Dockerイメージのビルドの中では、通常のinstallをしてソースコードをsite-packages以下に置くように工夫します。

Ryeのコミュニティに見つけた工夫

Rye + Docker · mitsuhiko rye · Discussion #239 · GitHub

WORKDIR /backend
COPY ./requirements.lock /backend/
RUN sed '/-e/d' requirements.lock > requirements.txt
RUN pip install -r requirements.txt
  • requirements.lockだけDockerイメージの中にコピー
    • これは理にかなっていて、requirements.lockの中身を変えない限り、ビルド中のレイヤーはキャッシュされ、ビルド時間短縮に寄与します3
  • sedコマンドで-eを含む行を削除
    • 依存ライブラリのバージョン指定だけが残りますね
  • 依存ライブラリをインストール
% cat requirements.lock
-e file:.
kojo-fan-art==0.1.1
% sed '/-e/d' requirements.lock
kojo-fan-art==0.1.1

nikkieによる改善案

WORKDIR /backend
COPY ./requirements.lock /backend/
RUN <<EOF
sed '/^-e/d' requirements.lock > requirements.txt
pip install -r requirements.txt
EOF
  • sedで削除するパターンを「-eで始まる」行に変えました
    • これによりhoge-eee==0.0.1のような-eを含む依存指定の行が削除されなくなります
    • Ryeのworkspaceを使うと-eで始まる行が複数できます。sedの削除はパターンにマッチした全行を消すので対応できています
    • sed-iオプションでrequirements.lockをインプレースに変更することもできます

% cat requirements.lock
-e file:.
-e file:subpkg
kojo-fan-art==0.1.1
hoge-eee==0.0.1
% sed '/-e/d' requirements.lock
kojo-fan-art==0.1.1
% sed '/^-e/d' requirements.lock
kojo-fan-art==0.1.1
hoge-eee==0.0.1
  • イメージビルド中のレイヤーの数を減らすために、RUNコマンドをまとめたいです
    • heredocが書けるので使いました
    • 私はEOFの代わりに、まとめた処理を表す「関数名」を使っています
  • ちなみに好みで、python -m pip install推しです

ソースコードを(site-packages以下に)インストール

先に述べた通常のinstallを進めるだけです。

COPY . .
RUN python -m pip install --no-cache-dir .

srcディレクトリを含む一式をDockerイメージ側にコピーし、editableではない(=通常の)インストールをしました。
これでsite-packages以下にコピーされます。

ソースコードの一部(例:printする文字列)を変えた後のDockerイメージのビルドは、COPY命令以降が都度実行される形になります。
requirements.lockは変更頻度が小さいので、キャッシュされたレイヤーが使われやすいのです。

動作環境

macOSです(zsh使用)

% docker --version
Docker version 24.0.2, build cb74dfc
% rye --version
rye 0.16.0
commit: 0.16.0 (c003223d5 2023-12-16)
platform: macos (aarch64)
self-python: cpython@3.11
symlink support: true

終わりに

Ryeで環境構築したクソライブラリをインストールしたクソDockerイメージの作り方でした。
おもちゃな例で取り上げていますが、以下の点は実用的ではないかと思います

  • Ryeが生成したrequirements.lockの-eで始まる行はDockerイメージのビルドにおいては除く
  • site-packages以下にインストールするため、ソースコードのeditable installはしない(通常のインストールをする)

実プロジェクトでも試してみて学びがあれば更新します

ちなみに-eの行をRyeが書き込むという挙動4は、音楽性が違うなと感じるポイントの1つです。
Ryeがラップしているpip-compileでは-eの行は書き込みません。
rye syncのeasyさのために、Dockerイメージビルドで複雑さが持ち込まれているように見えており、それをネガティブに評価しています。
(Ryeはmitsuhikoさんの個人的な課題解決のツールだから、mitshuhikoさんのユースケースではソースコードを含むDockerイメージをあんまりビルドしないのかなーって。あ、もしかすると、buildしてからPyPIに上げちゃうから、それをDockerイメージでインストールする想定なのかも!)


  1. 依存をなにか追加したかったので自作ライブラリを指定しました
  2. モジュール検索パスとも関係するようです。このあたり理解を深めたいところ https://docs.python.org/ja/3/using/cmdline.html#envvar-PYTHONPATH
  3. 例えば「Instruction order matters for leveraging build cache」にあります
  4. このあたり https://github.com/mitsuhiko/rye/blob/0.16.0/rye/src/lock.rs#L369

sphinx-revealjs製のスライドをGitHub Pagesで公開するためのリポジトリの初期設定

はじめに

ばーい!1 nikkieです。

発表資料を作るのにsphinx-revealjsを愛用しています。
年1ペースで発表資料リポジトリを作っているのですが、先日PHPカンファレンス関西2用のスライドを公開しました。
その中で実施した初期設定を未来の私に伝えるために、ここにまとめます。

目次

sphinx-quickstart

コマンドラインから引数マシマシで渡して、対話的にはあえて使いません

conf.pyにReveal.jsの設定を書く

sphinx-quickstartコマンドでできたconf.pyを編集します。
Reveal.jsの設定値をrevealjs_script_confに書きます。
https://sphinx-revealjs.readthedocs.io/en/stable/configurations/#confval-revealjs_script_conf

設定できる値は以下で確認できます。
https://revealjs.com/config/

参考例です:
https://github.com/ftnext/2024-slides/blob/10399a932b1d77f03a0d4eb8ae76232cea26e355/source/conf.py#L41-L48

revealjs_script_conf = {
    "controls": True,
    "progress": True,
    "history": True,
    "center": True,
    "transition": "none",
    "slideNumber": "c/t",
}

(設定値の解説は1ネタ分くらいになりそうですので、またの機会に)

共通CSSの配置

Reveal.jsではヘッダー(h1,h2,...)のアルファベットが、原稿中の記載によらず大文字になります
これを原稿中の記載のままとするために_static/css/common.cssを配置しています。

CSSの中身はtext-transformの指定です。

.reveal h1,
.reveal h2,
.reveal h3,
.reveal h4,
.reveal h5,
.reveal h6 {
  text-transform: none;
}

テキストをすべて大文字にしたり、すべて小文字にしたり、各単語の先頭を大文字にしたりすることを指定します。

GitHub Actions

スライドをビルドして、GitHub Pagesで公開するためのブランチにpushするActionを自作しています。

現在はこの記事の方法ではpermissionまわりでpushできないエラーが出るので、設定から権限を与えています3
伸びしろはGitHub Actionsの定義ファイルで解決することです。
https://docs.github.com/ja/actions/using-workflows/workflow-syntax-for-github-actions#permissions

OGP設定

OGP(Open Graph protocol)については以下に簡単にあります:
Sphinxはmetaディレクティブだけで、property属性を持ったOGP用のmetaタグがHTMLに作れちゃうんです! - nikkie-ftnextの日記

スライドをシェアするとき、SNSの中のコンテンツのように展開してほしいので設定します(要はカッコつけたいのです)。
Sphinxのテンプレートを使っているのですが、これはまだ書いていないようなので別記事で公開します(TODO:リンクを案内)

OGPの画像はhayasakaで撮影しています。最高の1枚!📸

終わりに

1年に1回作るsphinx-revealjs製スライドの公開リポジトリでやっていることを書き出しました。
2025年のnikkieは、ここを見ればバッチリのはずです!

自作GitHub Actionや自作ライブラリhayasakaなど、発表資料に使うツールも少しずつ整って領域展開できてきたので、当人としては楽しくなってきています。


  1. 言ってない「卑しか女ばい」で知られる
  2. 楽しかったです。ありがとうございました!
  3. ref: GitHub Actionsで自分のリポジトリ操作時に権限不足に起因するエラーが発生する