1 ポイント 投稿者 GN⁺ 2024-08-23 | 1件のコメント | WhatsAppで共有
  • Pythonでは、先頭2行の ソースエンコーディングのマジックコメント とユーザー定義 codec を使って、ファイル内容を実行前に書き換えたり、まったく別のコードに置き換えたりできる
  • ユーザー定義 codec は、.pth パス設定ファイルimport 実行を通じてインタプリタ初期化中に登録でき、codecs.register で検索関数を追加する
  • codec の実装には decode(data: bytes) -> tuple[str, int]増分デコーダ が必要で、例外処理をしないと原因の代わりに SyntaxError: encoding problem: your_codec だけが表示されることがある
  • ++/-- のインクリメント・デクリメント演算子、波かっこベースの Pythoncppyy による C/C++ 実行、TOML を JSON Schema で検証する方式まで、同じエントリポイントで実装できる
  • お遊びの例を超えて、pythonqlfuture-typingfuture-fstringsfuture-annotations のような Python 拡張・バックポートにも使え、magic_codec は反復作業を減らしてくれる

ソースエンコーディングをプリプロセスの入口として使う

  • PEP-0263 に従い、Python ファイルの先頭2行のいずれかで ソースコードのエンコーディング を指定できる
    • 例: # coding=utf8, # -*- coding: utf8 -*-, # vim: set fileencoding=utf8 :
  • マジック行は正規表現 ^[ \t\f]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+) に一致する必要がある
    • codec 名は [-_.a-zA-Z0-9]+ に一致していなければならない
  • ユーザー定義 codec は、ソースをデコードするだけでなく、ソース文字列を書き換えたうえで Python インタプリタに渡せる

.pth ファイルで codec を登録する

  • Python インタプリタが -S オプションなしで起動すると、site パッケージが初期化中に読み込まれる
  • site-packages 内の .pth パス設定ファイル は、空行と # で始まる行を除いた内容をモジュール検索パスに追加する
  • Python のドキュメントによれば、import の後に空白またはタブが続く行は実行される
    • 例: packagename.pthimport packagename.register_codec を入れると、初期化中にそのモジュールが import される
  • import されたモジュールは codecs.register を呼び出して codec 検索関数を登録できる
    • import は一度しか実行されないため、検索関数の登録も一度だけでよい

ユーザー定義 codec の実装

  • ユーザー定義 codec には2つが必要
    • decode(data: bytes) -> tuple[str, int]
    • 増分デコーダ クラス
  • decode 関数は codecs.utf_8_decode で実際の UTF-8 デコードを行い、結果の文字列をプリプロセス関数に渡せる
  • codec 内部の例外が捕捉されないと、通常の traceback の代わりに SyntaxError: encoding problem: your_codec だけが出力されることがある
    • プリプロセス関数で発生した例外は、traceback.print_exc() で直接表示してから再度 raise するほうがよい
  • 増分デコーダはファイル全体をバッファにため、最後の decode 呼び出しで一度だけプリプロセスできる
    • 実装例では codecs.BufferedIncrementalDecoder を継承し、decode(self, data, final=False)final のときだけ処理する
  • プリプロセス結果は元のファイル内容を必ずしも使う必要はなく、完全に任意の Python コードを返してもよい
    • ただし先頭行はマジック行として期待されるため削除され、結果は有効な Python でなければならない

Python 構文拡張の例

  • ++-- のインクリメント・デクリメント演算子

    • Python には単項の インクリメント・デクリメント演算子 がない
    • x++, x-- は構文的に無効
    • ++x, --x は構文上は有効だが、それぞれ x.__pos__().__pos__(), x.__neg__().__neg__() の呼び出しになる
    • プリプロセッサはトークンストリームを書き換え、インクリメント・デクリメント演算子のように動作させられる
      • x++(x, x := x + 1)[0]
      • x--(x, x := x - 1)[0]
      • ++x(x, x := x + 1)[1]
      • --x(x, x := x - 1)[1]
    • この変換は Python の代入式である ウォルラス演算子 (walrus operator) を使う
    • 単純なトークン置換だけでは x++ - -y のような式で失敗することがあり、x++ - (-y) のようにかっこで曖昧さを減らせる
    • incdec.py は正規表現で置換するが、文字列リテラル内部の置換を避けようとしても脆弱になりうる
    • トークンストリームを直接変更する実装は magic.incdec にある
  • 波かっこベースの Python

    • from __future__ import bracesSyntaxError: not a chance を発生させる
    • プリプロセッサはトークンストリームを修正し、波かっこスコープ をインデントベースの Python に変換できる
    • 実装の流れは次の通り
      • tokenize.generate_tokens でトークンを生成する
      • 入力文字列は io.StringIOreadline で1行ずつ渡す
      • 既存の INDENT, DEDENT トークンは削除する
      • { に出会ったらインデントレベルを上げて : を出力する
      • } に出会ったらインデントレベルを下げる
      • NL の後には現在のインデントレベルに応じた INDENT トークンを追加する
    • Python の辞書リテラルとの衝突を減らすには、{ の後ろが newline のときだけインデントレベルを調整し、} の前が newline のときだけ閉じるスコープとして扱える
    • 複数行の辞書はバックスラッシュを使えば、波かっこの内部に newline トークンが発生しないため利用できる

