web-dev-qa-db-ja.com

sys.pathやサードパーティのパッケージを変更せずに、ベンダーの依存関係をPythonパッケージにインポートします

概要

私は、オープンソースのフラッシュカードプログラムである Anki の一連のアドオンに取り組んでいます。 AnkiアドオンはPythonパッケージとして出荷され、基本的なフォルダー構造は次のようになります:

_anki_addons/
    addon_name_1/
        __init__.py
    addon_name_2/
        __init__.py
_

_anki_addons_は、基本アプリによって_sys.path_に追加され、次に各アドオンを_import <addon_name>_でインポートします。

私が解決しようとしている問題は、グローバル状態を汚染したり、ベンダーのパッケージの手動編集にフォールバックしたりせずに、パッケージとその依存関係をアドオンと配布する信頼できる方法を見つけることです

詳細

具体的には、このようなアドオン構造を考えると...

_addon_name_1/
    __init__.py
    _vendor/
        __init__.py
        library1
        library2
        dependency_of_library2
        ...
_

...__vendor_ディレクトリに含まれている任意のパッケージをインポートできるようにしたい、たとえば:

_from ._vendor import library1
_

このような相対インポートの主な問題は、絶対参照を介してインポートされた他のパッケージにも依存するパッケージでは機能しないことです(例:_import dependency_of_library2_のソースコードの_library2_)。

ソリューションの試み

これまでのところ、私は次のオプションを検討しました:

  1. サードパーティのパッケージを手動で更新して、それらのインポートステートメントがmy pythonパッケージ(たとえば、_import addon_name_1._vendor.dependency_of_library2_)内の完全修飾モジュールパスを指すようにします。しかし、これは退屈な作業ではありません。より大きな依存関係ツリーにスケーラブルで、他のパッケージに移植できません。
  2. パッケージ初期化ファイルのsys.path.insert(1, <path_to_vendor_dir>)を介して__vendor_に_sys.path_を追加します。これは機能しますが、モジュールルックアップパスにグローバルな変更が導入され、他のアドオンやベースアプリ自体にも影響を及ぼします。後で、Pandoraの一連の問題が発生する可能性のあるハックのように見えます(たとえば、同じパッケージの異なるバージョン間の競合など)。
  3. 一時的にsys.pathをインポート用に変更 ;しかし、これはメソッドレベルのインポートを含むサードパーティのモジュールでは機能しません。
  4. 私が setuptools で見つけた例に基づいて PEP302 -スタイルのカスタムインポーターを記述しましたが、その頭と尾を作成できませんでした。

私はこれにかなりの時間を費やしてきましたが、これを行う簡単な方法が完全に欠けているか、または私の全体的なアプローチに根本的に何か問題があると考え始めています。

_sys.path_ハックや問題のパッケージの変更に頼らなくても、サードパーティパッケージの依存関係ツリーをコードと一緒に出荷する方法はありませんか?


編集:

明確にするために:anki_addonsフォルダーからアドオンをインポートする方法を制御することはできません。 anki_addonsは、すべてのアドオンがインストールされる基本アプリによって提供されるディレクトリです。これはsysパスに追加されるため、その中のアドオンパッケージは、Pythonのモジュールルックアップパスにある他のpythonパッケージとほとんど同じように動作します。

17
Glutanimate

まず第一に、私はベンダーに反対することを勧めます。いくつかの主要なパッケージは以前はベンダーを使用していましたが、ベンダーを処理する必要のある苦痛を避けるために切り替えました。そのような例の1つが requests library です。 _pip install_を使用してパッケージをインストールするユーザーに依存している場合は、依存関係を使用して、仮想環境についてユーザーに伝えます。依存関係を複雑にしないという負担を負う必要がある、またはグローバルなPython _site-packages_の場所に依存関係をインストールするのを止める必要がある)必要があると思い込まないでください。

同時に、サードパーティツールのプラグイン環境が何か異なるものであることを感謝しています。また、依存関係をPythonこのツールで使用されるインストールに追加すると、面倒な、または不可能なベンダライズが可能になる場合があります。 Ankiは拡張機能をsetuptoolsサポートなしで_.Zip_ファイルとして配布しているので、確かにそのような環境です。

