PROJET AUTOBLOG


Sam et Max

source: Sam et Max

⇐ retour index

Mise à jour

Mise à jour de la base de données, veuillez patienter...

Rendre un élément scrollable avec Angular

mardi 1 juillet 2014 à 11:30

Petit snippet que j’utilise dans mes apps angular. Ça permet de définir un comportement quand l’utilisateur scrolle au-dessus d’un élément. Typiquement, augmenter la valeur d’un champ, faire défiler un carousel, etc. Il faut, bien entendu, éviter que la page scrolle elle-même.

Implémentation

app.directive('wheelable', function() {
"use strict";
 
  /* On définit sur quels attributs on va mettre les callbacks */
  var directive = {
      scope: {
          'onWheelUp': '&onwheelup',
          'onWheelDown': '&onwheeldown'
      }
  };
 
  /* On limite la directive aux attributs */
  directive.restrict = 'A';
 
  /* Le code qu'active la directive quand on la pose sur l'élément */
  directive.link = function($scope, element, attributes) {
 
      /* On attache un callback à tous les événements de scrolling */
      element.bind('mousewheel wheel', function(e) {
 
        /* On vérifie si l'utilisateur scroll up ou down */
        if (e.originalEvent) {
          e = e.originalEvent;
        }
        var delta = (e.wheelDelta) ? e.wheelDelta : -e.deltaY;
        var isScrollingUp = (e.detail || delta > 0);
 
        /* On appelle le bon callback utilisateur */
        if (isScrollingUp){
          $scope.$apply($scope.onWheelUp());
        } else {
          $scope.$apply($scope.onWheelDown());
        }
 
        /* On évite que la page scrolle */
        e.preventDefault();
      });
  };
 
  return directive;
});

Usage

Comme pour toutes les directives qui impliquent des callbacks, il faut définir des fonctions et les attacher à votre scope dans un controleur (ou un service attaché au controleur) :

app.controller('FooCtrl', function($scope) {
"use strict";
  $scope.votreCallBackPourQuandCaScrollDown = function(){
    // faire un truc par exemple moi je l'utilise pour changer
    // la valeur de l'élément.
  };
  $scope.votreCallBackPourQuandCaScrollDown = function(){
    // faire un autre truc
  };
});

La directive s’utilise en mettant l’attribut wheelable sur l’élément qu’on veut rendre scrollable. Ensuite on déclare dans les attributs onwheeldown et onwheelup le code à exécuter, et zou :

<div ng-controller="FooCtrl">
  ...
  <input type="text" wheelable
         onwheeldown="votreCallBackPourQuandCaScrollDown()"
         onwheelup="votreCallBackPourQuandCaScrollUp()"
         >
  ...
</div>

flattr this!

Aller plus loin avec les hash maps en Python

lundi 30 juin 2014 à 05:28

Les hash map sont souvent sous-utilisés, surtout par les personnes venant d’un autre langage avec implémentation vraiment batarde du concept. Les arrays en PHP et les objets en Javascript étant parmi les pires exemples.

Le point d’entrée pour les hash maps en Python, c’est le dictionnaire. Et la plupart des gens ont pigé le principe de l’association clé / valeur :

>>> d = {}
>>> d['cle'] = 'valeur'
>>> d['cle']
'valeur'
>>> d['pas cle']
Traceback (most recent call last):
  File "<ipython-input-12-eed7cf6f5344>", line 1, in <module>
    d['pas cle']
KeyError: 'pas cle'

L’intérêt du dictionnaire étant qu’accéder à une clé est très rapide (c’est une opération O(1)), tout comme vérifier qu’une clé est présente dans le dico :

>>> 'cle' in d
True

Mais généralement les gens s’arrêtent là.

Itération

Parfois, ils vont plus loin, et tentent l’itération dessus :

>>> scores = {"Joe": 1, "Jonh": 5, "Jack": 3, "Jenny": 7, "Jeanne": 0, "July": 3}
>>> for score in scores:
    print(score)
...
Jenny
Jack
Joe
July
Jonh
Jeanne

Ils s’aperçoivent qu’on peut uniquement récupérer les clés, et essayent de faire ça :

>>> for nom in scores:
    print(nom, scores[nom])
...
Jenny 7
Jack 3
Joe 1
July 3
Jonh 5
Jeanne 0

Rapidement ils sont corrigés par quelques collègues qui leur expliquent qu’on peut faire ceci :

>>> for nom, score in scores.items():
    print(nom, score)
...
Jenny 7
Jack 3
Joe 1
July 3
Jonh 5
Jeanne 0

Sans vraiment expliquer pourquoi. Si vous êtes curieux, cela marche grâce à l’unpacking.

Ensuite ils vont chercher à afficher des choses dans l’ordre, mais un dictionnaire n’est pas ordonné. Là commencent les embrouilles : dans l’ordre des clés, des valeurs ou dans l’ordre d’insertion ?

Dans l’ordre des clés ou des valeurs, il faut se taper le tri à chaque fois :

>>> for nom, score in sorted(scores.items()):
    print(nom, score)
...
Jack 3
Jeanne 0
Jenny 7
Joe 1
Jonh 5
July 3
>>> for nom, score in sorted(scores.items(), key=lambda x: x[1]):
    print(nom, score)
...
Jeanne 0
Joe 1
Jack 3
July 3
Jonh 5
Jenny 7

Dans l’ordre d’insertion par contre, ce n’est pas possible avec le dictionnaire. Mais voilà l’astuce : le hash map en Python, ce n’est pas QUE le type dict.

