Le blog à MG

Aller au contenu | Aller au menu | Aller à la recherche

vendredi, novembre 27 2009

Elixir : une surcouche ORM pour Python

Le problème

Pour mon travail, je dois collecter une grande quantité de données dans le but de réaliser différentes analyse assez conséquentes dessus. Les données proviennent de différentes sources (dépôts de code source, archives zip, forums, ...), sont extraites par différents logiciels écrits dans différents langages (Java, C, C++, Python, ...), et apparaissent dans différents formats (XML, CSV, ...). Afin que tout se beau monde soit intégré en un lieu unique, j'ai écrit une maître application en Python.

Initialement, les données étaient enregistrées sous la forme de fichiers CSV, mais ce n'était pas une solution adaptée : étant donné la quantité d'information, on se retrouvait avec une multitude de fichiers dont les relations étaient implicitement déterminées par le nom des fichiers et leur place dans le répertoire. De plus, ces données sont, par leur nature, fortement liées. En adoptant une présentation "à plat", on perdait de l'information relative à cette liaison. Une base de données relationnelle a donc été envisagée. SQLite a été retenue par sa simplicité de mise en oeuvre et parce que l'application en est à l'étape de prototype (n'importe quelle base de données supportant SQL pouvant s'y substituer par la suite).

La base comporte une vingtaine de tables, une quinzaine de relations et des milliers (voir millions) d'entrées. Lors de l'étude des données, il est évidemment souvent nécessaire de suivre les relations entre les entités qui peuplent les tables. Une couche ORM, réalisant un mapping entre des classes dans un langage de programmation d'une part et les entités de la base de données d'autre part s'avère nécessaire.

ORM

Un ORM (object-relational mapping) facilite la vie des personnes devant manipuler les bases de données. On part du principe que les entités stockées dans la base de données sont des objets (au sens de la programmation orientée objet). Certaines bases de données, comme PostgreSQL, sont orientées objet : on peut effectuer des requêtes manipulant les objets. Elles sont cependant peu répandue et/ou plus complexe à mettre en oeuvre que les bases de données "traditionnelles".

Les ORM utilisent typiquement deux design patterns pour associer des objets aux bases de données : Active Record et Data Mapper. Active Record est actuellement le plus utilisé. Une classe est composé d'attributs. A chaque classe correspond une table dans la base de données, et chaque attribut de la classe correspond à une colonne dans la table correspondante. Un objet correspond à un tuple dans la table, et les relations entre objets correspondent à des clés étrangères.

SQLAlchemy

Un des ORM les plus réputés lorsqu'on programme en Python est sans conteste SQLAlchemy. Il tire sa force de plusieurs points. Tout d'abord, c'est plus qu'un ORM : c'est un ensemble d'outils pour SQL en python. Vous pouvez ainsi effectuer toutes les opérations SQL imaginables dans un langage de haut niveau et abstrayant les spécificités de la base de données que vous utilisez. Ensuite, c'est un outil très complet et très efficace, qui couvre tous les besoins que vous pourriez avoir. Le prix à payer est une certaine lourdeur d'utilisation. En particulier, lorsque vous l'utilisez comme ORM, vous devez explicitement définir le mappage entre vos classes et votre base de données, ce qui ne facilite pas la maintenance et l'évolution de votre programme. Vous pouvez créer une base de données à partir de vos classes Python, mais, en plus de vos attributs, vous devrez également indiquer explicitement les relations entre les classes, ce qui introduit une redondance.

Elixir

Heureusement, Elixir vient sauver la mise. Il s'agit d'une surcouche à SQLAlchemy. Ce dernier reste donc nécessaire, mais vous ne devez plus l'utiliser directement (vous le pouvez toujours, cependant). La surcouche est très légère, et les performances sont au rendez-vous (bien que je n'ai pas encore personnellement testé cet aspect avec attention). Elixir propose uniquement des fonctions d'ORM, ce qui est bien moins complet que ce que permet SQLAlchemy.

Utilisation

Le Wiki d'Elixir est très bien pensé, et permet un démarrage rapide. Pour l'exemple, réalisons le traditionnel blog, où des auteurs peuvent publier des billets recevant des commentaires.

Tout d'abord, on crée le module blog dans le fichier blog.py contenant les classes :

# -*- coding: utf-8 -*-
 
from elixir import *
from datetime import datetime
 
