• Les cookies assurent le bon fonctionnement de nos services. En poursuivant votre navigation, vous acceptez l'utilisation de cookies.

Premiers pas avec la bibliothèque de gestion de configuration Tiramisu
Cadoles , Technique , Tiramisu

Qu'est que c'est Tiramisu ?

Tiramisu est une bibliothèque de gestion de configuration portée par Cadoles. Des explications plus approfondies ont été données dans un précédent article et dans une présentation faite par Cadoles.

Cet article a été réalisé avec la version 0.55 de Tiramisu et est valable pour les nouvelles versions.

Pour obtenir la dernière version de la bibliothèque :

$ git clone git://git.labs.libre-entreprise.org/tiramisu.git
La dernière version de la documentation de Tiramisu est disponible sur la forge du réseau Libre-entreprise à l'adresse : http://tiramisu.labs.libre-entreprise.org

Manipuler une configuration simple

Dans Tiramisu, une configuration est un ensemble de variables ou d'options de configuration (Option) rassemblées dans des familles ou des groupes (OptionDescription). Les variables sont donc placées dans une arborescence qui peut être multiple. Ces variables auront des types, des propriétés,  des valeurs par défaut et des valeurs... qui leurs seront propres. La configuration (Config) est alors un point d'entrée unique pour accéder à toutes ces informations.

Commençons par créer l'arborescence de configuration :

>>> from tiramisu.config import Config
>>> from tiramisu.option import UnicodeOption, OptionDescription
>>>
>>> var1 = UnicodeOption('var1', 'first variable')
>>> var2 = UnicodeOption('var2', '', u'value')
>>>
>>> od1 = OptionDescription('od1', 'first OD', [var1, var2])
>>> rootod = OptionDescription('rootod', '', [od1])
Une fois l'arborescence créée, il est possible de créer une configuration en lecture/écriture :
>>> c = Config(rootod)
>>> c.read_write()
Naviguons dans l'arborescence :
>>> print c
[od1]
>>> print c.od1
var1 = None
var2 = value
>>> print c.od1.var1
None
>>> print c.od1.var2
value
Modifions une valeur (attention au type de la valeur !) :
>>> c.od1.var1 = 'value'
Traceback (most recent call last):
[...]
ValueError: invalid value value for option var1
>>> c.od1.var1 = u'value'
>>> print c.od1.var1
value
>>> c.od1.var2 = u'value2'
>>> print c.od1.var2
value2
Revenons à la valeur par défaut :
>>> del(c.od1.var2)
>>> print c.od1.var2
value
La valeur est stockée dans un objet Values. C'est sur cet objet que nous devons exécuter le "reset". En paramètre il faudra donner l'option (ici var2).

Par contre, en lecture seule, il n'est pas possible de modifier la valeur :

>>> c.read_only()
>>> c.od1.var2 = u'value2'
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: cannot change the value to var2 for option ['frozen'] this option is frozen
Récupération de la documentation :
>>> var1.impl_get_information('doc')
'first variable'
Si vous n'avez plus l'option description racine ou l'option, il est possible de la retrouver :
>>> c.unwrap_from_path('od1.var1').impl_get_information('doc')
'first variable'

La recherche de variable

