web-dev-qa-db-fra.com

Importer des dépendances vendorées dans un package Python sans modifier les packages sys.path ou tiers

Résumé

Je travaille actuellement sur une série d’add-ons pour Anki , un programme de carte flash à source ouverte. Les modules complémentaires Anki sont livrés sous forme de packages Python, la structure de base des dossiers se présentant comme suit:

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

anki_addons est ajouté à sys.path par l'application de base, qui importe ensuite chaque add_on avec import <addon_name>.

Le problème que je tentais de résoudre est de trouver un moyen fiable pour expédier les paquets et leurs dépendances avec mes add-ons sans polluer l’état global ni revenir aux éditions manuelles des paquets vendus.

Détails

Plus précisément, étant donné une structure add-on comme celle-ci ...

addon_name_1/
    __init__.py
    _vendor/
        __init__.py
        library1
        library2
        dependency_of_library2
        ...

... Je voudrais pouvoir importer n'importe quel paquet arbitraire inclus dans le répertoire _vendor, par exemple:

from ._vendor import library1

La principale difficulté des importations relatives comme celle-ci est qu'elles ne fonctionnent pas pour les packages qui dépendent également d'autres packages importés via des références absolues (par exemple, import dependency_of_library2 dans le code source de library2).

Tentatives de solution

Jusqu'à présent, j'ai exploré les options suivantes:

  1. Mise à jour manuelle des packages tiers, de sorte que leurs instructions d'importation pointent vers le chemin de module pleinement qualifié de mon package python (par exemple, import addon_name_1._vendor.dependency_of_library2). Mais c’est un travail fastidieux qui n’est pas extensible à des arbres de dépendances plus grands et non transférable à d’autres progiciels.
  2. Ajout de _vendor à sys.path via sys.path.insert(1, <path_to_vendor_dir>) dans le fichier init de mon paquet. Cela fonctionne, mais cela introduit un changement global dans le chemin de recherche du module qui affectera les autres add-ons et même l'application de base elle-même. Cela ressemble simplement à un piratage qui pourrait donner lieu ultérieurement à une boîte de problèmes de Pandora (par exemple, des conflits entre différentes versions du même package, etc.).
  3. Modification temporaire de sys.path pour mes importations ; mais cela ne fonctionne pas pour les modules tiers avec des importations au niveau de la méthode.
  4. Écrire un importateur personnalisé de style PEP302 - basé sur un exemple que j'ai trouvé dans setuptools , mais je ne pouvais tout simplement pas en faire la tête ni la queue.

Cela fait quelques heures que je suis coincé là-dessus et je commence à penser que je manque complètement un moyen facile de faire cela ou que quelque chose ne va pas dans mon approche.

Est-il possible que je puisse envoyer une arborescence de dépendances de packages tiers avec mon code, sans avoir à recourir à sys.path hacks ou à modifier les packages en question?


Modifier:

Juste pour clarifier: je n’ai aucun contrôle sur la façon dont les add-ons sont importés du dossier anki_addons. anki_addons est simplement le répertoire fourni par l'application de base où tous les add-ons sont installés. Il est ajouté au chemin d'accès système, de sorte que les modules complémentaires qu'il contient se comportent plutôt comme tout autre paquetage python situé dans les chemins de recherche de module de Python.

15
Glutanimate

Tout d'abord, je conseillerais contre la vente; Quelques grands paquets utilisaient auparavant la vente mais se sont déconnectés pour éviter la douleur liée à la gestion de la vente. Un de ces exemples est la bibliothèque requests . Si vous comptez sur des personnes utilisant pip install pour installer votre package, alors utilisez uniquement les dépendances et informez les utilisateurs des environnements virtuels. Ne présumez pas que vous devez supporter le fardeau de garder les dépendances inchangées ou empêcher les utilisateurs d'installer des dépendances dans l'emplacement global site-packages de Python.

En même temps, j’apprécie qu’un environnement de plug-in d’un outil tiers soit quelque chose de différent et que, si ajouter des dépendances à l’installation Python utilisée par cet outil est fastidieux ou impossible, la vente peut être une option viable. Je vois qu'Anki distribue les extensions sous la forme de fichiers .Zip sans la prise en charge de setuptools. Il s'agit donc certainement d'un tel environnement.

Par conséquent, si vous choisissez de créer des dépendances de fournisseurs, utilisez un script pour gérer vos dépendances et mettre à jour leurs importations. C'est votre option n ° 1, mais automatisé