class Blog(Entity):  # Toutes les classes à mapper doivent hériter de Entity 
    name = Field(String(50))      # sera traduit par un varchar(50)
    subjet = Field(String(50))
 
   # pas nécessaire pour utiliser Elixir, il ne l'utilise 
   # jamais de lui-même
   def __init__(self, name, subjet):
      self.name = name
      self.subjet = subjet
 
   # Autre méthode de convenance :
   # juste pour une représentation "humaine", 
   # pas nécessaire pour Elixir
   def __repr__(self): 
      return "Blog<%s>" % self.name
 
class User(Entity):
   # Bien sûr, l'héritage est géré, et vous pouvez
   # hériter d'une classe héritant de Entity.
 
   name = Field(String(50))
   firstname = Field(String) # La taille du string n'est pas nécessaire, mais plusieurs SGDB la demande
   email = Field(String)
 
   # 1- Un lien one-to-many : on peut avoir écrit plusieurs billets
   # 2- A ce niveau du fichier, on ne connaît pas encore la classe Poll, 
   # donc, on l'écrit comme une chaîne de caractère.
   polls = OneToMany('Poll')
 
   # L'utilisateur est aussi propriétaire de commentaires
   comments = OneToMany("Comment")
 
   def __init__(self, firstname, name):
      self.name = name
      self.firstname = firstname
 
   def __repr__(self):
      return "User<%s %s>" % self.firstname, self.name
 
class Poll(Entity):
   # Bien sûr, l'héritage est géré, et vous pouvez
   # hériter d'une classe héritant de Entity.
 
   title = Field(String(50))
   content = Field(Text) # Un texte arbitrairement grand
   publication = Field(DateTime) # La date de publication
   owner = ManyToOne(User) # Un seul propriétaire du billet
   comment = OneToMany("Comment")
 
   def __init__(self, title, content):
      self.title = title
      self.content = content
      self.publication = datetime.now()
 
   def __repr__(self):
      return "Poll<%s>" % self.title
 
class Comment(Entity):
   content = Field(Text)
   owner = ManyToOne(User)
   poll = ManyToOne(Poll)
   publication = Field(DateTime)
 
   def __init__(self, content, poll):
      self.content = content
      self.poll = poll  # Création d'un lien
      self.publication = datetime.now()
 
   def __repr__(self):
      return "Comment<%s>" % self.content

Ensuite, utilisons ces classes pour créer la base de données correspondante et mémoriser quelques entités :

# -*- coding: utf-8 -*-
from elixir import *
from blog import *
 
# On se connecte à une base de données (ici : sqlite)
metadata.bind = 'sqlite:////home/mg/myBDD.sqlite'
# On veut afficher les requêtes SQL en console
metadata.bind.echo = True
# On créer la relation entre les classes et les tables
setup_all() 
# On crée toutes les tables nécessaires
create_all()

OK, on commence

moi = User(u"Mathieu", u"Goeminne")
lui = User(u"Raymond", u"Barre")
blob = Blog(u"myBlog", u"Un blog intéressant")
billet = Poll(u"La question ultime?", 
u"D'après vous, quelle est la réponse à la grande question sur la vie, l'univers et le reste")
billet.owner = moi

Tout travail n'est pas pris en compte tant qu'on n'a pas commité, même si la bdd ne gère pas les transactions.

session.commit()

Modifions un peu notre base de données.

