Il n’y a pas de mauvais script…


Ceci est un post invité de atrament posté sous licence creative common 3.0 unported.

… il n’y  a que des scripts en passe de devenir bons. Avis aux débutants, croyez-y, vous ne le resterez pas.

C’est l’histoire d’un des premiers “vrais scripts” que j’ai écrits. Je venais de finir Learning Python et Programming Python de Mark Lutz (à l’époque la seconde édition “couvre python 2 !”).

J’étais même pas un script kiddie, et je ne programmais que pour m’amuser. J’étais fan de Sinfest, un web comic gavé de gros blasphèmes qui tâchent.

Je me suis mis en tête de télécharger tout ces comics d’un coup. Voilà ce que ce script était, et ce qu’il est devenu.

Script originel avec formatage encore moins lisible, sur 0bin.

#~ Script to DL all sinfest webcomics
#~ one can edit the START and END parameters to DL a subset
STARTDAY = 17  # 17
STARTMONTH = 01  # 01
STARTYEAR = 2011  # 2000
ENDDAY = 31
ENDMONTH = 01
ENDYEAR = 2012
ROOTDIR = 'Sinfest'
ROOTURL = "http://www.sinfest.net/comikaze/comics/"
IMGEXT = ".gif"
#~ Code start - no edition from here unless you know what you're doing
#~ --------------------
from distutils.file_util import copy_file
from urllib import urlopen
import os.path
import os
#~ ROOTDIR=os.getcwd()+ROOTDIR
#~ Check if date is out of range
 
 
def checkdate(d, m, y, ed, em, ey, sd, sm, sy):  # end date is ed, em, ey
    if y > ey or (y == ey and m > em) or (y == ey and m == em and d > ed):
        return 0  # date over date range
    elif y < sy or (y == sy and m < sm) or (y == sy and m == sm and d < sd):
        return 0
    else:
        return 1  # date below end date and above start date
#~ make the file name teh sae format than sinfest server
 
 
def getfilename(y, m, d, IMGEXT):
    M =`m`
    D =`d`
    Y =`y`
    if len(M) < 2:
        M = '0' + M
    if len(D) < 2:
        D = '0' + D
    filename = Y + '-' + M + '-' + D + IMGEXT
    return filename
#~ run the loop
if not os.path.isdir(ROOTDIR):
    os.mkdir(ROOTDIR)
for y in range(STARTYEAR, ENDYEAR + 1):
    for m in range(1, 13):
        for d in range(1, 32):
            filename = getfilename(y, m, d, IMGEXT)
            if checkdate(d, m, y, ENDDAY, ENDMONTH, ENDYEAR,
                         STARTDAY, STARTMONTH, STARTYEAR):
                if os.path.isfile(ROOTDIR + '/' + filename):
                    print '[' + filename + '] already exists'
                else:
                    src = urlopen(ROOTURL + filename)
                    if src.info().gettype() == 'image/gif':
                        dst = open(ROOTDIR + '/' + filename, 'wb')
                        dst.write(src.read())
                        dst.close()
                        print '[' + filename + ']' + ' copied'
                    else:
                        print "MIMETYPE ERROR (probably ERROR 404) ignored"
                    src.close()

On remarquera l’arrogance du jeune codeur fier de lui dans certains commentaires. Ils étaient à mon intention, en vrai, et montraient surtout que je ne savais pas trop ce que je faisais.

En vrac, parmi les choses qui me font sourire aujourd’hui :

  • la fonction checkdate qui prend 9 paramètres pour vérifier si une date est entre deux autres,
  • les grosses constantes au début
  • la portabilité nulle du script qui doit être lancé au bon endroit
  • la date codée avec 3 boucles pour l’année, le mois, le jour.
  • Je ne parle même pas de la pep008, elle n’avait même pas un an, d’abord, et puis visiblement on était loin des questions de style…

Bon, il y a prescription, et puis je suis pas mal autodidacte, c’était pas si mal : ça fonctionnait ! Ça m’a quand même servi, de temps en temps, jusque 2012, pour remettre à jour mes dossiers d’images.


Et puis je l’ai relu et j’ai décidé de le reprendre avec ce que je savais de nouveau :

  • le module datetime,
  • la construction if __name__ == '__main__',
  • les exceptions,

et ça a donné ça :

# -*- coding: utf-8 -*-
#! /usr/bin/python
# Script to DL all sinfest webcomics
# one can edit the START and END parameters to DL a subset
#-------------------------------------------------------------------------------
# Name:        getSinfest
# Purpose:
#
# Author:      Atrament
#
# Created:     12/04/2013
# Copyright:   (c) Atrament 2013
# Licence:     
#-------------------------------------------------------------------------------
 