Pour ce problème, on peut utiliser collections.OrderedDict :

>>> from collections import OrderedDict
>>> d = OrderedDict()
>>> d['Jeanne'] = 3
>>> d['Jack'] = 2
>>> d['July'] = 6
>>> for nom, score in d.items():
        print(nom, score)
...
Jeanne 3
Jack 2
July 6

Après il y a le rare problème, mais tout de même existant, de la très très grosse structure de données que l’on veut itérer dans l’ordre de clés :

>>> import random
>>> l = range(10000000)
>>> random.shuffle(l)

Si on fait un sort dessus, ça prend plusieurs secondes :

>>> l.sort()

Imaginez avec un dico qui contient un million de clés sous forme de texte. La lecture dans l’ordre sera très, très lente. Parfois ce n’est pas grave, et parfois c’est très emmerdant.

La stdlib de Python ne permet pas de répondre à ce problème facilement. On pourrait bricoler quelque chose avec heapq, mais franchement, c’est se casser la tête pour rien.

Le plus simple est d’utiliser une lib externe, par exemple l’excellente sorted_container, qui en plus d’être très rapide, est en pur Python. Du coup, un peu de pip :

pip install sorted_container

Et on est bon.

>>> from sortedcontainers import SortedDict
>>> d = SortedDict()
>>> d['Joe'] = 1
>>> d['Jeanne'] = 6
>>> d['July'] = 3
>>> d['John'] = 3
>>> for nom, score in d.items():
    print(nom, score)
...
Jeanne 6
Joe 1
John 3
July 3

SortedDict s’assure que le dictionnaire reste ordonné à chaque insertion d’un élément, et ainsi, vous évite de devoir faire un tri tout à la fin.

Initialisation

La plupart du temps, on utilise la notation littérale. Mais le constructeur dict trouve son utilité dans le fait qu’il accepte un itérable de tuples en paramètre :

>>> dict([("a", 1), ("b", 2)])
{'a': 1, 'b': 2}

La plupart du temps, les gens n’en voient pas l’utilité. Mais il faut se rappeler que tout le langage Python est organisé autour de l’itération. Je ne cesse de le répéter, en Python, l’itération est tout.

De fait, cette particularité du constructeur du dico vous permet de créer des dictionnaires à partir de structures existantes inattendues…

Prendre deux séquences et les pairer :

>>> personnes = ('Joe', 'John', 'Jean-Michel')
>>> scores = (4, 10, 34)
>>> zip(personnes, scores)
[('Joe', 4), ('John', 10), ('Jean-michel', 34)]
>>> dict(zip(personnes, scores))
{'Jean-michel': 34, 'John': 10, 'Joe': 4}

Pairer les deux derniers champs du résultat d’une commande :

>>> import subprocess
>>> df = subprocess.check_output('df')
>>> print(df)
Sys. de fichiers       blocks de 1K  Utilisé Disponible Uti% Monté sur
/dev/sda7                   7972000  6614840     929156  88% /
none                              4        0          4   0% /sys/fs/cgroup
udev                        1968688        4    1968684   1% /dev
tmpfs                        395896     1112     394784   1% /run
none                           5120        0       5120   0% /run/lock
none                        1979472      160    1979312   1% /run/shm
none                         102400       44     102356   1% /run/user
/dev/sda5                  65438480 57693436    4397852  93% /media/sam/
>>> dict(l.split()[-2:] for l in  list(df.split('\n'))[1:] if l)
{'31%': '/media/truecrypt1', '1%': '/run/user', '93%': '/media/sam', '88%': '/', '0%': '/run/lock'}

Depuis Python 2.7, cette fonctionnalité est partiellement phagocytée par la syntaxe pour les intentions sur les dicos :

>>> from pprint import pprint
>>> pprint( {line: num for num, line in enumerate(open('/etc/fstab'), 1)})
{'#\n': 6,
 '# / was on /dev/sda7 during installation\n': 8,
 '# /etc/fstab: static file system information.\n': 1,
 '# <file system> <mount point>   <type>  <options>       <dump>  <pass>\n': 7,
 "# Use 'blkid' to print the universally unique identifier for a\n": 3,
 '# device; this may be used with UUID= as a more robust way to name devices\n': 4,
 '# swap was on /dev/sda6 during installation\n': 10,
 '# that works even if disks are added and removed. See fstab(5).\n': 5,
 'UUID=4c0455fb-ff57-466a-8d1f-22b575129f4f none            swap    sw              0       0\n': 11,
 'UUID=4f560031-1058-4eb6-a51e-b7991dfc6db7 /               ext4    errors=remount-ro 0       1\n': 9,
 'UUID=b27f7e93-60c0-4efa-bfae-5ac21a8f4e3c /media/sam ext4 auto,user,rw,exec 0 0\n': 12}

Cela dit, on n’a pas toujours besoin de clés ET de valeurs pour créer un dictionnaire. Ainsi, si on a une liste de n’clés qu’on veut toutes initialiser à la même valeur, la très peu connue méthode fromkeys nous rendra bien service :

>>> personnes = ('Joe', 'John', 'Jean-michel')
>>> dict.fromkeys(personnes, 0)
{'Jean-michel': 0, 'John': 0, 'Joe': 0}

De même, on peut ne pas vouloir initialiser un dico, mais vouloir une valeur par défaut pour toutes les clés. collections.defaultdict est fait pour ça. En plus, les valeurs peuvent être dynamiques :