他言語を Python として実行する

  • C と C++

    • # でコメントを書くシェルスクリプト、CMake スクリプト、PHP、Ruby のような言語は、shebang と一緒にエンコーディングのマジック行を入れやすい
    • C と C++ はコメントが /* ... */ または // ... だが、プリプロセッサディレクティブは # で始まるため、エンコーディング用の正規表現に一致させられる
    • 例のマジック行は C/C++ のソースでも有効であり、Python のエンコーディングパターンにも一致する
      • #define CODEC "coding:magic.cpp"
    • cppyy を使うと、Python から C/C++ コードを解釈し、バインディングを生成できる
      • cppyy は内部的に cling を使う
    • プリプロセス結果はおおむね次のような Python コードになる
      • import cppyy
      • cppyy.cppdef("<input source file content>")
      • from cppyy.gbl import main
      • __name__ == "__main__" のときに C/C++ の main() を呼び出す
    • 実装例は magic.cpp にある

TOML 検証ツールとして使う

  • TOML は # でコメントを始めるため、# coding: magic.toml のようなエンコーディングのマジック行を入れられる
  • プリプロセス結果を Python の検証スクリプトに変換すれば、Python インタプリタを TOML 検証ツール のように使える
  • 検証例では次のモジュールを使用する
    • tomllib で TOML ファイルを読む
    • json で JSON Schema ファイルを読む
    • jsonschema で検証する
  • 実行例:
    • python tests/toml/data_valid.toml -s tests/toml/schema.json
    • 有効なら Successfully validated. を出力する
  • 不正な TOML の例では、scores 配列内の文字列 '20' が数値ではないという検証エラーが出力される
  • 実装例は magic.toml にある

実運用と magic_codec

  • ユーザー定義 codec と .pth ファイルを組み合わせると、Python インタプリタの動作を大きく変えられる
  • 例の大半は遊び向けだが、実際の活用例もある
  • site-packages を直接いじったり、.pth ファイルや反復コードを自分で書いたりしたくないなら、magic_codec を使える
  • magic_codec 拡張は magic_ 接頭辞の付いた Python パッケージとして作成できる
    • ファイル codec を magic_foo に設定すると、magic_foo パッケージを読み込む
    • そのパッケージに preprocess 関数があるか確認する
  • 想定される preprocess のシグネチャは次の通り
    • def preprocess(data: str) -> str:
  • 拡張例は example/ にある