import datetime
from urllib.request import urlopen
import os.path
import os
from urllib.error import HTTPError
 
 
def main():
    debut=datetime.date(2000,1,17)
    curGif=debut
    if not os.path.isdir("Sinfest"):
        os.mkdir("Sinfest")
    while curGif<=datetime.date.today():
        if not os.path.isfile("Sinfest/"+curGif.isoformat()+".gif"):
            try:
                src=urlopen("http://www.sinfest.net/comikaze/comics/"+curGif.isoformat()+".gif")
                dst=open("Sinfest/"+curGif.isoformat()+".gif",'wb')
                dst.write(src.read())
                dst.close()
                print("    "+curGif.isoformat()+".gif : fetched.")
                src.close()
            except HTTPError as e:
                print("on "+curGif.isoformat()+" error happened. So is life.")
        else:
            print(curGif.isoformat()+" : ok")
        curGif+=datetime.timedelta(days=1)
 
if __name__ == '__main__':
    main()

Et honnêtement ça s’améliore (un peu): un shebang (pas tout à fait en haut du fichier, mais j’apprendrai plus tard), bien que je code à l’époque uniquement sous windows avec pyscripter, qui me fournit les commentaires de début de fichier, qui ne servent à rien, mais à l’époque m’éclatent. Ben oui, à ce moment là, je suis à peine adulte, et avec les heures que j’ai passé sur IDLE à tâtonnner, c’est une victoire pour moi.


C’est un peu plus tard encore que j’apprends que les requêtes internet qui prennent des heures, c’est pas obligatoire, parce qu’on peut faire du multi thread, et continuer de tourner pendant qu’on attend qu’une autre tâche s’accomplisse. Voilà de quoi faire rêver un jeune programmeur : la puissance du multi-coeurs à la portée de mon code ! (non, je ne savais pas que c’est faux). En conséquence, mon script évolue, pour devenir… ça. Attention, ça pique les yeux.

# -*- coding: UTF-8 -*-
# ---------------------------------------------------------------------
# Author:      Atrament
# Licence:     CC-BY-SA https://creativecommons.org/licenses/by-sa/4.0/
# ---------------------------------------------------------------------
# All libs are part of standard distribution for python 3.4
import imghdr
import os
import queue
from threading import Thread
import datetime
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
import zipfile
import sys
 
 
# Useful functions
def make_cbz(directory):
    for year in range(2000, datetime.date.today().year + 1):
        with zipfile.ZipFile("Sinfest-{}.cbz".format(year), "w") as archive:
            for gif_file in [x for x in os.listdir(directory) if x.split("-")[0] == str(year)]:
                archive.write(directory + '/' + gif_file, arcname=gif_file)
        print("Sinfest-{}.cbz has been generated".format(year))
 
 
def confirm(prompt):
    if input(prompt + " (y/n)") in "yY":
        return True
    else:
        return False
 
 
def file_needs_download(filename):
    """Checks whether a file exists, is corrupt, so has to be downloaded again
    also cleans garbage if detected"""
    if not os.path.isfile(filename):
        # many comics are *supposed* to be missing,
        # no need to output for these (uncomment for debug)
        # print("IS NOT FILE :", filename)
        return True
    elif os.path.getsize(filename) == 0:
        print("WRONG SIZE for", filename)
        return True
    elif filename.split(".")[-1] != "gif":
        print(filename, "IS NOT GIF")
        return True
    elif filename.split(".")[-1] in {"jpg", "gif", "png"} and imghdr.what(filename) is None:
        # Encoding error...
        print("WRONG FILE STRUCTURE for", filename)
        return True
    else:
        return False
 
 
def conditional_download(filename, base_url):
    if file_needs_download(filename):
        try:
            src = urlopen(base_url + filename)
            dst = open(filename, 'wb')
            dst.write(src.read())
            # gracefully close theses accesses.
            dst.close()
            src.close()
            print("\t" + filename + " : fetched.")
        except HTTPError:
            # many days do not have a comic published.
            # no need to flood the console for this.
            pass
        except URLError:
            pass
            print("network error on " + filename)
        finally:
            # clean garbage on disk, useful if failure occurred.
            file_needs_download(filename)
 
 