>>> from collections import defaultdict
>>> scores = defaultdict(lambda: 0)
>>> scores['Joe']
0
>>> scores['Joe'] = 1
>>> scores['Joe']
1
>>> scores['July']
0
>>> import datetime
>>> naissances = defaultdict(datetime.datetime.utcnow)
>>> naissances['Joe']
datetime.datetime(2014, 6, 29, 6, 58, 11, 412202)

Enfin, je sais que tous les tutos du monde en Python utilisent le dictionnaire pour montrer une forme ou une aute de compteur. Mais si vous avez VRAIMENT besoin d’un compteur, utilisez collections.Counter qui est un objet avec l’interface d’un dictionnaire mais avec tout ce qu’il faut pour compter :

>>> from collections import Counter
>>> c = Counter('abbbac') # comptage automatique
>>> c
Counter({'b': 3, 'a': 2, 'c': 1})
>>> c['c']
1
>>> c['d'] # pas de KeyError
0
>>> c['z'] += 1 # pas de KeyError
>>> c['z']
>>> c.most_common(2) # et en bonus
[('b', 3), ('a', 2)]

Clé en main

Récupérer une clé si on ne sait pas si elle est présente est une opération courante, et la documentation montre généralement ça :

try:
   val = dico['cle']
except KeyError:
   val = 'valeur par defaut'

Bien que ce soit parfaitement valide, c’est généralement se faire chier pour rien puisqu’on peut faire ça en une ligne :

   val = dico.get('cle', 'valeur par defaut')

Néanmoins la méthode get() est très connue. Moins connue est la méthode setdefault. En effet, parfois on veut faire plutôt ceci :

try:
   val = dico['cle']
except KeyError:
   dico['cle'] = 'valeur par defaut'
   val = 'valeur par defaut'

Et ça peut également se faire en une ligne :

   val = dico.setdefault('cle', valeur par defaut)

J’aimerais aussi en profiter pour rappeler que les clés des dicos peuvent être n’importe quel objet hashable, pas juste une string ou un int. Notamment, les tuples sont des clés valides, et comme l’opérateur tuple est la virgule et non la parenthèse, cette syntaxe est parfaitement valide :

>>> d = {}
>>> d[1, 2] = 'tresor'
>>> d[3, 3] = 'mine'
>>> d
{(1, 2): 'tresor', (3, 3): 'mine'}
>>> d[3, 3]
'mine'

Parmi les objets utilisables comme clés :

Si vous avez un doute, il est facile de savoir si un objet est hashable ou pas :

>>> import collections
>>> isinstance({}, collections.Hashable)
False
>> isinstance(0, collections.Hashable)
True

Mon dico à moi, c’est le meilleur

On peut tout à fait hériter du type dictionnaire pour obtenir un type qui a des fonctionnalités que le type original n’a pas :

>>> class MonDico(dict):
...     def __add__(self, other):
...         new = {}
...         new.update(self)
...         new.update(other)
...         return new
...
>>> d1 = MonDico(a=1, b=2)
>>> d2 = MonDico(b=3, c=3)
>>> d1 + d2
{'a': 1, 'c': 3, 'b': 3}

Mais c’est assez rare. La plupart du temps on veut plutôt rajouter des fonctionnalités de conteneur à un type existant. Dans ce cas, les méthodes magiques viennent à la rescousse. Par exemple :

class Phrase(object):
 
   def __init__(self, string):
      self.words = string.split()
 
   def __getitem__(self, word):
      return [i for i, w in enumerate(self.words) if w == word]
 
>>> p = Phrase("Une petite puce pique plus qu'une grosse puce ne pique")
>>> p['petite']
[1]
>>> p['puce']
[2, 7]

Hey oui, les hash maps en Python, c’est un sujet qui peut aller très, très loin. C’est ce qui est merveilleux avec ce langage, on peut rapidement programmer en effleurant juste la surface, sans se noyer. Et si on a besoin d’aller plus loin, des profondeurs abyssales de features nous attendent.

flattr this!

Annuler les derniers commits avec Git

dimanche 29 juin 2014 à 10:59

(Amélioration de ce dont je parle ici)

Use case typique : on a merdé les derniers commits, et on veut oublier tout ce qu’on a fait et retourner à l’état d’il y a x commits précédents.

Par exemple, là je veux revenir à mon commit 85711ad... :

commit 093bab5aa9d41f580037d51421b7c5d0db73e2ce
Author: sam 
Date:   Sat Jun 28 09:37:55 2014 +0700

    Ok, c'est la merde internationale

commit 0b768f1c0e37a6141e6cd4c472eb3f369f4334d7
Author: sam 
Date:   Sat Jun 28 09:37:36 2014 +0700

    Je commence à merder

commit 85711ad1e8c54f3fd3048d405addef48921e90fd
Author: sam 
Date:   Sat Jun 28 09:37:20 2014 +0700

    J'adore ce commit

Il y a plein de manières de faire, et on voit sur la toile beaucoup de solutions à base de checkout et de reset. La plupart sont dangereuses ou ont un résultat inattendu, présuppose un état de votre repo ou va vous mettre dans une situation que vous ne maîtrisez pas.

Devinez quoi ? Il y a plus simple, et plus propre.

Etape 1: avoir une copie de travail propre

Avant d’inverser des commits, assurez vous que votre copie de travail est nette. Pas de fichiers modifiés en attente d’être commités. Le moins de fichiers non trackés par git possible (idéalement zéro, soit c’est commité, soit c’est dans le .gitignore).