したがって、依存関係をベンダーに選択する場合は、スクリプトを使用して依存関係を管理し、それらのインポートを更新します。これはオプション#1ですが、automatedです。

これはpipプロジェクトが選択したパスです。 tasksサブディレクトリ を参照して、 invoke library 。それらのポリシーと根拠については、pipプロジェクト vendoring README を参照してください(これらの中で最も重要なのは、pipbootstrapそれ自体、たとえば、何でもインストールできるように依存関係を利用できるようにします)。

他のオプションは使用しないでください。 #2と#3の問題はすでに列挙されています。

カスタムインポーターを使用したオプション#4の問題は、インポートを書き換える必要があることです。言い換えると、setuptoolsで使用されるカスタムインポーターフックは、ベンダー化された名前空間の問題をまったく解決しません。代わりに、ベンダー化されたパッケージが欠落している場合、トップレベルパッケージを動的にインポートすることができます(問題 pipmanual分離プロセス )で解決します。 setuptoolsは実際にはオプション#1を使用し、ベンダー化されたパッケージのソースコードを書き換えます。たとえば、 packagingベンダーサブパッケージのsetuptoolsプロジェクト のこれらの行を参照してください。 _setuptools.extern_名前空間はカスタムインポートフックによって処理され、ベンダー化されたパッケージからのインポートが失敗した場合、_setuptools._vendor_または最上位の名前にリダイレクトされます。

ベンダーパッケージを更新するpip自動化は、次の手順を実行します。

  • ドキュメント、__vendor/_ファイル、および要件のテキストファイルを除く、___init__.py_サブディレクトリ内のすべての削除します。
  • pipを使用して、_vendor.txt_という名前の専用要件ファイルを使用し、_.pyc_バイトキャッシュファイルのコンパイルを回避して一時的な依存関係を無視して、これらのディレクトリにベンダー依存関係をすべてインストールします(これらは、 _vendor.txt_すでに);使用されるコマンドは_pip install -t pip/_vendor -r pip/_vendor/vendor.txt --no-compile --no-deps_です。
  • pipによってインストールされたがベンダー環境では不要なすべてのもの、つまり_*.dist-info_、_*.Egg-info_、binディレクトリ、およびインストールされている依存関係からのいくつかの削除pipは使用しません。
  • インストールされているすべてのディレクトリと追加されたファイルのsans _.py_拡張子を収集します(ホワイトリストにないもの)。これは_vendored_libs_リストです。
  • インポートを書き換えます。これは単に一連の正規表現であり、_vendored_lists_内のすべての名前を使用して_import <name>_を_import pip._vendor.<name>_に置き換え、すべてのfrom <name>(.*) importfrom pip._vendor.<name>(.*) importに置き換えます。
  • いくつかのパッチを適用して、必要な残りの変更をモップアップします。ベンダーの観点から見ると、pipパッチのみがrequests のパッチであり、requestsライブラリの下位互換性が更新されます。 requestsライブラリが削除したベンダーパッケージのレイヤー。このパッチはかなりメタです!

したがって、本質的には、pipアプローチの最も重要な部分であるベンダーパッケージのインポートの書き換えは非常に簡単です。ロジックを簡略化し、pip固有の部分を削除するために言い換えると、それは単に次のプロセスです。

_import shutil
import subprocess
import re

from functools import partial
from itertools import chain
from pathlib import Path

WHITELIST = {'README.txt', '__init__.py', 'vendor.txt'}

def delete_all(*paths, whitelist=frozenset()):
    for item in paths:
        if item.is_dir():
            shutil.rmtree(item, ignore_errors=True)
        Elif item.is_file() and item.name not in whitelist:
            item.unlink()

def iter_subtree(path):
    """Recursively yield all files in a subtree, depth-first"""
    if not path.is_dir():
        if path.is_file():
            yield path
        return
    for item in path.iterdir():
        if item.is_dir():
            yield from iter_subtree(item)
        Elif item.is_file():
            yield item

