Outils pour utilisateurs

Outils du site


cours:informatique:fun_mooc:python3_uca_inria:580_package

Python: notion de package

La notion de package permet de créer des bibliothèques plus structurées qu'avec un simple module.

Comme introduit précédemment, un module est donc un objet python qui correspond à la fois à:

  • un (seul) fichier sur le disque;
  • un espace de nom pour les variables du programme.

Package

Lorsqu'il s'agit d'implémenter une très grosse bibliothèque, il n'est pas concevable de tout concentrer en un seul fichier. C'est là qu'intervient la notion de package, qui est un peu aux répertoires ce que que le module est aux fichiers.

Pour illustrer par la pratique nous allons créer un package qui contient un module. Pour cela on crée une arborescence d&ans le répertoire courant:

package_jouet/
├── __init__.py
└── module_jouet.py

Ci dessous le contenu des fichiers __init__.py et module_jouet.py

__init__.py
print("chargement du package", __name__)
 
spam = ['a', 'b', 'c']
 
# on peut forcer l'import de modules
import package_jouet.module_jouet
 
# et définir des raccourcis
jouet = package_jouet.module_jouet.jouet
module_jouet.py
print("Chargement du module", __name__, "dans le package 'package_jouet'")
 
jouet = 'une variable définie dans package_jouet.module_jouet'

On Lance l'interpréteur dans le répertoire courant et on importe le package:

# L'importation du paquetage produit la sortie suivante:
>>> import package_jouet
chargement du package package_jouet
Chargement du module package_jouet.module_jouet dans le package 'package_jouet'

Comme on le voit, le package porte le même nom que le répertoire, c'est-à-dire que, de même que le module module_simple correspond au fichier module_simple.py, le package python package_jouet correspond au répertoire package_jouet.