Si vous avez des fichiers modifiés, vous pouvez soit les mettre de côté temporairement avec git stash, soit annuler toutes les modifications avec git reset --hard HEAD. Attention, cette dernière commande n’est pas réversible et va mettre à plat votre copie de travail pour qu’elle soit l’exacte copie du dernier commit de votre histo.

Etape 2

???

Etape 3: profit !

git revert --no-commit 85711ad1..HEAD

Ceci va modifier la copie de travail (donc les fichiers que vous avez sur le disque dur en direct, pas l’histo git) en appliquant des patchs qui contiennent les différences entre HEAD et le commit avec ce hash.

En clair : vos fichiers vont être dans l’état dans lequel ils étaient à ce commit. En prime, l’index est mis à jour.

Vous pouvez alors faire les derniers ajustements que vous le souhaitez. Il faut ensuite finaliser la procédure par un commit avec un message significatif :

git commit -m "Abort ! Abort ! Inversion des 2 derniers commits, retour à 85711a"

Si vous aviez fait un stash, c’est le moment de faire un stash apply derrière.

Maintenant, si vous matez l’histo, vous verrez qu’on n’a pas effacé les commits précédents, on a juste fait un commit qui inverse tout ce qu’ils avaient fait :

commit 03e55de36ad29a26a461874988d4066ebf6fe6be
Author: sam 
Date:   Sat Jun 28 09:43:32 2014 +0700

    Abort ! Abort ! Inversion des 2 derniers commits, retour à 85711a

commit 093bab5aa9d41f580037d51421b7c5d0db73e2ce
Author: sam 
Date:   Sat Jun 28 09:37:55 2014 +0700

    Ok, c'est la merde internationale

commit 0b768f1c0e37a6141e6cd4c472eb3f369f4334d7
Author: sam 
Date:   Sat Jun 28 09:37:36 2014 +0700

    Je commence à merder

commit 85711ad1e8c54f3fd3048d405addef48921e90fd
Author: sam 
Date:   Sat Jun 28 09:37:20 2014 +0700

    J'adore ce commit

Ce qui évite bien des problèmes : pas de réécriture de l’histo, possibilité de récupérer du code dans les commits inversés plus tard, claire indication de ce qui s’est passé…

N’oubliez pas que souvent, revenir à un commit précédent est overkill. Il est généralement beaucoup plus simple de juste récupérer un ou deux fichiers dans l’état de l’époque avec :

git checkout [hash] -- chemin/vers/fichier

flattr this!

La fin de l’ère du spam de sites pornos

samedi 28 juin 2014 à 03:09

Petit billet juste pour dire que cette époque où tout mail entré dans un formulaire sur un site de cul se retrouvait immédiatement noyé dans un flot d’emails publicitaires touche à sa fin.

L’industrie adulte a-t-elle soudainement une crise morale ?

Non, c’est juste que ce n’est plus efficace du tout.

La plupart des mails sont maintenant arrêtés sans efforts de l’utilisateur par tous les grands services de mails gratuits. Ils ne les voient même pas. En fait, ça en devient chiant car parfois les mails de confirmation d’ouverture de compte, les liens de validation et les reset de mots de passe y tombent aussi si le nom de domaine est trop tendancieux.

Le mail, en soit, n’a pas disparu. La mailling list est toujours d’actu, et fonctionne bien : le user peut se désabonner pour de vrai, et les intéressés (il y en a plus que le geek en moi veut l’admettre) cliquent consciemment sur les liens qu’elle inclus.

Le spam n’a pas disparu non plus. Il a changé de forme. On ne vend plus les mêmes choses, de la même façon. Mais les spams de cul sont une espèce en voie de disparition. Les derniers à le faire ont sans doute juste oublié de killer leur CRON.

RIP

J’ai commencé ma carrière dans un boîte qui faisait du spam SMS. Et ça malheureusement, ça ne se désengorge pas, j’en reçois toujours aujourd’hui. Le karma je vous dis, le karma…

flattr this!

Petite démo pragmatique d’un usage de WAMP en Python

jeudi 26 juin 2014 à 09:27

Vu que dernièrement je vous ai bien gavé avec WAMP, ça mérite un tuto non ?

Il se trouve que l’équipe derrière WAMP a publié plus tôt que prévu une version de leurs libs contenant l’API flaskesque sur laquelle on bosse. L’idée est que même si on n’a pas encore les tests unitaires, on peut déjà jouer avec.

Maintenant il me fallait un projet sexy, histoire de donner envie. Donc j’ai fouillé dans ce qui se faisait côté temps réel (essentiellement du NodeJS et du Tornado, mais pas que) pour trouver l’inspiration.

Et j’ai trouvé un truc très sympa : un player vidéo piloté à distance.

En effet, n’est-il pas chiant de regarder une vidéo en ligne sur son ordi posé sur la commode pendant qu’on est enfoncé dans le canap ? Si on veut faire pause ou changer le son, il faut se lever, arg.

Les problèmes du tiers monde, c’est du pipi de chat à côté. Ils ont de la chance, eux, ils ne connaissent pas le streaming.

Voici donc le projet :

Une page avec un player HTML 5 et un QR code.

Capture d'écran de la démo, côté player

Pour simplifier la démo, on peut cliquer sur le QR code et avoir la télécommande dans un autre tab pour ceux qui n'ont pas de smartphone ou d'app de scan de QRCode.

Si on scanne le QR code avec son téléphone, il vous envoie sur une page avec une télécommande pour contrôler le player sans bouger votre cul :

Capture d'écrand de la démo, côté contrôles