comment = Comment(u"Ca rétame du chacal unijambiste à la pince-monseigneur! 
Et même, ça décalotte de la taupe-garou apatride à la tronçonneuse", lui)
comment.poll = billet

Décidemment, ce commentaire est peu crédible. Annulons toute modification depuis le dernier commit!

session.rollback()

Quelques requêtes sur les tables :

# Récupération de tous les utilisateurs
l = User.query.all()
 
# On les compte
compte = User.query.count()
 
# On les filtre, et on les trie
lt = Poll.query.filter_by(title.like('%google')).order_by(title)
for p in lt:
   print p
 
# On veut tous les billets ayant été publiés par moi-même
Poll.query.filter_by(owner=moi)

Finalement, mon billet n'était pas terrible. Supprimons-le.

billet.delete()
session.commit



Commentaires

Bien sûr, ce n'est qu'on aperçu de ce qu'il est possible de réaliser avec Elixir. Cependant, les amateurs de Django auront remarqué que la syntaxe est semblable (mais pas similaire!) à cette utilisée dans leur framework web préféré. Pour plus d'informations sur la syntaxe et les possibilités de l'ORM, n'hésitez pas à consulter la documentation officielle, assez bien faite. En particulier, les attributs peuvent être plus finement décrits afin de prendre en compte les clés primaires, étrangères, etc.

Remarquez aussi que les relations sont déterminées par un attribut de classe, ce qui permet de les indiquer de manière explicite. Dans les relations de type Many, si plusieurs relations sont établies vers une même classe, l'option field permet de déterminer de quelle relations il est question de manière explicite. Dans beaucoup de cas, le nom de l'attribut suffit à savoir de quelle relation on parle à tout moment.



Limitations

Elixir a tout de même quelques limitations qu'il faut soulever.

Tout d'abord, comme cela a été dit, ce n'est qu'une surcouche d'SQLAlchemy, qui échange une simplicité d'utilisation contre une diminution des possibilités d'utilisation. Il est donc nécessaire de mettre en balance le pour et le contre, essentiellement en déterminant si vous avez besoin de plus qu'un ORM. Si non, Elixir sera un outil suffisant pour 99% des besoins.

Il s'agit d'un tout jeune projet, qui doit encore faire ses preuves dans le cadre de grands développements. Il est bien conçu, et simple d'utilisation, mais certaines imperfections doivent encore se faire, surtout des petits bugs internes pas vraiment gênant pour l'utilisateur final. En principe, il doit supporter les mêmes SGDB que SQLAlchemy (SQLite, MySQL, PostgreSQL, Oracle, MSSQL, Firebird, Sybase, Access). En pratique, une meilleur intégration est à l'ordre du jour. Je n'ai cependant rencontré aucun problème particulier avec SQLite et MySQL.

Le design pattern Data Mapper considère d'une part les classes, d'autre part les tables, et utilise une entité médiatrice qui sait comment associer les deux parts et comment les modifications des éléments d'une part influencent les éléments de l'autre part. C'est une solution plus souple, car elle profite d'un découplage très fort entre les classes et les tables. Elle permet également des comportements plus complexes, avec des mappages non triviaux. C'est cependant une solution peu utilisée, car il est rare qu'on ait besoin d'autant de souplesse. En général, représenter un objet par un tuple est une solution satisfaisante, et en particulier dans mon cas.

Une dernière limitation concerne la redondance introduite par les relations : la relation doit être décrite dans les deux classes intervenantes. Il est ainsi possible que les classes soient dans un état inconsistant, si la relation n'est déclarée que dans une seule des classes. Cela viole le principe DRY si cher à de nombreux frameworks, dont Django.

En fin de compte, il s'agit d'une solution intéressante qui, malgré quelques défauts, n'aura pas de mal à vous séduire dès lors que vous devez gérer des bases de données en python.

lundi, septembre 7 2009

Compilation conditionnelle sous Latex

Compilation conditionnelle

Dans le cadre de Latex, la compilation conditionnelle consiste à inclure ou pas une partie du document lorsque le code source de celui-ci est converti en un fichier lisible (pdf, dvi, etc). Une condition, représentée par une expression booléenne, est testée. En fonction de la valeur de l'expression, un bloc de code Latex est inséré ou non.

Dans le cadre de mon travail, j'ai été amené à utiliser consécutivement deux types d'inclusions. Bien qu'elles soient basées sur la même technique, la première est un peu plus difficile à mettre en oeuvre que la seconde -- rien d'insurmontable, je vous rassure. Il s'agissait de créer le support d'un TP. Une version, destinée aux étudiants, ne contenait que les exercices, tandis qu'une autre version, destinée au professeur, contenait les exercices suivis de leurs solutions.

Package et variable

Pour commencer, il est nécessaire de déclarer dans le préambule l'utilisation du package ifthen. Il est normalement inclu dans toutes les distributions Latex modernes et ne devrait pas vous causer de problème. Le package "verbatim" est également nécessaire.

\usepackage{ifthen}
\usepackage{verbatim}

Il faut ensuite déclarer une variable booléenne, prof dans mon cas. On peut ensuite définir sa valeur. Si elle vaut "true", il s'agit de la version du professeur et les solutions doivent apparaître.

\newboolean{prof}
\setboolean{prof}{true}



Utilisation

On déclare alors un "théorème", utilisé pour indiquer à chaque solution s'en est une. On déclare également un environnement qui sera utilisé par le rédacteur du TP pour signaler les blocs de texte qui sont des solutions.

\newtheorem{sol}{Solution}[section]
\newenvironment{solution}{\ifthenelse{\boolean{prof}}{\begin{sol}}{\comment }}{\ifthenelse{\boolean{prof}}{\end{sol}}{\endcomment }}

Les trois paramètres utilisés lors de la déclaration d'un nouvel environnement sont, respectivement, le nom de l'environnement, le code à insérer en début d'environnement et le code à insérer en fin d'environnement. La commande \ifthenelse prend elle aussi trois paramètres. Le premier est l'expression booléenne à tester (ici, notre variable "prof"). Le second est le bloc de code à exécuter si l'expression est vraie tandis que le troisième paramètre est le bloc de code à exécuter si l'expression est fausse.

Dans notre cas, le troisième paramètre de \ifthenelse est \comment et \endcomment . En effet, si on ne mettait rien, le texte s'afficherait simplement, comme s'il n'était pas pas dans un environnement. En utilisant comment/endcomment (provenant de verbatim), on force le compilateur à effacer ce qu'il y a entre ces deux commandes. Les espaces après les commandes sont nécessaires, sans quoi il est possible d'avoir une erreur de compilation due à la concaténation de la commande avec du texte.

On utilise le nouvel environnement tout à fait normalement :

...
\begin{solution}
     Ceci est la solution
\end{solution}



Nous terminerons ici notre utilisation de la compilation conditionnelle par l'utilisation de \ifthenelse d'une manière beaucoup plus simple. Il s'agit d'écrire dans le titre qu'il s'agit d'une version avec solutions si c'est le cas. Cela se fait simplement :

\title{Exercices de TP \ifthenelse{\boolean{prof}}{(avec solutions)}{}}



Conclusion

Au final, la compilation conditionnelle sous Latex n'est pas difficile. En l'utilisant conjointement avec les environnements, on peut définir des environnements dédiés à la suppression conditionnelle du texte.

jeudi, septembre 3 2009

Etendre une classe de la libraire javascript

Bonjour à tous !

Je suis d'humeur codeur cette nuit et je travaille sur un projet demandant masse de javascript (pour un interface intuitive) et je cherche à coder de façon de plus en plus propre, ce qui n'est pas toujours facile dans ce langage !

Bref, javascript permet d'étendre les classes de la libraire ce qui parfois peut être utile ! Et cela grace aux prototypes. Attention, ici le terme "prototype" n'a rien à voir avec la librairie prototype (http://www.prototypejs.org) ! En effet, le javascript est un langage orienté prototype. Si vous ne le saviez pas, allez faire un petit tour sur la page wikipedia[1] (en anglais).

Les prototypes permettent donc d'étendre n'importe quelle classe javascript en rajoutant par exemple une instance ou de nouvelles methodes, voir même d'écraser une méthode. Si cela vous intéresse, faites une recherche Google (vous êtes autorisé à prendre un autre moteur de recherche :-)) pour la POO en javascript (par exemple [2]).

Tout ça pour dire que j'ai eu le besoin, par soucis de propreté, d'étendre la classe Array car lorsqu'on travaille avec jQuery on a parfois besoin de passer un tableau en paramètre (pour une requête AJAX) sous la forme : name[]=value1&...&name[]=valueN

Au lien de faire une méthode recevant en paramètre un tableau et le retournant sous ce format (en string) comme ceci :

function hash(array, name) {
  return name+'[]='+array.join('&'+name+'[]=');
}
 
a = [21, 42, 84];
str = hash(a, 'name');
// str <- name[]=21&name[]=42&name[]=84

je préfère faire comme ceci :

Array.prototype.hash = function(name) {
  return name+'[]='+this.join('&'+name+'[]=');
}
 
a = [21, 42, 84];
str = a.hash('name');
// str <- name[]=21&name[]=42&name[]=84

Je trouve cela plus joli et surtout plus logique :)

Peut-être vous aurais-je appris quelque chose, il est désormais l'heure pour moi d'aller dormir ;-)

