Ah, le packaging Python, c’est toujours pas fun.
Parmi les nombreux problèmes, la question suivante se pose souvent : comment je livre proprement un fichier de données fourni avec mon package Python ?
Le problème est double:
- Comment s’assurer que setup.py l’inclut bien ?
- Comment trouver et lire le fichier proprement ?
On pourrait croire qu’un truc aussi basique serait simple, mais non.
Ca ne l’est pas car un package Python peut être livré sous forme de code source, ou d’un binaire, ou d’une archive eggs/whl/pyz… Et en prime il peut être transformé par les packageurs des repos debian, centos, homebrew, etc.
Mais skippy a la solution à tous vos soucis !
Include les fichiers avec votre code
A la racine de votre projet, il faut un fichier MANIFEST.IN
bien rempli et include_package_data
sur True
dans setup.py
. On a un article là dessus, ça tombe bien.
N’utilisez pas package_data
, ça ne marche pas à tous les coups.
Charger les fichiers dans votre code
Si vous êtes pressés, copiez ça dans un fichier utils.py:
import os import io from types import ModuleType class RessourceError(EnvironmentError): def __init__(self, msg, errors): super().__init__(msg) self.errors = errors def binary_resource_stream(resource, locations): """ Return a resource from this path or package """ # convert errors = [] # If location is a string or a module, we wrap it in a tuple if isinstance(locations, (str, ModuleType)): locations = (locations,) for location in locations: # Assume location is a module and try to load it using pkg_resource try: import pkg_resources module_name = getattr(location, '__name__', location) return pkg_resources.resource_stream(module_name, resource) except (ImportError, EnvironmentError) as e: errors.append(e) # Falling back to load it from path. try: # location is a module module_path = __import__(location).__file__ parent_dir = os.path_dirname(module_path) except (AttributeError, ImportError): # location is a dir path parent_dir = os.path.realpath(location) # Now we got a resource full path. Just open it as a regular file. canonical_path = os.path.join(parent_dir, resource) try: return open(os.path.join(canonical_path), mode="rb") except EnvironmentError as e: errors.append(e) msg = ('Unable to find resource "%s" in "%s". ' 'Inspect RessourceError.errors for list of encountered erros.') raise RessourceError(msg % (resource, locations), errors) def text_resource_stream(path, locations, encoding="utf8", errors=None, newline=None, line_buffering=False): """ Return a resource from this path or package. Transparently decode the stream. """ stream = binary_resource_stream(path, locations) return io.TextIOWrapper(stream, encoding, errors, newline, line_buffering) |
Et faites:
from utils import binary_resource_stream, text_resource_stream data_stream = binary_resource_stream('./chemin/relatif', package) # pour du binaire data = data_stream.read() txt_stream = text_resource_stream('./chemin/relatif', package) # pour du texte text = txt_stream.read() |
Par exemple:
image_data = binary_resource_stream('./static/img/logo.png', "super_package").read() text = text_resource_stream('./config/default.ini', "super_package", encoding="cp850").read() |
Si vous n’êtes pas pressés, voilà toute l’histoire…
A la base, on fait généralement un truc du genre:
PROJECT_DIR = os.path.dirname(os.path.realpath(__file__)) # ou pathlib/path.py data = open(os.path.join(PROJECT_DIR, chemin_relatif)).read()) |
Mais ça ne marche pas dans le cas d’une installation binaire, zippée, trafiquée, en plein questionnement existentiel après avoir regardé un film des frères Cohen, etc.
La manière la plus sûre de le faire est:
import pkg_resources data = pkg_resources.resource_stream('nom_de_votre_module', chemin_relatif).read() |
Ça va marcher tant que votre package est importable. On se fout d’où il est, de sa forme, Python se démerde.
Mais ça pose plusieurs autres problèmes:
pkg_resources
fait parti de setuptools, qui n’est pas forcément installé sur votre système. En effet, malgré l’existence deensure_pip
depuis la 3.4, beaucoup de distributions n’installent ni pip, ni setuptools par défaut et en font des paquets séparés.data
sera forcément de typebytes
. Il faut le décoder manuellement derrière.- si votre code doit fonctionner aussi en dehors du cadre d’un package importable (genre on unzip et ça juste marche),
pkg_resources
ne fonctionnera pas puisque par essence il utilise le nom du package pour trouver la ressource. - si vous voulez spécifier plusieurs endroits ou potentiellement trouver la ressource, ou passer l’objet module à la place du nom du module, ça ne marche pas.
- tous ces cas, bien entendu, lèvent des exceptions différentes histoire de faciliter votre
try
/except
. - Il existe
pkgutil
qui est bien installé partout, mais ça load tout en mémoire d’un coup. Si vous avez un XML de 10Mo à charger ça fait mal au cul. - la doc de
pkg_resource
est aussi claire que l’anus de la mère du premier commentateur de cet article. On va voir qui lit l’article en entier là…
Le snippet fourni corrige ces problèmes en gérant les cas tordus pour vous. Moi je l’utilise souvent en faisant:
with text_resource_stream('./chemin/relatif', ['package_name', 'chemin_vers_au_cas_ou_c_est_pas_un_package']) as f: print('boom !', f.read()) |
Je devrais peut-être rajouter ça dans batbelt et minibelt…
Travailler avec des fichiers non Python
Ça, c’est pour lire les ressources fournies. Mais si vous devez écrire des trucs, il vous faut un dossier de travail.
Si c’est pour un boulot jetable, faites-le dans un dossier temporaire. Le module tempfile est votre ami.
Si c’est pour un boulot persistant, trouvez le dossier approprié à votre (fichier de config, fichier de log, etc) et travaillez dedans. path.py a des méthodes dédiées à cela (un des nombreuses raisons qui rendent ce module meilleur que pathlib
).
setup.py est coupé en deux setup.p et y
package_date -> package_data
Quelle vitesse !
Et j’espère qu’il est pas vexé pour sa maman.
C’est vrai que c’est tout de suite plus simple…;) non je déconne…ça marche mais c’est pas une côte sexy ni industrialisable…Bref on cherche encore pour Python…
Try again
Insert coin ;)
Ouf, je ne suis pas le premier a poster un commentaire, ma mère me remercie…
Merci pour cet article. Pour m’être un peu frotté au problème, effectivement c’est le bazar, je vais regarder/tester le snippet avec joie !
Je ne suis pas sur de tout bien comprendre, que se passe-t-il par exemple si package_resource n’est pas disponible, et que le package est sour forme “eggs/whl/pyz…”. On recupère le path OK, mais ensuite pour extraire le fichier du package ?
Petite question, serait-il compliqué de gèrer le cas ou on a besoin du nom de fichier (par exemple pour charger une image avec imread d’openCV qui prend en argument le nom de fichier et ne fonctionne pas avec un objet file), comme la fonction
resource_filename
depkg_resources
le propose ( ici), en gérant le cas ou tout est packagé/zippé ?Sinon deux petites typo :
Parmi les
nombresnombreux problèmes ?tous ces cas, bien entendu,
lèveslèventresource_filename va marcher, mais il va extraire ton fichier dans un dossier temporaire, et donc retiré pas mal de bénéfice du zip. Mais dans ton cas ça a du sens.
Merci pour les typos.
Merci pour cet article.
Un petite “côte” de grammaire : “tous ces cas, bien entendu, lèves des exceptions” doit s’écrire “tous ces cas, bien entendu, lèvent des exceptions”.
Parmi les nombres problèmes
=> nombreux