def patch_vendor_imports(file, replacements):
    text = file.read_text('utf8')
    for replacement in replacements:
        text = replacement(text)
    file.write_text(text, 'utf8')

def find_vendored_libs(vendor_dir, whitelist):
    vendored_libs = []
    paths = []
    for item in vendor_dir.iterdir():
        if item.is_dir():
            vendored_libs.append(item.name)
        Elif item.is_file() and item.name not in whitelist:
            vendored_libs.append(item.stem)  # without extension
        else:  # not a dir or a file not in the whilelist
            continue
        paths.append(item)
    return vendored_libs, paths

def vendor(vendor_dir):
    # target package is <parent>.<vendor_dir>; foo/_vendor -> foo._vendor
    pkgname = f'{vendor_dir.parent.name}.{vendor_dir.name}'

    # remove everything
    delete_all(*vendor_dir.iterdir(), whitelist=WHITELIST)

    # install with pip
    subprocess.run([
        'pip', 'install', '-t', str(vendor_dir),
        '-r', str(vendor_dir / 'vendor.txt'),
        '--no-compile', '--no-deps'
    ])

    # delete stuff that's not needed
    delete_all(
        *vendor_dir.glob('*.dist-info'),
        *vendor_dir.glob('*.Egg-info'),
        vendor_dir / 'bin')

    vendored_libs, paths = find_vendored_libs(vendor_dir, WHITELIST)

    replacements = []
    for lib in vendored_libs:
        replacements += (
            partial(  # import bar -> import foo._vendor.bar
                re.compile(r'(^\s*)import {}\n'.format(lib), flags=re.M).sub,
                r'\1from {} import {}\n'.format(pkgname, lib)
            ),
            partial(  # from bar -> from foo._vendor.bar
                re.compile(r'(^\s*)from {}(\.|\s+)'.format(lib), flags=re.M).sub,
                r'\1from {}.{}\2'.format(pkgname, lib)
            ),
        )

    for file in chain.from_iterable(map(iter_subtree, paths)):
        patch_vendor_imports(file, replacements)

if __name__ == '__main__':
    # this assumes this is a script in foo next to foo/_vendor
    here = Path('__file__').resolve().parent
    vendor_dir = here / 'foo' / '_vendor'
    assert (vendor_dir / 'vendor.txt').exists(), '_vendor/vendor.txt file not found'
    assert (vendor_dir / '__init__.py').exists(), '_vendor/__init__.py file not found'
    vendor(vendor_dir)
_
9
Martijn Pieters

anki_addonsフォルダーをパッケージにして、必要なライブラリーをメインパッケージフォルダーの__init__.pyにインポートしてみませんか。

だからそれは次のようなものになるでしょう

anki/
__init__.py

anki.__init__.py内:

from anki_addons import library1

anki.anki_addons.__init__.py内:

from addon_name_1 import *

私はこれが初めてなので、ここで我慢してください。

1
gavin

依存関係をバンドルする最良の方法は、virtualenvを使用することです。 Ankiプロジェクトは、少なくとも1つのプロジェクト内にインストールできる必要があります。

あなたが求めているのはnamespace packagesだと思います。

https://packaging.python.org/guides/packaging-namespace-packages/

Ankiのメインプロジェクトにはsetup.pyがあり、すべてのアドオンには独自のsetup.pyがあり、独自のソースディストリビューションからインストールできると思います。その後、アドオンは依存関係を独自のsetup.pyにリストでき、pipはそれらをsite-packagesにインストールします。

名前空間パッケージは問題の一部のみを解決し、あなたが言ったようにanki_addonsフォルダーからアドオンをインポートする方法を制御することはできません。アドオンのインポート方法の設計とそれらのパッケージ化は密接に関連していると思います。

pkgutilモジュールは、メインプロジェクトがインストールされたアドオンを発見する方法を提供します。 https://packaging.python.org/guides/creating-and-discovering-plugins/

これを広範囲に使用するプロジェクトはZopeです。 http://www.zope.org

ここを見てください: https://github.com/zopefoundation/zope.interface/blob/master/setup.py

0
Eddy Pronk