Évidement, c'est basique. Je vais pas m'amuser à faire un produit complet juste pour un truc dont le code source ne sera même pas regardé par la plupart d'enter vous. Je vous connais, bandes de feignasses !

Et vous allez voir, c’est même pas dur à faire.

Mais d’abord :

La démo

Bon, y a plus de latence que je le voudrais, mais sur un projet sérieux, il y a moyen de faire ça mieux. Ici on veut faire simple pour expliquer le principe.

Et vous pouvez télécharger le code ici.

Pour comprendre ce qui va suivre, il va vous falloir les bases en prog Javascript et Python, ainsi que bien comprendre la notion de callback. Être à l’aise avec promises peut aider.

Et pour bien digérer ce paté, rien ne vaut un peu de son :

Le Chteumeuleu

Il va nous falloir deux pages Web, une pour le player video, et une pour la télécommande.

Le player :

<!DOCTYPE html>
<html>
<head>
   <title>Video</title>
   <meta charset='utf-8'>
   <!-- Chargement des dépendances : autobahn pour WAMP
   et qrcode pour générer le QR code. Bien entendu, je
   vous invite à ne pas les hotlinker dans vos projets,
   mais pour la démo c'est plus simple. -->
  <script src="https://autobahn.s3.amazonaws.com/autobahnjs/latest/autobahn.min.jgz"
         type="text/javascript"></script>
   <script src="http://davidshimjs.github.com/qrcodejs/qrcode.min.js"
           type="text/javascript"></script>
 
   <style type="text/css">
      /* Quelques styles histoire que ça soit pas
         TROP moche. J'inline, hein, on va pas
         se faire chier à faire un fichier CSS
         externe juste pour ça. */
      #vid {
         /* Taille de la video */
         width:427px;
         height:240px;
      }
      /* Centrage avec la méthode Rache */
      #container {
          width:427px;
          margin:auto;
      }
      #ctrllink {
          display:block;
          width:256px;
          margin:auto;
      }
   </style>
 
</head>
<body>
<div id="container">
  <p>
 
   <!-- J'utilise le lecteur video HTML5 car c'est le plus facile
        à faire. Je ne vais pas m'attarder sur comment ça
        marche, y a plein de tutos pas trop mauvais
        sur la question -->
    <video id="vid"
           class="video-js vjs-default-skin"
           controls preload="auto"
           poster="http://media.w3.org/2010/05/sintel/poster.png" >
 
      <!-- Encore une fois, je hotlink la video, mais ne faites
       pas ça à la maison les enfants. Surtout que les perfs du
      serveur du W3C sont merdiques et ça bufferise à mort. -->
       <source id='ogv'
         src="http://media.w3.org/2010/05/sintel/trailer.ogv"
         type='video/ogg'>
       <source id='mp4'
         src="http://media.w3.org/2010/05/sintel/trailer.mp4"
         type='video/mp4'>
       <source id='webm'
         src="http://media.w3.org/2010/05/sintel/trailer.webm"
         type='video/webm'>
    </video>
  </p>
  <!-- Un élément vide pour le QR code -->
  <p>
    <a id="ctrllink" href="#" target="_blank">
      <span id="qrcode"></span>
    </a>
  </p>
 </div>
 
</body>
</html>

Et la télécommande :

<!DOCTYPE html>
<html>
<head>
  <title>Télécommande</title>
  <meta charset='utf-8'>
  <script src="https://autobahn.s3.amazonaws.com/autobahnjs/latest/autobahn.min.jgz"
         type="text/javascript"></script>
  <!-- Zoom du viewport sur mobile pour éviter d'avoir
       à le faire à la main. -->
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style type="text/css">
    #controls {
      width:350px;
      margin:auto;
    }
    #controls button {
      font-size: 1em;
    }
    #controls input {
      vertical-align:middle;
       width: 200px;
       height:20px;
   }
  </style>
</head>
<body>
  <p id="controls">
    <!-- Marrant de se dire qu'en 2000, le JS inline était
         considéré comme démoniaque, et maintenant avec
         angularjs et cie, c'est exactement ce que tout
         le monde fait...
         Bref, on attache le clic sur nos contrôles à des
         méthodes de notre objet qui va se charger de la
         logique. -->
 
    <button id="play" onclick="control.togglePlay()">Play</button>
    <input id="volume"
                    onchange="control.volume(this.value)"
                    type="range">
  </p>
</body>
</html>

Rien d’incroyable. C’est du HTML, un peu de CSS, on charge les dépendances en JS. Classique.

Vu qu’on utilise des ressources hotlinkées par souci de simplicité, il vous faudra être connecté à Internet.

Setup du serveur

On va travailler avec Python 2.7. Je sais, je sais, c’est pas idéal, mais pour le moment je n’ai fait l’API flaskesque que pour le backend twisted, qui est en 2.7. On ne peut pas tout faire. Est-ce que vous savez le temps que me prend juste la rédaction de ce tuto, sérieux ?

Bref, il nous faut avant tout un serveur HTTP pour servir nos fichiers HTML. Normalement crossbar peut le faire pour nous en prod, mais en dev, on va pas setuper crossbar, on va faire plus simple.

Donc, à la racine du projet, lancez la commande :

python -m SimpleHTTPServer

Vos pages Web seront ainsi servies en local sur le port 8000. Par exemple, pour afficher la page de video :

http:localhost:8000/index.html

Ensuite, faut installer de quoi faire du WAMP en Python avec pip :

pip install "autobahn[twisted]"