class ThreadedWorker():
    def __init__(self, function=None, number_of_threads=8):
        self.queue = queue.Queue()
 
        def func():
            while True:
                item = self.queue.get()
                if function:
                    function(item)
                else:
                    print(item, "is being processed.")
                self.queue.task_done()
 
        self.function = func
 
        for i in range(number_of_threads):
            t = Thread(target=self.function, name="Thread-{:03}".format(i))
            t.daemon = True
            t.start()
 
    def put(self, object_to_queue):
        self.queue.put(object_to_queue)
 
    def join(self):
        self.queue.join()
 
    def feed(self, iterator):
        for task in iterator:
            self.queue.put(task)
 
 
def download_sinfest(target_folder):
    """
    Creates a directory and fetches Sinfest comics to populate it in full.
    """
    if not os.path.isdir(target_folder):
        os.makedirs(target_folder)
    os.chdir(target_folder)
 
    f = lambda filename: conditional_download(filename, "http://www.sinfest.net/btphp/comics/")
    # Make a worker with this function and run it
    t = ThreadedWorker(function=f, number_of_threads=20)
    # structure of comprehended list is a bit complex to generate all file names
    files = [(datetime.date(2000, 1, 17) + datetime.timedelta(days=x)).isoformat() + ".gif"
             for x in range((datetime.date.today() - datetime.date(2000, 1, 17)).days + 1)]
    t.feed(files)
    t.join()
 
 
if __name__ == "__main__":
    if any(("y", "Y", "-y", "-Y" in sys.argv)):
        folder = os.path.expanduser("~/Pictures/Sinfest/").replace("\\", "/")
        print("\nproceeding to download...")
        download_sinfest(folder)
        print("\ngenerating comic book files (.cbz)...")
        os.chdir(folder)
        os.chdir("..")
        make_cbz(folder)
    else:
        folder = os.path.expanduser("~/Pictures/Sinfest/").replace("\\", "/")
        while not confirm("Target to downloads is {} ?".format(folder)):
            folder = input("Please enter new folder (N to abort) :")
            if folder in "nN":
                exit(0)
        if confirm("Proceed to download ?"):
            download_sinfest(folder)
        if confirm("Do you want to generate cbz (comic books) files ?"):
            os.chdir(folder)
            os.chdir("..")
            make_cbz(folder)
        input("Finished. Please press Enter")

C’est une horreur. Si vous êtes de ces perfectionnistes qui lisent le code et font la code review par habitude, je suis désolé, vous avez du pleurer.  Le commentaire sur la lib standard “en intro” montre que à ce moment là, j’ai un peu conscience qu’on peut accéder à d’autres modules, mais on est loin de pip pour moi. Je tente maladroitement d’exploiter sys.argv (avec des erreurs qui pourraient être catastrophiques),  et l’input à défaut. Une fonctionnalité neuve est apparue : faire des archives comic book en zip. Je suis fier.

Mais le côté comique de ce code, c’est la classe ThreadedWorker. C’est ptêt bien ma première ‘vraie’ classe, mais j’implémente moi-même un dispatcheur de jobs sur des threads. Il y a de quoi être fier, mais on n’est pas du tout dans du python propre, là. C’est ballot, la version précédente était pas si mal, niveau clarté.

Et dire que la lib standard en fournit un, de dispacheur de jobs…


Je passe quelques itérations sur des détails, aujourd’hui il en est là, ce script.

#! /usr/bin/env python3.5
import imghdr
import os
import datetime
import zipfile
from concurrent.futures import ThreadPoolExecutor
 
import requests
import begin
 
 
# Useful functions
def make_cbz(dst_directory, src_directory):
    for year in range(2000, datetime.date.today().year + 1):
        with zipfile.ZipFile("Sinfest-{}.cbz".format(year), "w") as archive:
            for filename in os.listdir(src_directory):
                if filename.startswith(str(year)):
                    archive.write(src_directory + "/" + filename, arcname=filename)
        print("Sinfest-{}.cbz has been generated".format(year))
 
 
def file_needs_download(filename):
    """Checks whether a file exists, is corrupt, so has to be downloaded again
    also cleans garbage if detected"""
    if not os.path.isfile(filename):
        # many comics are *supposed* to be missing,
        # no need to output for these (uncomment for debug)
        # print("IS NOT FILE :", filename)
        return True
    elif os.path.getsize(filename) == 0:
        print("WRONG SIZE for", filename)
        return True
    elif filename.split(".")[-1] != "gif":
        print(filename, "IS NOT GIF")
        return True
    elif filename.split(".")[-1] in {"jpg", "gif", "png"} and imghdr.what(filename) is None:
        # Encoding error...
        print("WRONG FILE STRUCTURE for", filename)
        return True
    else:
        return False
 
 
def conditional_download(filename, base_url, caller=None):
    if file_needs_download(filename):
        src = requests.get(base_url + filename)
        # manage failure to download
        if src.status_code == 404:
            src.close()
            return  # ignore it, that file is simply missing.
        if src.status_code != 200:  # an error other than 404 occurred
            # print("Error {} on {}".format(src.status_code, filename))
            src.close()
            if caller:  # retry that file later
                caller.submit(conditional_download, filename, base_url, caller)
        # actually copy that file
        dst = open(filename, 'wb')
        dst.write(src.content)
        # gracefully close theses accesses.
        dst.close()
        src.close()
        print("\t{} : fetched.".format(filename))
 
 
def download_sinfest(target_folder):
    """
    Source function for the process
    Creates a directory and fetches Sinfest comics to populate it in full.
    """
    if not os.path.isdir(target_folder):
        os.makedirs(target_folder)
    os.chdir(target_folder)
 
    with ThreadPoolExecutor(max_workers=64) as executor:
        for file in ("".join([(datetime.date(2000, 1, 17) + datetime.timedelta(days=x)).isoformat(), ".gif"])
                     for x in range((datetime.date.today() - datetime.date(2000, 1, 17)).days + 1)):
            executor.submit(conditional_download, file, "http://www.sinfest.net/btphp/comics/", executor)
 
 
@begin.start
def run(path: "folder in which the comics must be downloaded" = os.path.expanduser("~/Sinfest/"),
        makecbz: "Compile CBZ comic book archives" = False):
    """Download the Sinfest WebComics"""
    download_sinfest(path)
    if makecbz:
        dst = path[:-1] if path.endswith('/') else path
        dst = "/".join(dst.split("/")[:-1])
        make_cbz(dst, path)
    print("Finished. Goodbye.")
    exit()

Enfin je m’autorise à utiliser des modules tierces. Alors, ça marche, même plutôt vite et bien. Mais c’est largement perfectible : si c’est mieux que par le passé, ça ne correspond pas à ce que je code aujourd’hui : il y a beaucoup de code hérité des anciennes versions et la prochaine évolution serait de le réécrire entièrement. Mais je ne lis plus vraiment Sinfest.


 

Mais à écrire ce texte, j’ai pris les nerfs, et j’ai refait tout ça. J’ai fait un effort, et j’ai tout jeté à github.

Encore un pas de mieux : les fichiers ont du sens, il y a une classe abstraite pour faire une pseudo-API, c’est devenu pratiquement évolutif.

 

La conclusion à l’attention du débutant : chacun voit et juge son code et celui des autres selon son niveau de compétence à l’instant T. On a tous été fiers comme Artaban de bouts de code laids à nos yeux d’aujourd’hui. Débutant qui m’a lu (merci, t’as bien du courage), garde tes scripts à travers les années, reprends les, tu te rendras compte de tous les progrès que tu fais au fil du temps. Quand un type te dis sur internet que ce que tu fais n’est pas “pythonique”, il a sûrement raison, de son point de vue, et il y a sans doute un autre gars qui lui dira la même chose demain. La courbe d’apprentissage est longue comme la vie.

11 thoughts on “Il n’y a pas de mauvais script…

  • DrHaze

    Quelques typos et autres:

    – Avis aux débutants

    – gavé de gros blasphèmes qui tâchent

    Et dire que la lib standard (oui je suis tatillon)

    – niveau de compétence

    J’ai eu le courage de lire l’article, étant débutant, merci. J’ai commencé à faire du Python il y a deux ans maintenant dans un studio de 3D. Je code principalement des petits scripts et outils sur Maya et Nuke pour les artistes de la boîte.

    Il y a quelques mois, j’ai jeté un coup d’oeil aux premiers scripts que j’ai fait. J’étais pas fier.

    Puis je me suis mis en tête de reprendre un peu tout ça avec les connaissances que j’avais acquise au fil du temps. Ces connaissances elles viennent principalement de ce blog. J’ai pas encore pu mettre en pratique les plans à quatre par contre, ni à trois :(

    Résultat, sur un outil qui permet d’exporter des anims, je suis passé de 400 lignes de code à 100 et des brouettes pour un script qui met trois fois moins de temps à s’exécuter… J’étais fier.

    Maintenant, il me tarde juste d’être dans deux ans, revoir les scripts qui sont ouverts actuellement sur l’écran à ma droite et éclater de rire (ou de désespoir).

    Bref, merci atrament pour cet article qui fait du bien à un débutant. Et merci S&M pour le reste des articles, je vous lis mais ne commente pas beaucoup, mais vos articles m’aident énormément.

  • elnazi

    Il n’y a pas de mauvais script, il n’y a que des mauvais problèmes ;)

  • buffalo974

    Article sympa ;-)

    Après avoir beaucoup bidouillé pleins de tests et micro-projets et avoir appris

    grâce au traceback, forums et à S&M je butte sur un probleme:

    l’ architecture.

    Je fait des schemas UML de projets qui m’ intéressent mais j’ai du mal avec les design pattern. Les étudier en anglais en C++ c’est vraiment pas agréable.

    Et quand j’en trouve quelques un en français Et en python, c’est vague, abstrait.

    Je cherche des exemples percutants, détaillés sous toutes les coutures, en python et en français. Ouais je sais…

    Et quand j’aurai trouvé ça, je me demande quel sera le prochain seuil.

  • Sylvain

    Article assez intéressant. J’ai l’impression que les comparaisons dans les bouts de code passent mal (un problème de html entity escape j’imagine).

    C’est cool que que tu aies mis la dernière version sur github, j’ai un projet perso très similaire https://github.com/SylvainDe/ComicBookMaker mais le tien a l’air mieux organisé, je vais pouvoir en prendre de la graine.

  • Nattefrost

    @buffalo je crois justement que les gars d’indexerror/sametmax avaient lancé un projet du genre, un repo github avec des exemples de patterns en python, mais hélas j’ai plus le lien, quelqu’un se souvient de ce dont je cause?

  • boblinux

    @Nattefrost

    Oui c’est même @Buffalo qui a posé la question, donc je pense qu’il rechercher autre chose, je suppose que tu vises ce repo (d’IndexErrorCoders), mais ce n’est qu’un vulgaire fork, il faut le traduire en français, et faire des exemples plus user friendly. Si quelqu’un est chaud c’est l’occasion

  • atrament Post author

    @DrHaze:

    En principe j’ai corrigé pour tenir compte de tes corrections orthographiques.

    Merci de ce retour, c’est exactement pour ça que je l’ai écrit. Personne n’est devenu un dieu de la prog du jour au lendemain, pas même Sam ni Max. Personne n’a la sacro-sainte vérité universelle de python.

    Mais pourtant, les débutants ont honte de montrer ce qu’ils font. Ils devraient être fiers, mais ils cachent leurs codes, se privant de progresser, et réinventant la roue les uns après les autres. C’est pas innocent, si j’ai choisi ce script de téléchargement de comics, parmi mes scripts que j’ai gardés. On en a tous fait un, en python ou pas. Et on doit à peu près tous être insatisfaits de nos premiers jets.

    ET C’EST PAS GRAVE ! Quand un gamin fait ses premiers pas titubants, on le félicite, on ne le gronde pas parce qu’il ne va assez vite ou assez droit.

  • buffalo974

    @boblinux : oui exactement. C’est pas facile de trouver un truc tout cuit, faut souvent s’arracher les cheveux pour comprendre un dixième de ce qu’on veut.

    @ atrament : je pense que la honte vient du ratio energie dépensée / resultat obtenu.

    Quand un proche te vois bosser de façon assidue, et qu’il te dit “montres moi ce que tu sais faire depuis l’temps…”.

    Là tu as honte, parceque même sans intention de te vexer , il peut pas s’ empecher de pointer du doigt tout ce qui n’est pas implémenté, ou va de travers. Sans aucune perception de ce qui a le mérite de déjà fonctionner.

    Mais si tu fais une merde avec une jolie interface, t’es la réincarnation de cyber-bouddah. Et si y’a un .exe, t’es un hacker !

  • Hadrien

    Pour un giga-noob comme moi (pareil, j’ai fait trois scripts et demi pour rigger sous Maya), c’est super décourageant. Man, j’voulais tout savoir en deux semaines; et tu me dis que c’est long comme la vie ! En tout cas merci pour les notes c’est vraiment intéressant.

  • Paul

    Quand je pense que mon script, pour récupérer des vidéos de certains sites, simplement print des wget +xarg commandes que je copy paste dans une nouvelle fenêtre de terminal, je me sens un peu con. Le vrai problème c’est qu’écrire ces scripts c’est l’éclate et je peux y passer tout mon week-end. :-)

Comments are closed.

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