Connaître le chemin d'une option n'est pas toujours facile. Il est possible de rechercher, dans l'arborescence, une option. Faisons une arborescence :
>>> var1 = UnicodeOption('var1', '')
>>> var2 = UnicodeOption('var2', '')
>>> var3 = UnicodeOption('var3', '')
>>> od1 = OptionDescription('od1', '', [var1, var2, var3])
>>> var4 = UnicodeOption('var4', '')
>>> var5 = UnicodeOption('var5', '')
>>> var6 = UnicodeOption('var6', '')
>>> var7 = UnicodeOption('var1', '', u'value')
>>> od2 = OptionDescription('od2', '', [var4, var5, var6, var7])
>>> rootod = OptionDescription('rootod', '', [od1, od2])
>>> c = Config(rootod)
>>> c.read_write()
Ensuite il est possible de trouver une option par son nom :
>>> print c.find(byname='var1')
[<tiramisu.option.UnicodeOption object at 0x7ff1bf7d6ef0>, <tiramisu.option.UnicodeOption object at 0x7ff1b90c7290>]
Si le nom est univoque, il est possible d'arrêter la  recherche à  la première option correspondant au critère :
>>> print c.find_first(byname='var1')
<tiramisu.option.UnicodeOption object at 0x7ff1bf7d6ef0>
Au lieu de l'option, on peut récupérer directement la valeur de la variable ou le chemin :
>>> print c.find(byname='var1', type_='value')
[None, u'value']
>>> print c.find(byname='var1', type_='path')
['od1.var1', 'od2.var1']
Enfin,  on peut faire une recherche sur la valeur, le type ou une combinaison de différents critères :
>>> print c.find(byvalue=u'value', type_='path')
['od2.var1']
>>> print c.find(bytype=UnicodeOption, type_='path')
['od1.var1', 'od1.var2', 'od1.var3', 'od2.var4', 'od2.var5', 'od2.var6', 'od2.var1']
>>> print c.find(byvalue=u'value', byname='var1', bytype=UnicodeOption, type_='path')
['od2.var1']
La recherche peut être faite à partir d'une sous-arborescence :
>>> print c.od1.find(byname='var1', type_='path')
['od1.var1']
Au lieu de rechercher des options, il est possible de récupérer des options à partir d'une option description :
>>> print c.make_dict()
{'od2.var4': None, 'od2.var5': None, 'od2.var6': None, 'od2.var1': u'value', 'od1.var1': None, 'od1.var3': None, 'od1.var2': None}
Si l'arborescence n'a pas d'importance, on peut ne récupérer que le nom de l'option. Attention, ici nous perdons une des deux options nommées var1 :
>>> print c.make_dict(flatten=True)
{'var5': None, 'var4': None, 'var6': None, 'var1': u'value', 'var3': None, 'var2': None}
On n'est pas obligé d'exporter tout l'arbre des options dans un dictionnaire. On peut ne récupérer que les options présentes dans un (ou des) groupes d'options spécifiques, par exemple ici les options "voisines" de l'option 'var1' :
>>> print c.make_dict(withoption='var1')
{'od2.var4': None, 'od2.var5': None, 'od2.var6': None, 'od2.var1': u'value', 'od1.var1': None, 'od1.var3': None, 'od1.var2': None}
>>> print c.make_dict(withoption='var1', withvalue=u'value')
{'od2.var4': None, 'od2.var5': None, 'od2.var6': None, 'od2.var1': u'value'}
Bien évidement, make_dict peut se faire sur une sous-configuration :
>>> print c.od1.make_dict(withoption='var1')
{'var1': None, 'var3': None, 'var2': None}

Les propriétaires

Lorsqu'on modifie la valeur d'une variable, on lui définit également un "owner". On peut, à tout moment, savoir si une variable est une variable par défaut ou si la variable a été modifiée. Pour cela créons une configuration :
>>> var1 = UnicodeOption('var1', '', u'oui')
>>> od1 = OptionDescription('od1', '', [var1])
>>> rootod = OptionDescription('rootod', '', [od1])
>>> c = Config(rootod)
>>> c.read_write()
Et regardons l'utilisateur associé à l'option :
>>> print c.getowner('var1')
default
>>> c.od1.var1 = u'non'
>>> print c.getowner('var1')
user
>>> del(c.var1)
>>> print c.getowner('var1')
default

Les propriétés