Par le passé, pour définir un package, il fallait obligatoirement créer dans le répertoire (celui, donc, que l'on veut exposer à python), un fichier nommé __init__.py; ce n'est plus le cas depuis Python-3.3.

Importer un package revient essentiellement à charger, lorsqu'il existe, le fichier __init__.py dans le répertoire correspondant (et sinon, on obtient un package vide).

On a coutume de faire la différence entre package et module, mais en termes d'implémentation les deux objets sont en fait de même nature, ce sont des objets modules:

>>> type(package_jouet)
module
 
>>> type(package_jouet.module_jouet)
module

Ainsi, le package se présente aussi comme un espace de nom. L'espace de noms du package permet de référencer les packages ou modules qu'il contient, comme on l'a vu ci-dessus, le package référence le module au travers de son attribut module_jouet:

>>> print(package_jouet.module_jouet)
<module 'package_jouet.module_jouet' from '/tmp/yoann/package_jouet/module_jouet.py'>

Rôle du fichier __init.py__

Vous remarquerez que le module module_jouet a été chargé au même moment que package_jouet. Ce comportement n'est pas implicite ou prédéfini. C'est nous qui avons explicitement choisi d'importer ce module dans le package (en le définissant dans le fichier __init__.py).

Cette technique correspond à un usage assez fréquent, où on veut exposer directement dans l'espace de nom du package des symboles qui sont en réalité définis dans un module.

Avec le code présent dans __init__.py, après avoir importé package_jouet, nous pouvons utiliser la variable jouet

>>> package_jouet.jouet
'une variable définie dans package_jouet.module_jouet'

Alors qu'en fait, sans la référence jouet crée dans le package, il faudrait écrire pour y accéder:

>>> package_jouet.module_jouet.jouet
'une variable définie dans package_jouet.module_jouet'

Mais cela impose alors à l'utilisateur d'avoir une connaissance sur l'organisation interne de la bibliothèque, ce qui est considéré comme une mauvaise pratique.

D'abord, cela donne facilement des noms à rallonge et du coup nuit à la lisibilité, ce n'est pas pratique. Mais surtout, que se passerait-il alors si le développeur du package voulait renommer des modules à l'intérieur de la bibliothèque? On ne veut pas que ce genre de décision ait un impact sur les utilisateurs.

De manière générale, __init__.py peut contenir n'importe quel code Python chargé d'initialiser le package. Notez que depuis Python-3.3, la présence de __init__.py n'est plus strictement nécessaire.

Lorsqu'il est présent, comme pour les modules usuels, __init__.py n'est chargé qu'une seule fois par l'interpréteur Python; s'il rencontre plus tard à nouveau le même import, il l'ignore silencieusement.

Attributs spéciaux

Les objets de type module possèdent des attributs spéciaux:

__name__ Nom canonique du module.
__file__ L'emplacement du fichier duquel a été chargé le module.
__all__ Définir les symboles concernés par un import * (déconseillé en production)

Import absolu

La mécanique des imports telle qu'on l'a vue jusqu'ici est ce qui s'appelle un import absolu qui est le mécanisme par défaut (depuis python-2.5): le module importé est systématiquement cherché à partir de sys.path.

Dans ce mode de fonctionnement, si on trouve dans le même répertoire deux fichiers foo.py et bar.py, et que dans le premier on écrit:

# tentative d'import d'un fichier présent dans le même répertoire
import bar

Le plus souvent, alors qu'il existe ici même un fichier bar.py, l'import ne réussit pas (sauf si le répertoire courant est dans sys.path mais en général ce n'est pas le cas).

Import relatif

Ce mécanisme d'import absolu a l'avantage d'éviter qu'un module local, par exemple “random.py”, ne vienne cacher le module “random” de la bibliothèque standard. Mais comment peut-on faire alors pour charger le module “random.py” local? C'est à cela que sert l'import relatif.

# Pour importer un module entier en mode relatif
from . import random as local_random_module
 
 
# La syntaxe pour importer seulement un symbole
from .random import alea

Il faut savoir également qu'on peut “remonter” dans l'arborescence de fichiers en utilisant plusieurs points '.' consécutifs.

# Importer un symbole du module local random présent dans
# le repertoire parent: deux points pour remonter
from ..random import alea as imported

Lorsque deux modules sont situés dans le même répertoire, il semble naturel que l'import entre eux se fasse par un import relatif, plutôt que de devoir répéter ad nauseam le nom de la bibliothèque dans tous les imports. De plus, l'import relatif présente l'avantage d'être insensible aux renommages divers à l'intérieur d'une bibliothèque.

Spécificités de l'import relatif

l'import relatif ne fonctionne pas toujours comme on pourrait s'y attendre. Le point important à garder en tête est que lors d'un import relatif, c'est l'attribut __name__ qui sert à déterminer le point de départ.

Concrètement, lorsque dans main.py on écrit:

# usage de l'import relatif
from . import random

L'interpréteur:

  • détermine que dans main.py, __name__ vaut package_relatif.main;
  • il “oublie” le dernier morceau main pour calculer que le package courant est package_relatif
  • et c'est ce nom qui sert à déterminer le point de départ de l'import relatif.

Aussi cet import est-il retranscrit en:

from package_relatif import random

Or, le point d'entrée du programme (c'est-à-dire le fichier qui est passé directement à l'interpréteur python) est considéré comme un module dont l'attribut __name__ vaut la chaîne “main”.

Attribut __package__

  • le point d'entrée (celui qui est donné à python sur la ligne de commande) voit comme valeur pour name la constante “main”,
  • et le mécanisme d'import relatif se base sur name pour localiser les modules importés.

Du coup, par construction, il n'est quasiment pas possible d'utiliser les imports relatifs à partir du script de lancement.

Pour pallier à ce type d'inconvénients, il a été introduit ultérieurement (voir PEP 366 ci-dessous) la possibilité pour un module de définir (écrire) l'attribut package, pour contourner cette difficulté

En résumé

On voit que tout ceci est rapidement assez scabreux. Cela explique sans doute l'usage relativement peu répandu des imports relatifs.

De manière générale, une bonne pratique consiste à:

  • Considérer votre ou vos points d'entrée comme des accessoires; un point d'entrée se contente typiquement d'importer une classe d'un module, de créer une instance et de lui envoyer une méthode;
  • toujours placer ces points d'entrée dans un répertoire séparé;
  • notamment si vous utilisez setuptools pour distribuer votre application via pypi.org, vous verrez que ces points d'entrée sont complètement pris en charge par les outils d'installation.

S'agissant des tests:

  • la technique qu'on a vue rapidement (de tester si name vaut “main”) est extrêmement basique et limitée. Le mieux est de ne pas l'utiliser en fait, en dehors de micro-maquettes.
  • En pratique on écrit les tests dans un répertoire séparé (souvent appelé tests) et en tirant profit de la librairie unittest.

du coup les tests sont toujours exécutés avec une phrase comme

python3 -m unittest tests.jeu_de_tests

et dans ce contexte-là, il est possible par exemple pour les tests de recourir à l'import relatif.

cours/informatique/fun_mooc/python3_uca_inria/580_package.txt · Dernière modification : 2021/05/21 17:03 de yoann