pkg_resource – Sam & Max http://sametmax.com Du code, du cul Wed, 23 Dec 2020 13:35:02 +0000 en-US hourly 1 https://wordpress.org/?v=4.9.7 32490438 Embarquer un fichier non Python proprement http://sametmax.com/embarquer-un-fichier-non-python-proprement/ http://sametmax.com/embarquer-un-fichier-non-python-proprement/#comments Fri, 12 Feb 2016 12:38:51 +0000 http://sametmax.com/?p=18160 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).

]]>
http://sametmax.com/embarquer-un-fichier-non-python-proprement/feed/ 8 18160