Pour simplifier le développement, notre app Python va lancer automatiquement un petit serveur WAMP, du coup pas besoin d’installer crossbar du tout pour cette démo.

Création de l’App WAMP côté serveur

Pour cette démo, le serveur n’a pas grand chose à faire. On pourrait en fait la faire sans aucun code serveur, mais ça va nous simplifier la vie.

En effet, on a deux problématiques que le serveur va résoudre facilement pour nous : créer un ID unique pour le player et récupérer l’IP sur le réseau local.

L’ID, c’est simplement que si plusieurs personnes lancent en même temps un player, on ne veut pas que les télécommandes puissent lancer un ordre à un autre player que le sien. On pourrait utiliser un timestamp, mais ils sont contiguës, n’importe quel script kiddies pourrait faire un script pour foutre la merde. On va donc créer un ID unique qui ne soit pas facilement prévisible. Javascript n’a rien pour faire ça en natif, et c’est un peu con de charger une lib de plus pour ça alors que Python peut le faire pour nous.

L’IP, c’est parce qu’il faut donner l’adresse de notre machine au téléphone qui va l’afficher sur la télécommande, puisque notre serveur tourne dessus. Sinon notre démo ne marchera pas. Bien sûr, en prod, on ne fera pas ça, mais en local, il faut faire avec les moyens du bord.

Cela veut dire aussi que le téléphone doit être sur le même réseau local pour que ça fonctionne. Donc mettez votre téléphone en Wifi, par en 3G.

Voilà ce que donne notre code WAMP côté serveur :

# -*- coding: utf-8 -*-
 
from autobahn.twisted.wamp import Application
 
import socket
import uuid
 
# Comme pour flask, l'objet app
# est ce qui lie tous les éléments
# de notre code ensemble. On lui donne
# un nom, ici "demo"
app = Application('demo')
# Bien que l'app va démarrer un serveur
# pour nous, l'app est bien un CLIENT
# du serveur WAMP. Le serveur démarré
# automatiquement n'est qu'une facilité
# pour le dev. En prod on utiliserait
# crossbar.
 
# Juste un conteneur pour y mettre notre IP
app._data = {}
 
# On déclare que cette fonction sera appelée
# quand l'app se sera connectée au serveur WAMP.
# Ceci permet de lancer du code juste après
# le app.run() que l'on voit en bas du fichier.
# '_' est une convention en Python pour dire
# "ce nom n'a aucune importance, c'est du code
# jetable qu'on utilisera une seule fois".
@app.signal('onjoined')
def _():
   # On récupère notre adresse IP sur le réseau local
   # C'est une astuce qui demande de se connecter et donc
   #  à une IP externe, on a besoin d'une connexion internet.
   s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
   s.connect(("8.8.8.8", 80))
   # On stocke l'adresse IP locale dans un conteneur
   # qui sera accessible partout ailleur.
   app._data['LOCAL_IP'] = s.getsockname()[0]
   s.close()
 
# On déclare que la fonction "ip()" est appelable
# via RCP. Ce qui veut dire que tout autre client
# WAMP peut obtenir le résultat de cette fonction.
# Donc on va pouvoir l'appeler depuis notre navigateur.
# Comme notre app s'appelle "demo" et notre fonction
# s'appelle "ip", un client pourra l'appeler en faisant
# "demo.ip".
@app.register()
def ip():
   # On ne fait que retourner l'IP locale. Rien de fou.
   return app._data['LOCAL_IP']
 
# Je voulais appeler cette fonction distante "uuid", mais ça
# override le module Python uuid. Ce n'est pas une bonne
# idée. Je l'appelle donc 'get_uuid' mais je déclare le
# namespace complet dans register(). Un client WAMP pourra donc
# bien l'appeler via "demo.uuid".
# Notez que ce namespace doit toujours s'écrire
# truc.machine.bidule. Pas truc/machin ou truc:machin.
# ou truc et bidule.MACHIN.
@app.register('demo.uuid')
def get_uuid():
   # Retourne un UUID, sans les tirets.
   # ex: b27f7e9360c04efabfae5ac21a8f4e3c
   return str(uuid.uuid4()).replace('-', '')
 
# On lance l'application. Ceci va lancer le serveur
# puis le client. On peut désactiver le lancement du
# serveur une fois qu'on met tout ça en prod.
if __name__ == '__main__':
    app.run(url="ws://0.0.0.0:8080/")
# On ne peut rien mettre comme code ici, il faut le
# mettre dans @app.signal('onjoined') si on veut
# entrer du code après que l'app soit lancée.

Et on lance notre app :

python app.py

Nous avons maintenant deux serveurs qui tournent : un serveur HTTP qui écoute sur le port 8000, et un serveur WAMP qui écoute sur le port 8080. En prod, crossbar peut servir à la fois HTTP et WAMP, donc pas besoin de lancer deux outils.

Le lecteur video

