Des arguments positionnels avancés avec argsparse

a marqué ce sujet comme résolu.

Bonjour !

Je viens de sortir de ma grotte et en face de moi j’ai trouvé un clavier, du coup j’ai attaqué un petit projet histoire de me dérouiller les mains. :) Je souhaite faire un programme en ligne de commande qui fonctionne avec des actions : c’est à dire comme le programme Git :

  • git add est une action ;
  • git commit est une autre action ;
  • git commit --amend est la même action avec un paramètre ;
  • git add --amend n’a pas de sens et renvoie une erreur.

Le seul truc que j’ai trouvé qui se rapprocherait de ce que j’aimerais faire est (pour reprendre l’exemple de git) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import argparse

def commit(is_amend):
    [...]

def add():
    [...]

if __name__ == '__main__':
    p = argparse.ArgumentParser()
    p.add_argument('command', choices=['commit', 'add'], help='The action to execute')
    p.add_argument('--amend', action='store_true')
    args = p.parse_args()
    if args.command == 'commit':
        commit(args.amend)
    elif args.command == 'add':
        add()

Mais en fait ça ne me convient pas du tout :

  • L’aide du programme Git affiche en détail toutes les actions possible avec leur signification, tandis que mon programme affiche juste: *positional arguments: {commit,add} The action to execute, ce qui est un peu succint ;
  • on ne peut pas avoir une aide dédiée à une action, du style git.py commit -h) ;
  • git.py add --amend (ou même git.py --amend) est valide ici, alors qu’il ne devrait l’être qu’avec l’action commit.

Bref, l’idée serait d’avoir un parseur général et un parseur pour chaque action.

J’ai farfouillé dans la doc de argparse et je n’ai rien trouvé qui correspondait vraiment à ce fonctionnement…

Il faut que je recode un truc pour ça où il y a un truc qui existe déjà, voir c’est possible avec argparse ?

Merci :)

+0 -0

Salut !

J’ai justement ébauché il y a peu un parser dans ce genre (mais sans argparse) pour un gestionnaire de contacts. Voilà le gros du code, si tu veux t’en inspirer. :)

Fichier "main.py"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from commands import cmd_ls
from commands import cmd_add

available_commands = {
    "ls": cmd_ls,
    "add": cmd_add,
}

def command_parser(args):
    parsed_args = {"commands": [], "options": [], "var_options": [], "sysargv": args}
    args = args[1:]
    for arg in args:
        if arg.startswith('--'):
            if '=' not in arg:
                parsed_args["options"].append(arg[2:])
            else:
                option, var = arg.split('=', 1)
                parsed_args["var_options"].append((option[2:], var))
        else:
            parsed_args["commands"].append(arg)
    return parsed_args

if __name__ == '__main__':
    args = command_parser(sys.argv)

    # Prints the main help if there is no command.
    if len(args["commands"]) == 0 and "help" in args["options"]:
        print(main_help)
        exit(0)

    # Prints the script's version.
    if "version" in args["options"]:
        print("PyContacts: v{}".format(__version__))
        exit(0)

    command = available_commands.get(args["commands"][0])
    command(args)
    exit(0)

Fichier "commands.py"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def get_var_option(args, arg, default=''):
    output = ''
    if args["var_options"]:
        output = [couple[1] for couple in args["var_options"] if couple[0] == arg][0]
    if not output:
        return default
    else:
        return output

class Command:
    def __init__(self, args):
        if "help" in args["options"]:
            print(self.__doc__)
            exit(0)
        try:
            self.handle(args)
        except KeyboardInterrupt:
            print(_("\nCancelling."))
            return
        return

    def error(self, message=None):
        if message:
            print(_("Error: {}").format(message))
        else:
            print(_("Error: invalid command."))
        print(_("Type 'PRGM CMD --help' to get the help for this command."))
        exit(0)

class cmd_ls(Command):
    """
        Prints the list of all contacts.
    """

    def handle(self, args):
        if "all" in args["options"]:
            print_all = True
        # Traitement de la commande.

class cmd_add(Command):
    """
        Allows to add a contact or a category of contacts.

        add contact
            Prompts some questions to add a contact.
        add category
            Use an option --name=NAME or ask for a category name
            and creates it.
    """

    def handle(self, args):
        if len(args["commands"]) < 2:
            self.error(_("You must specify what to add (contact / category)."))
        if not any(x in args["commands"][1] for x in ["contact", "category"]):
            self.error()

        if args["commands"][1] == "contact":
            self.add_contact(args)
        else:
            self.add_category(args)
        return