C’est le chemin choisi par le projet pip. Voir leur sous-répertoire tasks pour leur automatisation, qui repose sur la bibliothèque invoke . Voir le projet pip vendoring README pour leur politique et leur justification (le plus important d'entre eux est que pip doit bootstrap lui-même, par exemple, avoir ses dépendances disponibles pour pouvoir installer quoi que ce soit).

Vous ne devez utiliser aucune des autres options. vous avez déjà énuméré les problèmes avec les numéros 2 et 3.

Le problème avec l'option n ° 4, en utilisant un importateur personnalisé, est que vous devez toujours réécrire les importations. Autrement dit, le point d’importation personnalisé utilisé par setuptools ne résout en rien le problème de l’espace de noms vendu, mais permet plutôt d’importer de manière dynamique les packages de niveau supérieur si les packages fournis sont manquants (problème que pip résout avec un manuel processus de débundling ). setuptools utilise en fait l'option n ° 1, où ils réécrivent le code source pour les packages fournis par le fabricant. Voir par exemple ces lignes dans le projet packaging dans le sous-package setuptools vendored; l'espace de noms setuptools.extern est géré par le hook d'importation personnalisé, qui redirige ensuite soit vers setuptools._vendor, soit vers le nom de niveau supérieur si l'importation à partir du package vendu échoue.

L'automatisation pip pour mettre à jour les paquetages vendorés se déroule comme suit:

  • Supprimez tout dans le sous-répertoire _vendor/ à l'exception de la documentation, du fichier __init__.py et du fichier de texte des exigences.
  • Utilisez pip pour installer toutes les dépendances vendorées dans ce répertoire, à l'aide d'un fichier d'exigences dédié nommé vendor.txt, en évitant la compilation de fichiers .pyc bytecache et en ignorant les dépendances transitoires (celles-ci sont déjà répertoriées dans vendor.txt); la commande utilisée est pip install -t pip/_vendor -r pip/_vendor/vendor.txt --no-compile --no-deps.
  • Supprimez tout ce qui a été installé par pip mais dont vous n'avez pas besoin dans un environnement vendu, à savoir *.dist-info, *.Egg-info, le répertoire bin et quelques éléments des dépendances installées que pip n'utilisera jamais.
  • Rassemblez tous les répertoires installés et les fichiers ajoutés sans l'extension .py (donc tout ce qui ne figure pas dans la liste blanche); c'est la liste vendored_libs.
  • Réécrire les importations; c'est simplement une série de regex, où chaque nom dans vendored_lists est utilisé pour remplacer les occurrences import <name> par import pip._vendor.<name> et toutes les occurrences from <name>(.*) import par from pip._vendor.<name>(.*) import.
  • Appliquez quelques correctifs pour éponger les changements restants nécessaires; Du point de vue de la vente, seul le correctif pipde requests est intéressant dans la mesure où il met à jour la couche de compatibilité ascendante de la bibliothèque requests pour les packages revendus supprimés par la bibliothèque requests. ce patch est assez méta!

Donc, essentiellement, la partie la plus importante de l’approche pip, la réécriture des importations de paquet vendored est assez simple; Paraphrasé pour simplifier la logique et supprimer les parties spécifiques à pip, il s’agit simplement du processus suivant:

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 __== '__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)
6
Martijn Pieters

Le meilleur moyen de regrouper des dépendances consiste à utiliser une variable virtualenv. Le projet Anki devrait au moins pouvoir être installé à l'intérieur d'un projet.

Je pense que ce que vous recherchez, c'est namespace packages.

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

J'imagine que le projet principal Anki a un setup.py et que chaque module complémentaire a son propre setup.py et peut être installé à partir de sa propre distribution source. Ensuite, les add-ons peuvent lister leurs dépendances dans leur propre setup.py et pip les installera dans site-packages.

Les paquets d'espace de noms ne résolvent qu'une partie du problème et, comme vous l'avez dit, vous n'avez aucun contrôle sur la manière dont les add-ons sont importés à partir du dossier anki_addons. Je pense que concevoir la manière dont les add-ons sont importés et emballés va de pair.

Le module pkgutil fournit au projet principal un moyen de découvrir les modules complémentaires installés. https://packaging.python.org/guides/creating-and-discovering-plugins/

Zope est un projet qui l'utilise beaucoup. http://www.zope.org

Regardez ici: https://github.com/zopefoundation/zope.interface/blob/master/setup.py

0
Eddy Pronk