Tim-ats

Articles

Un curses plus pythonique

curses est une librairie servant à interagir de manière graphique avec les terminaux. Ses premières versions sont parues dans les années 1990.

Originellement implantée en C, curses a été porté pour Python au sein d'un module du même nom présent dans la librairie standard (du moins sur les systèmes d'exploitation UNIX/POSIX). Cependant, ce module n'est qu'un simple binding des fonctions et méthodes de la bibliothèque curses d'origine.

Ce choix comporte des avantages - le port d'application C est facilité - mais aussi un inconvénient majeur : les deux philosophies de ces langages divergent grandement.

En effet, le C cherche à offrir du contrôle, quitte à alourdir sa syntaxe. Python, quant à lui, privilégie une approche plus simpliste, mais aussi syntaxiquement allégée.

Ce modeste article présente donc quelques recettes pour travailler plus confortablement avec curses en Python. J'ai compilé cette collection de snippets dans une petite librairie dédiée dont les sources sont disponibles sur ce dépot git.

Type

Depuis l'ajout des annotations de typages en Python 3.5, cette fonctionnalité est devenue un prérequis dans de nombreux projets. Malheureusement, curses ne dispose d'aucun type accessible à l'import.

Créons-en donc quelques-uns pour agrémenter les signatures de nos fonctions :

from typing import Union

# Une union de type des types des valeurs retournées par les méthodes win.getch et win.getkey.
CursesKey = Union[int, str]

# Ou en Python 3.10+
CursesKey = int | str

Un alias de type pour les attributs textuels de curses - qui sont des entiers - :

CursesTextAttr = int

Et un alias pour les objets fenêtres.

try:
    import _curses
    CursesWin = _curses.window
except ImportError:
    pass

Pour une raison que j'ignore, le module _curses n'est importable qu'en Python 3.8+.

Il est courant dans les applications réalisées avec curses que des fonctions ne produisent que des effets à l'écran sans retourner de valeur spécifique. Il peut être judicieux d'indiquer ce comportement en annotant le type de retour de vos fonctions :

from typing import NoReturn

ConsoleEffect = NoReturn

De cette façon, l'effet que produit une procédure sur son environnement apparaît dans la signature de la fonction annotée.

Combinaison d'attributs

On peut combiner les effets des attributs textuels avec l'opérateur bitwise OR (|). Ainsi,

win.addstr(0, 0, "Hello world", curses.A_BOLD | curses.A_ITALIC)

affichera la chaîne Hello world en gras et italique.

Bien que pratique, ce comportement devient lourd dès lord que l'on manipule de nombreux attributs à la fois. En effet, chaîner "manuellement" chaque attribut entre un opérateur s'avère peu pratique. Or, un des concepts clé de la philosophie de Python est l'itération. Reproduisons donc ce comportement, non pas avec une succession d'opérateurs, mais à l'aide d'une fonction.

La fonction combine_attr - annotée avec les types définis précédemment - prend un itérable d'attributs et les retourne combinés :

import functools
from typing import Iterable


def combine_attr(attributes: Iterable[CursesTextAttr]) -> CursesTextAttr:
    """Combine the given curses text attributes iterable and return it."""
    return functools.reduce(lambda x, y: x | y, attributes)

Et s'utilise comme ça :

>>> from curses import A_BOLD, A_REVERSE
>>> text_attr = (A_BOLD, A_REVERSE)
>>> combine_attr(text_attr) == (A_BOLD | A_REVERSE)
True

Nous obtenons une fonction à la fois flexible - elle accepte tout type d'itérables (générateurs, n-uplets etc) - et élégante.

Gestion des couleurs

Il est fréquent de devoir activer certains attributs sur une fenêtre, d'effectuer des opérations graphiques sur cette dernière puis de les désactiver ceci fait.

Python fournit un type d'objet exactement destiné à cet usage : les gestionnaires de contexte (plus couramment appelés context managers).

Écrivons-en on un pour remplacer le très laid :

from curses import A_BOLD, A_ITALIC

def func(win)
    win.attron(A_BOLD, A_ITALIC)
    win.addstr(0, 0, "foo")
    win.addstr(1, 0, "bar")
    win.attroff(A_BOLD, A_ITALIC)

par un superbe :

from curses import A_BOLD, A_ITALIC

def func(win):
    with TextAttributes(win, A_BOLD, A_ITALIC):
        win.addstr(0, 0, "foo")
        win.addstr(1, 0, "bar")

qui plus est dans un pur style pythonique.

Commençons par définir TextAttributes sous forme d'une simple classe. Il est alors nécessaire de définir deux méthodes : __enter__ et __exit__.

__enter__ contiendra le code qui sera exécuté à l'entrée dans le bloc with et __exit__ celui exécuté après l'évaluation de toutes les instructions englobées dans le bloc with.

from contextlib import ContextDecorator

class TextAttributes(ContextDecorator):
    """A context manager to manage curses text attributes."""

    def __init__(self, win, *attr: CursesTextAttr):
        self.win = win
        self.attributes = attr

    def __enter__(self):
        for attr in self.attributes:
            self.win.attron(attr)

    def __exit__(self, type, value, traceback):
        for attr in self.attributes:
            self.win.attroff(attr)

Notons que l'héritage de la classe ContextDecorator du module contextlib autorise l'utilisation de TextAttributes en tant que décorateur.

Une autre approche peut consister à définir text_attributes - le changement de nom est dû aux conventions de nommage des fonctions - directement sous forme d'un décorateur.

Nous obtenons alors un code un plus alambiqué :

from functools import wrap

def text_attributes(*attributes: CursesTextAttr):
    """A decorator to manage curses text attributes."""
    def decorator(func):
        @wraps(func)
        def wrapper(win, *args, **kwargs):
            for attr in attributes:
                win.attron(attr)

            result = func(win, *args, **kwargs)

            for attr in attributes:
                win.attroff(attr)

            return result
        return wrapper

    return decorator

Peu importe la manière avec laquelle sont implémentés ces objets, on peut leurs passer une infinité d'attributs en paramètre ou lors de leur création. Ce comportement offre une grande flexibilité quand il est utilisé de paire avec l'unpacking :

from curses import A_ALTCHARSET, A_REVERSE

ATTR = (A_ALTCHARSET, A_REVERSE)

def greeting(win):
    with TextAttributes(win, *ATTR):
        win.addstr(0, 0, "Hello")

Le même exemple que ci-dessus, utilisant cette fois le décorateur :

from curses import A_ALTCHARSET, A_REVERSE

ATTR = (A_ALTCHARSET, A_REVERSE)

@text_attributes(*ATTR)
def greeting(win):
    win.addstr(0, 0, "Hello")

Itérer sur des entrés clavier

Pour récupérer des entrées clavier, on écrit fréquemment :

EXIT_KEYS = ["q"]

key = 0
while key not in EXIT_KEY:
    some_drawing()
    key = win.getkey()

Utiliser un générateur en lieu et place d'une boucle while dissimule les instructions récupératrices d'entrées clavier à l'intérieur d'un mécanisme d'itération classique et permet de réduire la verbosité au sein de la boucle :

from typing import Container, Iterator

def iterkey(win, exit_keys: Container[CursesKey] = ()) -> Iterator[CursesKey]:
    """Yield key pressed as long as a key contained in exit_keys is not
    pressed.
    """
    while 1:
        key = win.getkey()

        if key in exit_keys:
            break
        else:
            yield key

Et cela s'utilise comme suit :

EXIT_KEYS = ["q"]

for key in iterkey(win, EXIT_KEYS):
    some_drawing()

Pour plus de souplesse, ajoutons un paramètre optionnel à cette fonction afin de laisser à l'utilisateur le choix de la méthode de détection utilisée :

from typing import Container, Iterator, Literal

curses_input_method = Literal["getch",
                              "getkey",
                              "getwch"]

def iterkey(win,
            exit_keys: Container[CursesKey] = (),
            method: curses_input_method = "getkey") -> Iterator[CursesKey]:
    """Yield key pressed as long as a key contained in exit_keys is not
    pressed.
    """
    input_method = getattr(win, input_method)

    while 1:
        key = input_method()

        if key in exit_keys:
            break
        else:
            yield key

Cette possibilité laissée à l'utilisateur exploite les capacités de la fonction built-in getattr.

Voyons maintenant un exemple qui tire parti de cette nouvelle fonctionnalité :

keys = iterkey(win, ["q"], method="getch")

for key in keys:
    win.addstr(0, 0, str(key))
    win.refresh()

iterkey yieldera maintenant le code décimal des touches pressées.

Liens utiles