Voilà en gros comment ça marche :

  • Quand tu appelles le programme, la fonction "command_parser()" renvoie un dictionnaire avec toutes les options de ta commande. Par exemple, si tu fait "git commit –ammend –verbose=1", le dictionnaire sera : {"commands": [’commit’], "options": [’ammend’], "var_options": [(’verbose’, 1)], "sysargv": (la variable sys.argv)}.
  • Ensuite, il voit si tu appelles juste "–help" (sans rien d’autre) ou "–version" et il affiche sa variable doc ou sa variable version et il quitte.
  • Sinon, il récupère la première commande et va la chercher dans le dictionnaire des commandes possibles (available_commands) puis l’exécute.
  • Chaque commande est une classe qui hérite de la classe "Command", qui gère les erreurs et les appels à l’option "–help". Sa fonction init appelle la fonction handle() de la classe de la commande, qui elle, fait tout le boulot. Si tu appelles "–help", c’est la variable doc de la classe de la commande qui est affichée.

Si ça peut t’être utile… ;)

+2 -0

Salut,

Je pense que si tu peux utiliser une dépendance externe, il vaut mieux que tu regardes du côté de docopt ou d’une alternative à argparse. Pour moi, argparse est idéal pour les petits scripts légers (vu que c’est intégré de base à Python 2 et 3), mais pour les choses avancées je trouve ça assez limité.

Un bon exemple assez complet utilisant docopt est docker-compose.

EDIT :

Après, si tu fais ça pour le fun, tu peux utiliser du fait maison comme l’a proposé rezemika.

+1 -0

Merci à vous 2 !

Rezemika ton code semble super chouette, mais du coup je vais plutôt partir sur docopt qui m’a l’air franchement bien sympa.

Et lol : git, git add et git commit sont justement proposé en exemple pour les "subparser" ! :D

Et puis j’aime bien la façon dont ça fonctionne, en parsant les docstrings.

+1 -0

Salut,

Il m’est arrivé récemment de réaliser un programme du genre, donc je vais pouvoir te renseigner. J’ai fait cela avec argparse, parce que c’est intégré de base, et parce que je ne suis pas fan des autres solutions (notamment celles comme docopt qui parsent des docstrings)

Premièrement, argparse contient aussi des subparsers, qui sont tout indiqués pour cela.

Tu crées un ensemble de subparsers avec la méthode add_subparsers. L’objet retourné possède ensuite une méthode add_parser que tu peux appeler à plusieurs reprises pour ajouter tes subparsers.

add_parser retourne un objet ArgumentParser, identique à l’objet initial donc, que tu peux manipuler pour ajouter des options et autres.

Pour te donner un exemple, voici un squelette de gestion d’arguments qui serait destiné à récupérer/modifier des entrées de dictionnaire dans un fichier JSON.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import argparse
import json

def cmd_list(args):
    for key in args.db.keys():
        print(key)

def cmd_get(args):
    print('GET', args.key)
    ...

def cmd_set(args):
    print('SET', args.key, args.value)
    ...

parser = argparse.ArgumentParser(description='Store')
parser.add_argument('--db', default='db.json', help='database file', dest='db_file')

subparsers = parser.add_subparsers(title='commands',
  description='Command to execute', metavar='<command>', dest='command_name')
subparsers.required = True

parser_list = subparsers.add_parser('list', help='list all entries')
parser_list.set_defaults(command=cmd_list)

parser_get = subparsers.add_parser('get', help='get value for an entry')
parser_get.set_defaults(command=cmd_get)
parser_get.add_argument('key')

parser_set = subparsers.add_parser('set', help='set a new value')
parser_set.set_defaults(command=cmd_set)
parser_set.add_argument('key')
parser_set.add_argument('value', nargs='?', default=None)

if __name__ == '__main__':
    args = parser.parse_args()

    try:
        with open(args.db_file) as f:
            args.db = json.load(f)
    except FileNotFoundError:
        args.db = {}
    args.command(args)

Et un aperçu de l’aide produite :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
% ./args.py -h
usage: args.py [-h] [--db DB_FILE] <command> ...

Store

optional arguments:
  -h, --help    show this help message and exit
  --db DB_FILE  database file

commands:
  Command to execute

  <command>
    list        list all entries
    get         get value for an entry
    set         set a new value

Ah par contre docopt ne semble plus maintenu : dernier commit datant de août 2016, 172 tickets et 28 PR en attente.

Et comme je viens de tomber sur des erreurs super chelou (qui ne seront pas corrigées) et que j’ai pas envie de m’arracher les cheveux plus longtemps, je vais prendre la solution d’Entwane : back to argparse :)

C’est dommage ça avait l’air bien pratique, notamment pour la lisibilité du code…

+0 -0

Ah par contre docopt ne semble plus maintenu : dernier commit datant de août 2016, 172 tickets et 28 PR en attente.

Les versions d’argparse (spécialement celles de Python 2.7) ne sont pas plus maintenues que docopt : elles sont figées avec les versions de Python. Un projet comme ça n’a pas spécialement besoin de maintenance — et si des programmes connus comme docker-compose l’utilisent, c’est que c’est quand-même une valeur sûre :D

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte