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.
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
).
Jusqu'à présent, j'ai exploré les options suivantes:
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._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.).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.
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:
_vendor/
à l'exception de la documentation, du fichier __init__.py
et du fichier de texte des exigences.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
.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..py
(donc tout ce qui ne figure pas dans la liste blanche); c'est la liste vendored_libs
.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
.pip
de 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)
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