Une propriété est une information sur l'état de l'option. Créons des options avec propriétés :
>>> var1 = UnicodeOption('var1', '', u'value', properties=('hidden',))
>>> var2 = UnicodeOption('var2', '', properties=('mandatory',))
>>> var3 = UnicodeOption('var3', '', u'value', properties=('frozen', 'inconnu'))
>>> var4 = UnicodeOption('var4', '', u'value')
>>> od1 = OptionDescription('od1', '', [var1, var2, var3])
>>> od2 = OptionDescription('od2', '', [var4], properties=('hidden',))
>>> rootod = OptionDescription('rootod', '', [od1, od2])
>>> c = Config(rootod)
>>> c.read_write()
Une variable "cachée" est une variable non accessible en lecture/écriture. Cette variable ne sera donc pas modifiable. Testons l'accès à une variable cachée :
>>> print c.od1.var1
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: trying to access to an option named: var1 with properties ['hidden']
>>> c.read_only()
>>> print c.od1.var1
value
Une variable "obligatoire" est une variable dans laquelle il faut nécessairement une valeur. L'accès à ce type de variable ne pose pas de soucis particuliers en mode lecture/écriture. Par contre, une erreur sera générée en mode lecture seule. Cherchons à accéder à une variable obligatoire sans et avec valeur :
>>> c.read_write()
>>> print c.od1.var2
None
>>> c.read_only()
>>> print c.od1.var2
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: trying to access to an option named: var2 with properties ['mandatory']
>>> c.read_write()
>>> c.od1.var2 = u'value'
>>> c.read_only()
>>> print c.od1.var2
value
Une variable "gelée" est une variable non modifiable par l'utilisateur. Essayons de modifier une variable "gelée" :
>>> c.read_write()
>>> print c.od1.var3
value
>>> c.od1.var3 = u'value2'
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: cannot change the value for option var3 this option is frozen
>>> c.read_only()
>>> print c.od1.var3
value
Tiramisu est suffisamment flexible pour accepter des propriétés inconnues. Utilisons une propriété personnalisée en activant ou désactivant l'interdiction d'accès :
>>> c.cfgimpl_get_settings().append('inconnu')
>>> print c.od1.var3
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: trying to access to an option named: var3 with properties ['inconnu']
>>> c.cfgimpl_get_settings().remove('inconnu')
>>> print c.od1.var3
value
Les propriétés fonctionnent aussi pour les familles. Accédons à une variable dans une famille cachée :
>>> c.read_write()
>>> print c.od2.var4
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: trying to access to an option named: od2 with properties ['hidden']
>>> c.read_only()
>>> print c.od2.var4
value
Pour aller plus loin, récupérons les propriétés, supprimons et ajoutons la propriété 'hidden' :
>>> c.read_write()
>>> c.cfgimpl_get_settings()[rootod.od1.var1]
['hidden']
>>> print c.od1.var1
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: trying to access to an option named: var1 with properties ['hidden']
>>> c.cfgimpl_get_settings()[rootod.od1.var1].remove('hidden')
>>> c.cfgimpl_get_settings()[rootod.od1.var1]
[]
>>> print c.od1.var1
value
>>> c.cfgimpl_get_settings()[rootod.od1.var1].append('hidden')
>>> c.cfgimpl_get_settings()[rootod.od1.var1]
['hidden']
>>> print c.od1.var1
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: trying to access to an option named: var1 with properties ['hidden']

Les exigences