Il nous faut maintenant définir le comportement de notre lecteur video via Javascript. Il s’agit essentiellement de se connecter au serveur WAMP, et d’échanger des messages via RPC ou PUB/SUB :

  var player = {};
  var url;
  /* On va utiliser du pur JS histoire de pas mélanger
    des notions de jQuery dans le tas. Je ne vais
    PAS utiliser les best practices sinon vous allez
    être noyés dans des détails */
 
  /* Lancer le code une fois que la page est chargée */
  window.addEventListener("load", function(){
 
    /* Connexion au serveur WAMP. J'utilise
       les valeurs par défaut du serveur de
       dev. On ouvre explicitement la connection
       à la fin du script. */
    var connection = new autobahn.Connection({
       url: 'ws://' + window.location.hostname + ':8080/',
       realm: 'realm1'
    });
 
    /* Lancer ce code une fois que la connexion
       est réussie. Notez que je ne gère pas
       les erreurs dans dans une APP JS, c'est
       un puits sans fond. */
    connection.onopen = function (session) {
 
      /* Appel de la fonction ip() sur le serveur */
      session.call('demo.ip')
 
      /* Une fois qu'on a récupéré l'IP,
         on peut fabriquer l'URL de notre
         projet et on appelle la fonction
         get_uuid() du serveur */
      .then(function(ip){
        url = 'http://' + ip + ':8000';
        return session.call('demo.uuid');
      })
 
      /* Une fois qu'on a l'UUID, on peut commencer
         à gérer la partie télécommande */
      .then(function(uuid){
 
        /* Création du QR code avec le lien pointant
           sur la bonne URL. On met l'ID dans le hash. */
        var controlUrl = url + '/control.html#' + uuid;
        var codeDiv = document.getElementById("qrcode");
        new QRCode(codeDiv, controlUrl);
        var ctrllink = document.getElementById("ctrllink");
        ctrllink.href = controlUrl;
 
        /* Notre travail consiste essentiellement à
           manipuler cet élément */
        var video = document.getElementById("vid");
 
        /* On attache déclare 4 fonctions comme étant
           appelable à distance. Ces fonctions sont
           appelables en utilisant le nom composé
           de notre ID et de l'action qu'on souhaite
           faire. Ex:
           'b27f7e9360c04efabfae5ac21a8f4e3c.play'
           pour appeler "play" sur notre session. */
        session.register(uuid + '.play', function(){
           video.play();
        });
 
        session.register(uuid + '.pause', function(){
           video.pause();
        });
 
        session.register(uuid + '.volume', function(val){
           video.volume = val[0];
        });
 
        session.register(uuid + '.status', function(val){
          return {
            'playing': !video.paused,
            'volume': video.volume
          };
        });
 
 
 
       /* Quelqu'un peut très bien
           appuyer sur play directement sur cette page.
 
          Il faut donc réagir si l'utilisateur le fait,
          publier un événement via WAMP pour permettre
          à notre télécommande de se mettre à jour
          */
       video.addEventListener('play', function(){
         /* On publie un message indiquant que
            le player a recommencé à lire la vidéo.
            */
         session.publish(uuid + '.play');
       });
 
        video.addEventListener('pause', function(){
          session.publish(uuid + '.pause');
        });
 
        video.addEventListener('volumechange', function(){
          session.publish(uuid + '.volume', [video.volume]);
        });
 
     });
    };
 
    /* Ouverture de la connection une fois que tous les
       callbacks sont bien en place.*/
    connection.open();
  });

Code de la télécommande

La télécommande est notre 3eme client WAMP (on peut avoir des centaines de clients WAMP). Notre 1er est l’App Python, notre second est le player.

Son code a pour but d’envoyer des ordres au player HTML5, mais aussi de mettre à jour son UI si le player change d’état.

/* L'objet qui se charge de la logique de nos
   controles play/pause et changement de
   volume.
   Rien de fou, il change l'affichage
   du bouton et du slider selon qu'on
   est en pause/play et la valeur du
   volume.
   */
var control = {
   playing: false,
   setPlaying: function(val){
      control.playing = val;
      var button = window.document.getElementById('play');
      if (!val){
         button.innerHTML = 'Play'
      } else {
         button.innerHTML = 'Pause';
      }
   },
   setVolume: function(val){
      var slider = window.document.getElementById('volume');
      slider.value = val;
   }
};
window.onload = function(){
  var connection = new autobahn.Connection({
    url: 'ws://' + window.location.hostname + ':8080/',
    realm: 'realm1'
  });
 
  connection.onopen = function (session) {
 
    /* Récupération de l'ID dans le hash de l'URL */
    var uuid = window.location.hash.replace('#', '');
 
    /* Mise à jour des controles selon le status actuel
       du player grace à un appel RPC vers notre autre
       page. */
    session.call(uuid + '.status').then(function(status){
 
      control.setPlaying(status['playing']);
      control.setVolume(status['volume'])
 
      /* On attache l'appui sur les contrôles à
         un appel de la fonction play() sur le
         player distant. L'uuid nous permet
         de n'envoyer l'événement que sur le
         bon player. */
      control.togglePlay = function() {
        if (control.playing){
          session.call(uuid + '.pause');
          control.setPlaying(false);
        } else {
          session.call(uuid + '.play');
          control.setPlaying(true);
        }
      };
 
      control.volume = function(val){
        session.call(uuid + '.volume', [val / 100]);
      };
 
      /* On ajoute un callback sur les événements
         de changement de status du player. Si
         quelqu'un fait play/pause ou change le
         volume, on veut mettre à jour la page. */
      session.subscribe(uuid + '.play', function(){
        control.setPlaying(true);
      });
 
      session.subscribe(uuid + '.pause', function(){
        control.setPlaying(false);
      });
 
      session.subscribe(uuid + '.volume', function(val){
        control.setVolume(val[0] * 100);
      });
    });
  };
 
  connection.open();
};

En résumé

Voici à quoi ressemble le projet final :

.
├── app.py (client python))
├── control.html (télécommande)
├── index.html (player video)
Schéma de fonctionnement de la démo

Bien que l'app Python lance le serveur automatiquement et de manière invisible, c'est bien un composant à part.