[1] http://en.wikipedia.org/wiki/Prototype-based_programming

[2] http://mckoss.com/jscript/object.htm

lundi, août 31 2009

Création de plugins jQuery

Il est temps d'écrire mon premier article réellement intéressant. Je vais donc vous parler de quelque chose que j'utilise dans mon travail d'étudiant et qui est absolument génial, j'ai nommé : les plugins jQuery.

Il est déjà bien de voir la simplicité avec laquelle on peut utiliser les plugins jQuery mais c'est encore mieux d'en créer lorsqu'on n'en trouve pas qui réponde à nos exigences. Et c'est utile pour les autres (si on le partage) mais également pour soi. En effet, certains "widget" peuvent être nécessaires à différents endroits d'un même projet mais également dans différents projets. Centraliser le code en un seul "composant" et l'utiliser dans plusieurs projet permet de n'avoir qu'un seul code à maintenir (correction de bugs, ajout de nouvelles fonctionnalités) et d'en faire profiter toutes les applications.

Je vais donc expliquer la syntaxe pour créer un plugin par un exemple. Pour ceux qui veulent comprendre toute la syntaxe, je vous conseille l'article de Paul Crowley [1] (en anglais) en deux parties expliquant l'héritage, ou plutôt la solution utilisée pour faire de l'héritage en javascript. En effet, c'est ce qui est utilisé pour créer des plugins en jQuery :)