1件のコメント

 
GN⁺ 2024-08-23
Hacker News のコメント
  • from __future__ import braces を実行すると SyntaxError: not a chance が出るこの面白いエラーメッセージは、2001年から CPython にハードコードされていた
    https://github.com/python/cpython/commit/ad3d3f2f3f19833f59f...
    作者の Jeremy Hylton は現在 Google で AI 検索品質を担当する Principal Engineer として働いているが、24年の間に、禁止された構文を遊び心で記念していたところから、専用の構文を必要としない汎用的なクエリシステムへとキャリアがつながっているのはなかなか印象的

    • break rust; が Rust コンパイラで内部コンパイラエラーを出していた件を思い出す。他の言語にも似たようなイースターエッグがどれだけあるのか気になる
    • それの何が驚きなのか分からない。2001年に誰でも Python に何かを入れられたわけではなく、当時はニッチなプロジェクトで、貢献していた人たちは賢く献身的だったので、影響力のあるキャリアを持つ可能性が高かったはず
      遊び心のある非公式な趣味のハックが、実際のプロの開発とは別世界だというのは誤解だと思う
    • 無邪気な時代だった。Hylton が Tim Peters の正義のために不信任投票の動きに加わることもあり得るかもしれない
      https://news.ycombinator.com/item?id=41314393
    • こういうイースターエッグを見るのはいつも楽しい。昔ほど一般的でなくなったのが残念
  • import hook でふざけるのがクビになる最も創造的な方法だと思っていたが、それは甘い考えだった。codec の正規表現のせいで μtf8 みたいなものでうまくトロールできなさそうなのは残念で、これからは import hook、プリプロセッサ、sys.settrace を使って全関数を直前に呼ばれた関数へモンキーパッチし、17分ごとに stdout と stderr を入れ替えるしかなさそう

    • 良い言語らしく、中かっこの使用も強制しないと
  • Python がプリプロセッサフックを意図的に表に出していないのには十分な理由があり、分別ある大人なら近づくべきではないと思う部分がある
    しかし一方で、分別ある大人たちとは関わりたくない。実に面白いことがたくさんできそうだ

    • Python には「合意した大人たち(consenting adults)」のための言語という哲学があり、そのため public/private の可視性指定子がなく、おそらくあらゆるメタプログラミングの魔法も開放しているのだと思う。「分別ある大人」を心配していたなら、こうした設計判断は少し奇妙だったはず :)
    • これを簡単で明白な方法でできないようにすると、人々はもっと悪くてハックっぽい方法で試そうとするだろう
  • これは便利で本当に役立ちそう。馬鹿げた import ハックをするときは、たいていモジュールを import して ast モジュールでコードを書き換え、exec した後に exit() を挟み込んでいたが、プリプロセッサがあればずっと使いやすそう
    すべての dict が順序を保証するようになる前は、ast の書き換えでリストリテラルを ordered dict 呼び出しに変えるのに主に使っていて、実際に役立っていた
    Python の柔軟さが好きだ。自分がやった中で最も呪われたことは文字列をその場で変更したことで、最終的には mmap まで乱用して自己変更するスクリプトを書く羽目になった。今度は producer として Lisp インタプリタを書くべきな気がしている

    • 「文字列をその場で変更」したって、文字列は不変だ。ctypes みたいなものでメモリ位置に直接書き込んだのか?
  • これまで見つけた最高の用途は JSX に着想を得た pyxl: https://github.com/dropbox/pyxl
    こんなコードが書ける
    # coding: pyxl
    print Hello World!

  • これが Python 2 から 3 への移行をよりうまく扱うのに使えたのか気になる。例えば # coding: six.python2 が Python 2 コードを有効な Python 3 コードに合わせたり、# coding: six.python3 が Python 3 コードを Python 2 で動くように変えたりするような形
    b"..."u"..." のプレフィックスを追加・削除することもできたかもしれない

    • 役には立ち得るが、役に立つ部分は簡単な部分だ。Python 2 から 3 への難しさはランタイム動作の変化だった。Python 2 では ASCII を含む Unicode と通常の文字列が同じ文字列のように動作し、dict のキーに使うと同じ項目を指していたが、Python 3 では同じ ASCII 内容の bytesstr が同じ dict 内で別々の項目を指す
      さらに厄介な変更もある。.keys().values() のような複数の組み込み機能は、Python 2 ではリストを返すが、Python 3 ではイテレータを返す。six のユーティリティや他の回避策でコードを安全に翻訳しようとすると、コードが非常に冗長になる。ほとんどは一度しか使われないが、時には二度使われることがあるためだ
      import 時点でコードを書き換えられるツールがあるなら、むしろ変換後のコードをコミットして段階的に整理するほうがよいと思う。難しい部分は strbytes のように、遠く離れたコードにまで影響する動作変化だ
  • このcoding hook 戦略で生じた依存関係は pip freezeuv が検出してくれるのだろうか?
    そうでないなら楽しい時間になりそう :). 誰かがこういうものを仕込んでいたなら、他にどんな罠があるかもほぼ保証されたようなものなので、そうしたドラゴンと戦うくらいならライブラリを書き直すほうが簡単そう

  • 擬似コードの Python を作って、それを LLM にデコードさせたらかなり笑えそう。もちろんひどいものになるだろうが、面白くはありそう