Импортировать зависящие зависимости в пакете Python без изменения пакетов sys.path или сторонних поставщиков

Резюме

Я работаю над серией дополнений для Anki, программы с открытым исходным кодом. Дополнения Anki поставляются в виде пакетов Python, а основная структура папок выглядит следующим образом:

anki_addons/
    addon_name_1/
        __init__.py
    addon_name_2/
        __init__.py

anki_addons добавляется к sys.path базовым приложением, которое затем импортирует каждый add_on с 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. Ручное обновление сторонних пакетов, чтобы их операторы импорта указывали на полный путь к модулю в моем пакете python (например, import addon_name_1._vendor.dependency_of_library2). Но это утомительная работа, которая не масштабируется для больших деревьев зависимостей, а не переносима в другие пакеты.
  2. Добавление _vendor в sys.path через sys.path.insert(1, <path_to_vendor_dir>) в файле инициализации пакета. Это работает, но оно вводит глобальное изменение в путь поиска модуля, который будет влиять на другие надстройки и даже на базовое приложение. Это похоже на хак, который может привести к появлению проблемы с пандорой позже (например, конфликты между различными версиями одного и того же пакета и т.д.).
  3. Временное изменение sys.path для моего импорта; но это не работает для сторонних модулей с импортом уровня метода.
  4. Написание пользовательского импортера PEP302 -style основано на примере, который я нашел в setuptools, но я просто не мог сделать этого или нет.

Я застрял на этом в течение нескольких часов, и я начинаю думать, что я либо полностью пропустил простой способ сделать это, либо что есть что-то принципиально неправильное для всего моего подхода.

Разве я не могу отправить дерево зависимостей сторонних пакетов с моим кодом, не прибегая к sys.path или изменяя пакеты, о которых идет речь?


Редактировать:

Просто уточнить: у меня нет никакого контроля над тем, как надстройки импортируются из папки anki_addons. anki_addons - это только каталог, предоставляемый базовым приложением, в котором установлены все надстройки. Он добавляется к пути sys, поэтому надменные пакеты в нем в значительной степени просто ведут себя как любой другой пакет python, расположенный в путях поиска Python.

Ответы

Ответ 1

Прежде всего, я бы советовал против продажи; в нескольких крупных пакетах раньше использовалось предложение, но они отказались, чтобы избежать боли при работе с поставщиками. Одним из таких примеров является библиотека requests. Если вы полагаетесь на людей, использующих pip install для установки вашего пакета, просто используйте зависимости и расскажите людям о виртуальных средах. Не предполагайте, что вам нужно взять на себя бремя удержания зависимостей незанятыми или заставить людей не устанавливать зависимости в глобальном местоположении site-packages Python.

В то же время я ценю, что подключаемая среда стороннего инструмента - это нечто иное, и если добавление зависимостей к установке Python, используемой этим инструментом, громоздко или невозможно, вендоризация может быть жизнеспособным вариантом. Я вижу, что Anki распространяет расширения как .zip файлы без поддержки setuptools, так что, безусловно, такая среда.

Поэтому, если вы выбираете зависимости поставщиков, используйте сценарий для управления зависимостями и обновления их импорта. Это ваш вариант №1, но автоматизирован.

Это путь, выбранный проектом pip, см. Подкаталог их tasks для их автоматизации, который основывается на библиотеке invoke. См. Проект pip, предлагающий README для их политики и обоснования (главным из них является то, что pip должен сам загружаться, например, иметь свои зависимости, чтобы иметь возможность устанавливать что-либо).

Вы не должны использовать какие-либо другие параметры; вы уже перечисляли проблемы С# 2 и # 3.

Проблема с опцией №4 с использованием пользовательского импортера заключается в том, что вам все равно нужно переписать импорт. Иными словами, пользовательский крючок-импортер, используемый установкой setuptools вообще не решает проблему с пространством имен поставщиков, вместо этого он позволяет динамически импортировать пакеты верхнего уровня, если отсутствуют пакеты с вендорами (проблема в том, что pip решает процесс ручного дедупликации). setuptools фактически используется опция №1, где они переписывают исходный код для пакетов с вендорами. См. Например, эти строки в проекте packaging в подпапке, поставляемой setuptools; пространство имен setuptools.extern обрабатывается пользовательским захватом импорта, который затем перенаправляет либо на setuptools._vendor либо на имя верхнего уровня, если импорт из упакованного пакета не выполняется.

Автоматизация pip для обновления пакетов с производителем выполняет следующие шаги:

  • Удалите все в подкаталоге _vendor/ кроме документации, файла __init__.py и текстового файла требований.
  • Используйте pip для установки всех зависимых поставщиков в этот каталог, используя специальный файл требований с именем vendor.txt, избегая компиляции файлов .pyc bytecache и игнорируя переходные зависимости (предполагается, что они уже указаны в vendor.txt); используется команда pip install -t pip/_vendor -r pip/_vendor/vendor.txt --no-compile --no-deps.
  • Удалите все, что было установлено в pip но не нужно в среде с производителем, то есть *.dist-info, *.egg-info, каталог bin и несколько вещей из установленных зависимостей, которые pip никогда не будет использовать.
  • Собирайте все установленные каталоги и добавленные файлы без расширения .py (так что ничего не в белом списке); это список vendored_libs.
  • Переписать импорт; это просто серия регулярных выражений, где каждое имя в vendored_lists используется для замены import <name> вхождения с import pip._vendor.<name> и каждый from <name>(.*) import from pip._vendor.<name>(.*) import.
  • Примените несколько патчей, чтобы вытереть оставшиеся изменения; с точки зрения vendoring только pip патч для requests интересно здесь в том, что он обновляет requests библиотеки обратной слой совместимости для vendored пакетов, что 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 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)

Ответ 2

Как сделать папку 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 *

Я новичок в этом, поэтому, пожалуйста, несите меня здесь.

Ответ 3

Лучший способ связывания зависимостей - использовать virtualenv. Проект Anki должен, по крайней мере, иметь возможность установить внутри него.

Я думаю, что вы находитесь за 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