Template de base

Le code d'un plugin doit être dans le code suivant (pour le comprendre, voir [1]) :


(function($){
  // Code du plugin
})(jQuery);

Dans le code du plugin vous pouvez utiliser :


$.fn.pluginName = function() {
    // code du plugin
  };

Par exemple, imaginons que nous voulons créer un plugin permettant de limiter un texte à un certain nombre de caractères et de permettre d'afficher le texte en entier. Nous appellerons ce plugin "truncate". Vous pouvez d'ores et déjà voir ce que fera notre plugin : http://blog.goeminne.eu/public/vincent/plugin_jquery/sample/index.html


(function($){
  $.fn.truncate = function() {
    // ne fait rien
  };
})(jQuery);

avec le code actuel nous pouvons appeler le plugin comme ceci :


$('selector').truncate();

selector peut être n'importe quel sélecteur css supporté par jQuery. Bien sûr, pour le moment, notre plugin ne fait rien mais ça viendra par la suite ;-)

Pour le moment on voudrait également pouvoir passer des options au plugin. Dans notre exemple on voudrait passer le nombre de caractères qu'il faut afficher par défaut, ainsi que les caractères pour indiquer que le texte n'est pas fini (par exemple "..." ou "(...)" ou encore "...'). Pour cela il faut que truncate puisse recevoir ces options et qu'il ait des valeurs par défaut pour ces options. Celà se fait comme suit


(function($){
  $.fn.truncate = function(options) {
    var options = $.extend({}, $.fn.truncate.defaults, options);
    // ne fait rien
  };
 
  $.fn.truncate.defaults = {
    'chars': 42,
    'ending':  '...',
    'minChars': 50,
    'moreText': 'suite',
    'lessText': 'réduire'
  };
})(jQuery);

De cette manière, l'utilisateur du plugin peut accéder aux valeurs par défauts en appelant :


$.fn.truncate.defauts.optionName

Par exemple :


alert($.fn.truncate.defauts.chars);

et peut appeler le plugin avec ou sans options :


$('selector').truncate();
 
$('selector').truncate({});
 
$('selector').truncate({
  chars: 50
});
 
$('selector').truncate({
  chars: 30,
  ending: '[...]'
});

Terminons enfin notre template en permettant au plugin de s'appliquer à tous les éléments du sélecteur (et non un seul, comme c'est le cas pour le moment). Ainsi, en faisant :


$('.class').truncate();

le plugin sera effectif sur tous les éléments de la casse truncate. Pour cela nous utilisons le code suivant (dans le code du plugin) :


this.each(function(){
  // this donne accès à l'élément courant sur lequel on applique le plugin
});

Ce qui donne :


(function($){
  $.fn.pluginName = function(options) {
    var options = $.extend({}, $.fn.pluginName.defaults, options);
 
    return this.each(function(){
      // code du plugin
    });
  }
 
  $.fn.pluginName.defaults = {
    option1: 'value1'
  };
})(jQuery);

Création du plugin truncate

Le plugin doit tester si le texte est assez long pour pouvoir appliquer la "réduction". Si le texte est assez long, alors on le réduit. Il faut également ajouter un lien pour afficher/masquer le reste du texte.