Créons une option et une option description avec des exigences :
>>> from tiramisu.option import *
>>> from tiramisu.config import *
>>> var2 = UnicodeOption('var2', '', u'oui')
>>> var1 = UnicodeOption('var1', '', u'value', requires=[{'option':var2, 'expected':u'non', 'action':'hidden'}])
>>> var3 = UnicodeOption('var3', '', u'value', requires=[{'option':var2, 'expected':u'non', 'action':'hidden'}, {'option':var2, 'expected':u'non', 'action':'disabled'}])
>>> var4 = UnicodeOption('var4', '', u'oui')
>>> od1 = OptionDescription('od1', '', [var1, var2, var3])
>>> od2 = OptionDescription('od2', '', [var4], requires=[{'option':od1.var2, 'expected':u'oui', 'action':'hidden', 'inverse':True}])
>>> rootod = OptionDescription('rootod', '', [od1, od2])
>>> c = Config(rootod)
>>> c.read_write()
L'exigence pour la variable 'od1.var1' est la liste à un élément =[{'option':var2, 'expected':u'non', 'action':'hidden'}]. Cela signifie que si la variable 'od1.var2' est à non, la variable 'od1.var1' sera cachée. Par contre si la variable 'od1.var2', n'est pas égale à 'non', la variable ne sera plus cachée :
>>> print c.cfgimpl_get_settings()[rootod.od1.var1]
[]
>>> print c.od1.var1
value
>>> print c.od1.var2
oui
>>> c.od1.var2 = u'non'
>>> print c.cfgimpl_get_settings()[rootod.od1.var1]
['hidden']
>>> print c.od1.var1
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: trying to access to an option named: var1 with properties ['hidden']
>>> c.od1.var2 = u'oui'
>>> print c.cfgimpl_get_settings()[rootod.od1.var1]
[]
>>> print c.od1.var1
value
L'exigence de l'option description od2 est [{'option':od1.var2, 'expected':u'oui', 'action':'hidden', 'inverse':True}], cela signifie que si la variable 'od1.var2' est à 'oui', la variable ne sera pas 'cachée' (grâce au True à la fin du tuple qui veut dire "inversé") et inversement :
>>> print c.od2.var4
oui
>>> c.od1.var2 = u'non'
>>> print c.od2.var4
Traceback (most recent call last):
[...]
tiramisu.error.PropertiesOptionError: trying to access to an option named: od2 with properties ['hidden']
>>> c.od1.var2 = u'oui'
>>> print c.od2.var4
oui
Les exigences peuvent s'additionner sans soucis :
>>> print c.cfgimpl_get_settings()[rootod.od1.var3]
[]
>>> c.od1.var2 = u'non'
>>> print c.cfgimpl_get_settings()[rootod.od1.var3]
['disabled', 'hidden']
>>> c.od1.var2 = u'oui'
>>> print c.cfgimpl_get_settings()[rootod.od1.var3]
[]
Il est possible d'additionner les exigences pour des propriétés différentes ou identiques (inversées ou non) :
>>> a = UnicodeOption('var3', '', u'value', requires=[{'option':od1.var2, 'expected':'non', 'action':'hidden'}, {'option':od1.var1, 'expected':'oui', 'action':'hidden'}])
>>> a = UnicodeOption('var3', '', u'value', requires=[{'option':od1.var2, 'expected':'non', 'action':'hidden'}, {'option':od1.var1, 'excepted':'oui', 'action':'disabled', 'inverse':True}])
Mais il n'est pas possible d'avoir des exigences inversées pour une même propriété. Ainsi, il n'est pas possible d'avoir :
>>> a = UnicodeOption('var3', '', u'value', requires=[{'option':od1.var2, 'expected':'non', 'action':'hidden'}, {'option':od1.var1, 'expected':'oui', 'hidden', True}])
Traceback (most recent call last):
[...]
ValueError: inconsistency in action types for option: var3 action: hidden

Les calculs :

Créons quatre fonctions de calculs :
def return_calc():
    #return an unicode value
    return u'calc'
def return_value(value):
   return value
def return_value_param(param=u''):
   return param
def return_no_value_if_non(value):
    #if value is not u'non' return value
    if value == u'non':
        return None
    else:
        return value
Créons ensuite quatre variables utilisant ces quatre fonctions :
>>> var1 = UnicodeOption('var1', '', callback=return_calc)
>>> var2 = UnicodeOption('var2', '', callback=return_value, callback_params={'': (u'value',)})
>>> var3 = UnicodeOption('var3', '', callback=return_value_param, callback_params={'param': (u'value_param',)})
>>> var4 = UnicodeOption('var4', '', callback=return_no_value_if_non, callback_params={'': (('od1.var5', False),)})
>>> var5 = UnicodeOption('var5', '', u'oui')
>>> od1 = OptionDescription('od1', '', [var1, var2, var3, var4, var5])
>>> rootod = OptionDescription('rootod', '', [od1])
>>> c = Config(rootod)
>>> c.read_write()
La première variable 'var1' retourne le résultat de la fonction return_calc, c'est à dire u'calc' :
>>> print c.od1.var1
calc
La deuxième variable 'var2' retourne le résultat de la fonction return_value avec le paramètre u'value' :
>>> print c.od1.var2
value
La troisième variable 'var3' retourne le résultat de la fonction return_value_param avec le paramètre nommé 'param' et la valeur u'value_param' :
>>> print c.od1.var3
value_param
La quatrième variable 'var4' retourne le résultat de la fonction 'return_no_value_if_non', c'est à dire la valeur de 'od1.var5' sauf si sa valeur est u'non' :
>>> print c.od1.var4
oui
>>> c.od1.var5 = u'new'
>>> print c.od1.var4
new
>>> c.od1.var5 = u'non'
>>> print c.od1.var4
None
Le calcul remplace la valeur par défaut, si on modifie la valeur, le calcul n'est plus effectué :
>>> print c.od1.var1
calc
>>> c.od1.var1 = u'new_value'
>>> print c.od1.var1
new_value
Pour forcer le calcul dans tous les cas, il faut ajouter les propriétés 'frozen' et 'force_default_on_freeze' :
>>> c.cfgimpl_get_settings()[rootod.od1.var1].append('frozen')
>>> c.cfgimpl_get_settings()[rootod.od1.var1].append('force_default_on_freeze')
>>> print c.od1.var1
calc
>>> c.cfgimpl_get_settings()[rootod.od1.var1].remove('frozen')
>>> c.cfgimpl_get_settings()[rootod.od1.var1].remove('force_default_on_freeze')
>>> print c.od1.var1
new_value

