Embarquer un fichier non Python proprement 8


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 de ensure_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 type bytes. 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).

8 thoughts on “Embarquer un fichier non Python proprement

  • Xavier Combelle

    setup.py est coupé en deux setup.p et y

    package_date -> package_data

  • cocksucker

    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 ;)

  • Gerardo

    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 de pkg_resources le propose ( ici), en gérant le cas ou tout est packagé/zippé ?

    Sinon deux petites typo :

    Parmi les nombres nombreux problèmes ?

    tous ces cas, bien entendu, lèves lèvent

  • projetmbc

    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”.

  • Sam Post author

    resource_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.

Leave a comment

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <pre> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.