(function($){
  $.fn.truncate = function(options) {
    var options = $.extend({}, $.fn.truncate.defaults, options);
 
    return this.each(function(){
 
      obj = $(this);
      body = obj.html();
 
      if( body.length > options.minChars ) {
        firstPartText = body.substring(0, options.chars);
        secondPartText = body.substring(options.chars, body.length);
 
 
        firstPart = $('<span />').addClass('first_part').html(firstPartText);
 
	ending = $('<span />').addClass('ending').html(options.ending);
 
	moreLink = $('<a />').attr('href', '#').addClass('more_link').html(options.moreText);
	secondPart = $('<span />').addClass('second_part').html(secondPartText).hide();
 
	lessLink = $('<a />').attr('href', '#').addClass('less_link').html(options.lessText).hide();
 
	obj.html(firstPart).append(ending).append(secondPart).append(moreLink).append(lessLink);
 
	moreLink.click(function(){
	  // hide more link
	  $(this).hide();
 
	  // get the text container
	  textContainer = $(this).parent();
 
	  // hide ending
	  textContainer.children('.ending').hide();
 
	  // show the second part
	  textContainer.children('.second_part').css('display','inline');
 
	  // show less link
	  textContainer.children('.less_link').css('display', 'inline');
 
          return false;
	});
 
	lessLink.click(function(){
	  // hide less link
	  $(this).hide();
 
	  // get the text container
          textContainer = $(this).parent();
 
	  // show ending
	  textContainer.children('.ending').css('display', 'inline');
 
	  // hide the second part
	  textContainer.children('.second_part').hide();
 
	  // show more link
	  textContainer.children('.more_link').css('display', 'inline');
 
          return false;
	});
      }
    });
  }
 
  $.fn.truncate.defaults = {
    minChars: 50,
    chars: 42,
    ending: '...',
    moreText: '[lire la suite]',
    lessText: '[fermer]'
  };
})(jQuery);

Il ne reste plus qu'à faire appel au plugin après l'avoir préalablement chargé (cela va de soi) :


$(document).ready(function(){
  $('.truncate').truncate(); // ou avec des paramètres
});

Conclusion

Ecrire un plugin jQuery est relativement simple et pourtant très utile et puissant. Avec ce petit plugin on peut voir qu'en faisant simplement un appel ".truncate()" sur un paragraphe permet de limiter l'affichage en permettant de l'étendre, et ce, pour n'importe quelle page de n'importe quel projet !

J'espère vous avoir convaincu que développer des plugins est très intéressant, même si vous ne comptez pas le redistribuer publiquement. Vous verrez, vous serez content d' avoir votre code sous forme de plugin lorsque vous en aurez besoin pour d'autres projets. Développer sous forme de plugins jQuery ne prend pas beaucoup plus de temps mais peut vous en faire gagner plus que vous ne croyez :)

Vous pouvez télécharger les sources de cet exemple ici

[1] http://www.lshift.net/blog/2006/07/24/subclassing-in-javascript-part-1

Je me présente aussi...

Salut à tous !

Mon nom est Vincent Dieltiëns, ami de Mathieu, le créateur de ce blog. Je suis étudiant en 2ème Master en informatique et je vais également rédiger sur ce blog pour faire partager mes problèmes informatiques (et les solutions que j'ai trouvées ou que j'ai développées) ainsi que les bonnes choses que je découvre.

Ce blog étant à faire vivre (cfr ici), inutile d'en créer un sur mon site :) Principalement, je devrais rédiger des articles sur les thèmes suivants :

  1. PHP (langage de programmation pour le web)
  2. Symfony (framework PHP)
  3. Javascript (langage de programmation pour le web)
  4. jQuery (framework javascript)
  5. CSS (Feuille de style pour les désign de sites)
  6. C++ (langage de programmation multi-plateforme)
  7. wxWidgets (librairie graphique multi-plateforme pour c++)
  8. Base de données

Il pourrait m'arriver d'écrire sur le python, ruby ou d'autres langages mais ca sera plus occasionnel :)

J'espère apprendre également de nouvelles choses lors de mon stage cette année et vous le faire partager.

Je souhaite la bienvenue à Henri Fabien en passant et lui souhaite une bonne rédaction :) J'ai hâte de le lire ;-)

- page 1 de 8