Les variables multiples

Les variables multiples sont des variables normales.

Les valeurs sont alors une liste :

>>> var1 = UnicodeOption('var1', '', [u'val1', u'val2'], multi=True)
>>> od1 = OptionDescription('od1', '', [var1])
>>> rootod = OptionDescription('rootod', '', [od1])
>>> c = Config(rootod)
>>> c.read_write()
Il est possible de manipuler la valeur comme n'importe quelle liste :
>>> print c.od1.var1
[u'val1', u'val2']
>>> c.od1.var1 = [u'var1']
>>> print c.od1.var1
[u'var1']
>>> c.od1.var1.append(u'val3')
>>> print c.od1.var1
[u'var1', u'val3']
>>> c.od1.var1.pop(1)
u'val3'
>>> print c.od1.var1
[u'var1']
Par contre il n'est pas possible de mettre une valeur qui ne soit pas une liste :
>>> c.od1.var1 = u'error'
Traceback (most recent call last):
[...]
ValueError: invalid value error for option var1 which must be a list

Les groupes maître/esclave

Un groupe maître/esclave est une option description avec la liste des options faisant parties de ce groupe. La variable maître a, obligatoirement, le même nom que l'option description. L'option description doit ensuite être du type "master". Les variables d'un groupe master sont forcement des multi. Il existe un attribut "default_multi" utilisé uniquement dans le cadre des slaves.

Voici un exemple :

>>> from tiramisu.setting import groups
>>> from tiramisu.config import Config
>>> from tiramisu.option import UnicodeOption, OptionDescription
>>>
>>> var1 = UnicodeOption('master', '', multi=True)
>>> var2 = UnicodeOption('slave1', '', multi=True)
>>> var3 = UnicodeOption('slave2', '', multi=True, default_multi=u"default")
>>>
>>> od1 = OptionDescription('master', '', [var1, var2, var3])
>>> od1.impl_set_group_type(groups.master)
>>>
>>> rootod = OptionDescription('rootod', '', [od1])
>>> c = Config(rootod)
>>> c.read_write()
Il est possible de faire varier les longueurs des listes de la façon suivante :
>>> print c.master
master = []
slave1 = []
slave2 = []
>>> c.master.master.append(u'oui')
>>> print c.master
master = [u'oui']
slave1 = [None]
slave2 = [u'default']
>>> c.master.master = [u'non']
>>> print c.master
master = [u'non']
slave1 = [None]
slave2 = [u'default']
>>>
>>> c.master.master = [u'oui', u'non']
>>> print c.master
master = [u'oui', u'non']
slave1 = [None, None]
slave2 = [u'default', u'default']
Par contre, il est interdit de moduler la longueur d'une esclave :
>>> c.master.slave1[0] = u'super'
>>> print c.master
master = [u'oui', u'non']
slave1 = [u'super', None]
slave2 = [u'default', u'default']
>>> c.master.slave1 = [u'new1', u'new2']
>>> print c.master
master = [u'oui', u'non']
slave1 = [u'new1', u'new2']
slave2 = [u'default', u'default']
>>> c.master.slave1 = [u'new1']
Traceback (most recent call last):
[...]
tiramisu.error.SlaveError: invalid len for the slave: slave1 which has master.master as master
>>> c.master.slave1 = [u'new1', u'new2', u'new3']
[...]
tiramisu.error.SlaveError: invalid len for the slave: slave1 which has master.master as master
Pour réduire la longueur, il faut obligatoirement passer par la fonction "pop" sur la master :
>>> c.master.master = [u'oui']
Traceback (most recent call last):
[...]
tiramisu.error.SlaveError: invalid len for the master: master which has slave1 as slave with greater len
>>> c.master.master.pop(0)
u'oui'
>>> print c.master
master = [u'non']
slave1 = [u'new2']
slave2 = [u'default']