Pour ce projet, on aura utilisé :

On n’aura pas utilisé crossbar, le serveur WAMP Python. On a utilisé un petit serveur de dev inclus dans autobahn. En prod, on utiliserait crossbar comme serveur WAMP.

Il y a pas mal de notions à prendre en compte.

D’abord, le RPC.

Cela permet à un client de dire “les autres clients peuvent appeler cette fonction à distance”. On l’utilise pour exposer ip() et get_uuid() sur notre serveur et notre javascript peut donc les appeler. Mais on l’utilise AUSSI pour qu’une des pages (le player) expose play(), pause() et volume() et que l’autre page (notre télécommande) puisse les utiliser.

La grosse différence, c’est que ip() peut être appelé par tous les clients en utilisant “demo.ip” alors que play() ne peut être appelé que par les clients qui connaissent l’ID du player, puisqu’il faut utiliser “<id>.play”.

Ensuite, il y a le PUB/SUB.

Cela permet à un client de dire “j’écoute tous les messages adressés à ce nom”. Et un autre client peut envoyer un message (on appelle ça aussi un événement, c’est pareil) sur ce nom, de telle sorte que tous les clients abonnés le reçoivent.

On l’utilise pour que notre télécommande dise “j’écoute tous les messages qui concernent les changements de status du player.” De l’autre côté, quand on clique sur un contrôle du player, on envoie un message précisant si le volume a changé, ou si on a appuyé sur play/pause. La télécommande peut ainsi mettre son UI à jour et refléter par exemple, la nouvelle valeur du volume.

Cela résume bien les usages principaux de ces deux outils :

Voici le workflow de notre projet :

Si vous virez tous les commentaires, vous verrez que le code est en fait vraiment court pour une application aussi complexe.

Encore une fois, il est possible de le faire sans WAMP, ce sera juste plus compliqué. Je vous invite à essayer de le faire pour vour rendre compte. Avec PHP, Ruby ou une app WSGI, c’est pas marrant du tout. Avec NodeJs, c’est plus simple, mais il faut quand même se taper la logique de gestion RPC et PUB/SUB à la main ou installer pas mal de libs en plus.

WAMP rend ce genre d’app triviale à écrire. Enfin triviale parce que là j’ignore tous les edge cases, évidemment. Pour un produit solide, il faut toujours suer un peu.

Les limites du truc

C’est du Python 2.7. Bientôt on pourra le faire avec asyncio et donc Python 3.4, mais malheureusement sans le serveur de dev.

Heureusement, Twisted est en cours de portage vers Python 3, et donc tout finira par marcher en 3.2+.

C’est du HTML5, mais bien entendu, rien ne vous empêche de faire ça avec du flash si ça vous amuse.

C’est du websocket, mais on peut utiliser un peu de flash pour simuler websocket pour les vieux navigateurs qui ne le supportent pas.

Non, la vraie limite c’est encore la jeunesse du projet : pas d’autoreload pour le serveur (super chiant de devoir le faire à la main à chaque fois qu’on modifie le code) et les erreurs côté serveur se lisent dans la console JS, et pas dans le terminal depuis lequel on a lancé le serveur. Plein de petits détails comme ça.

EDIT: Mise en prod

On m’a demandé à quoi ça ressemblerait si on mettais le truc en prod, alors je vais donner un exemple.

D’abord, on installe crossbar :

pip install crossbar

Ensuite, on le fait créer un dossier de configuration :

crossbar init

Ça va créer un dossier .crossbar avec un fichier config.json dedans.

On édite le fichier, de telle sorte qu’il ressemble à ça :

{
   "controller": {
   },
   "workers": [
      {
         "type": "router",
         "options": {
            "pythonpath": [".."]
         },
         "realms": [
            {
               "name": "realm1",
               "roles": [
                  {
                     "name": "anonymous",
                     "permissions": [
                        {
                           "uri": "*",
                           "publish": true,
                           "subscribe": true,
                           "call": true,
                           "register": true
                        }
                     ]
                  }
               ]
            }
         ],
         "transports": [
            {
               "type": "web",
               "endpoint": {
                  "type": "tcp",
                  "port": 8080
               },
               "paths": {
                  "/": {
                     "type": "static",
                     "directory": "../templates"
                  },
                  "ws": {
                     "type": "websocket"
                  }
               }
            }
         ]
      },
      {
         "type": "container",
         "options": {
            "pythonpath": [".."]
         },
         "components": [
            {
               "type": "class",
               "classname": "app.app",
               "realm": "realm1",
               "transport": {
                  "type": "websocket",
                  "endpoint": {
                     "type": "tcp",
                     "host": "127.0.0.1",
                     "port": 8080
                  },
                  "url": "ws://127.0.0.1/ws"
               }
            }
         ]
      }
   ]
}

Ceci n’est pas un tuto sur la mise en prod d’un projet WAMP avec crossbar, alors je ne vais pas rentrer dans les détails, mais en gros on change les ports HTTP et WAMP pour le port 8080, et on donne juste l’url /ws comme point d’entrée pour WAMP (on doit éditer le code source de la démo en conséquence). On sert les fichiers statiques en utilisant la section “transports” et notre app via la section “components”. Pour le realm, encore une fois, je skip, c’est un sujet à part, celui par défaut marche pour notre usage.

A partir de là il y a plusieurs solutions possibles :

Pour lancer la machine :

crossbar run

On met un petit script d’init pour le lancer au démarrage, et on est good.

flattr this!