Les validations

En plus des validations proposées par défaut (différentes suivant le type) il est possible de créer une fonction personnalisée. Voici par exemple une fonction qui retourne 'True' si la valeur commence par la lettre "letter" :
>>> def valid_a(value, letter=''):
...     return value.startswith(letter)
Voici une option qui utilise ce validateur :
>>> var1 = UnicodeOption('var1', '', u'oui', validator=valid_a, validator_args={'letter': 'o'})
>>>
>>> od1 = OptionDescription('od1', '', [var1])
>>>
>>> rootod = OptionDescription('rootod', '', [od1])
>>>
>>> c = Config(rootod)
>>> c.read_write()
La validation est donc appliquée à chaque modification de la variable :
>>> c.od1.var1 = u'non'
Traceback (most recent call last):
[...]
ValueError: invalid value non for option var1
>>> c.od1.var1 = u'oh non'
Il est possible de désactiver la validation :
>>> c.cfgimpl_get_settings().remove('validator')
>>> c.od1.var1 = u'non'

Les SymLinkOption

Une 'SymLinkOption' ou option de type lien symbolique, est une option qui pointe en réalité vers une autre option. Lorsqu'on accède à l'une des deux options nous avons les mêmes valeurs. Voici un exemple :
>>> from tiramisu.config import Config
>>> from tiramisu.option import UnicodeOption, SymLinkOption, OptionDescription
>>> var1 = UnicodeOption('var1', '', u'oui')
>>> var2 = SymLinkOption('var2', '', var1)
>>> od1 = OptionDescription('od1', '', [var1, var2])
>>> rootod = OptionDescription('rootod', '', [od1])
>>> c = Config(rootod)
>>> c.read_write()
Les valeurs sont identiques :
>>> print c.od1.var1
oui
>>> print c.od1.var2
oui
Il est possible de modifier soit l'une, soit l'autre :
>>> c.od1.var1 = u'non'
>>> print c.od1.var1
non
>>> print c.od1.var2
non
>>> c.od1.var2 = u'oui'
>>> print c.od1.var1
oui
>>> print c.od1.var2
oui

Autres types :

Voici un exemple avec d'autres types d'option :
>>> from tiramisu.config import Config
>>> from tiramisu.option import IntOption, BoolOption, ChoiceOption, OptionDescription
>>>
>>> var1 = IntOption('var1', '', 1)
>>> var2 = BoolOption('var2', '', True)
>>> var3 = ChoiceOption('var3', '', ('oui', 'non'), 'oui')
>>> var4 = ChoiceOption('var4', '', ('1', '2'), '1', open_values=True)
>>> od1 = OptionDescription('od1', '', [var1, var2, var3, var4])
>>> rootod = OptionDescription('rootod', '', [od1])
>>> c = Config(rootod)
>>> c.read_write()
Il est possible de manipuler des nombres :
>>> print c.od1.var1
1
>>> c.od1.var1 = 2
>>> print c.od1.var1
2
Des booléens :
>>> print c.od1.var2
True
>>> c.od1.var2 = False
>>> print c.od1.var2
False
Ou des options à choix multiples :
>>> print c.od1.var3
oui
>>> c.od1.var3 = 'non'
>>> print c.od1.var3
non
>>> c.od1.var3 = 'autre'
Traceback (most recent call last):
[...]
ValueError: invalid value autre for option var3
Voire des options à choix multiple avec valeurs ouvertes :
>>> print c.od1.var4
1
>>> c.od1.var4 = '2'
>>> print c.od1.var4
2
>>> c.od1.var4 = 'autre'
>>> print c.od1.var4
autre

En conclusion

Voilà, nous avons fait le tour des manipulations de base et des concepts de la librairie Tiramisu.

La compréhension des notions exposées dans cet article nous ont permis de devenir un vrai "end developer", c'est-à-dire un véritable utilisateur final. A vous de jouer...

Commentaires

Aucun commentaire pour l'instant, soyez le premier !

Cadoles recrute !

Nous recherchons de nouveaux coopérateurs :