# -*- coding: utf-8 -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8 """Fonctions utilitaires pour lire des fichiers de configuration. Dans un fichier de configuration, l'on reconnait des lignes de la forme:: [comment][prefix]varname=value value peut être placé entre double quotes ou simple quotes. Elle peut s'étendre sur plusieurs lignes si elle est mise entre quotes, ou si elle se termine par \ """ __all__ = ( 'ConfigFile', 'ShConfigFile', 'PListFile', 'ShConfig', ) import os, string, re, types, shlex from os import path from .base import odict, make_prop, isseq, seqof, firstof from .uio import _s, _u from .files import TextFile from .formats import unicodeF #################### # gestion des commentaires re_comments = { 'shell': re.compile(r'[ \t]*#+'), 'conf': re.compile(r"[ \t]*;+"), 'C': re.compile(r'[ \t]*//+'), 'visual basic': re.compile(r"[ \t]*'+"), 'wincmd': re.compile(r'[ \t]*(?:r|R)(?:e|E)(?:m|M)'), } def is_comment(s, type=None): """Retourner vrai si s un commentaire (c'est à dire si la ligne commence par un des styles de commentaires supportés) """ comment_types = type is None and re_comments.values() or [re_comments[type]] for comment_type in comment_types: if comment_type.match(s): return True return False #################### # gestion des fichiers de configuration _marker = object() class ConfigFile(TextFile): r"""Un fichier de configuration, que l'on doit lire sous Python, et que l'on doit partager éventuellement avec d'autres langages ou d'autres systèmes d'exploitation. Par exemple, il peut s'agir d'un fichier de configuration sous bash. Une valeur non quotée est trimée à droite et à gauche. Une valeur quotée n'est jamais trimée. Une valeur quotée peut être suivie d'une valeur non quotée, et les deux sont mergés. Mais une fois que l'on commence à parser une valeur non quotée, plus aucun traitement n'est effectuée, ce qui fait qu'une valeur quotée ne peut pas suivre une valeur non quotée (cf le "andme" ci-dessus). Ceci diffère du comportement de parseur plus évolués comme celui de bash. On considère néanmoins que c'est une caractéristique, non un bug. XXX corriger ce problème, ne serait-ce que pour supporter la lecture de fichiers tels que var='value'\''with a quote' Tests ===== >>> from StringIO import StringIO >>> input = StringIO(r'''# comment ... name=value ... name2= value ... name3 = value ... qname="qvalue" ... qname2=" qvalue " ... qname3 = " qvalue " ... qname4=" ... multi-line ... qvalue ... " ... fancy="\ ... noNL\ ... "foryou"andme" ... quote='"' ... quote2="\"" ... quote3='\'' ... quote4='\\' ... quote5='\\\'' ... quote6='\\\'remainder' ... ''') >>> from ulib.base.config import ConfigFile >>> cf = ConfigFile(input) >>> cf.get_string('name') u'value' >>> cf.get_string('name2') u'value' >>> cf.get_string('name3') u'value' >>> cf.get_string('qname') u'qvalue' >>> cf.get_string('qname2') u' qvalue ' >>> cf.get_string('qname3') u' qvalue ' >>> cf.get_string('qname4') u'\n multi-line\n qvalue\n ' >>> cf.get_string('fancy') u'noNLforyouandme' >>> cf.get_string('quote') u'"' >>> cf.get_string('quote2') u'\\"' >>> cf.get_string('quote3') u"\\'" >>> cf.get_string('quote4') u'\\\\' >>> cf.get_string('quote5') u"\\\\\\'" >>> cf.get_string('quote6') u"\\\\\\'remainder" """ # valeurs lues dans le fichier de configuration _items, items = make_prop('_items')[:2] # valeurs par défaut _defaults, defaults = make_prop('_defaults')[:2] # expression régulière identifiant le préfixe des variables _prefix, prefix = make_prop('_prefix', '')[:2] # expression régulière identifiant pour le séparateur entre le nom de la # variable et sa valeur. _equals, equals = make_prop('_equals', r'\s*=')[:2] # faut-il considérer les variables en commentaires? _comment, comment = make_prop('_comment')[:2] ############################################################################ # interface publique def __init__(self, file=None, defaults=None, prefix=None, equals=None, comment=False, raise_exception=True, lines=None): """ @param prefix une expression régulière identifiant un préfixe mentionné avant chaque variable. par exemple, si prefix=='##@' et qu'on cherche la variable value, alors la ligne ##@value est cherchée. @param comment faut-il considérer les valeurs qui sont en commentaires? Si oui, tout se passe comme si le commentaire n'existe pas. @param defaults un ensemble de valeurs par défaut qui sont retournées si la variable n'existe pas dans le fichier. @param lines instance de Lines ou BLines permettant de décoder le contenu du fichier. """ super(ConfigFile, self).__init__(file, raise_exception=raise_exception, lines=lines) self._items = {} self._defaults = defaults or {} if prefix is not None: self._prefix = prefix if equals is not None: self._equals = equals self._comment = comment def __getitem__(self, name, default=_marker): """Obtenir la valeur de la variable name, telle qu'elle a été lue. Si c'est un tableau, retourner une liste. Sinon retourner une chaine. Si la variable n'est pas définie, retourner default. """ if not self._items.has_key(name): self._load_value(name) if default is _marker: if not self._items.has_key(name) and self._defaults.has_key(name): return self._defaults[name] return self._items[name] return self._items.get(name, default) get = __getitem__ def __setitem__(self, name, value): self._items[name] = value def __delitem__(self, name): del self._items[name] def has_key(self, name): try: self.__getitem__(name) except KeyError: return False else: return True def get_string(self, name, default=_marker): """Obtenir la valeur de la variable name. Si la variable est un tableau, retourner la première valeur de ce tableau. Retourner None si le tableau est vide. """ value = self.__getitem__(name, default) if isseq(value): return firstof(value) else: return value def get_lines(self, name, strip=False, default=_marker): """Obtenir une valeur avec get_string(), et la spliter sur le caractère de fin de ligne. Retourner la liste des lignes. si strip est vrai, on strip toutes les lignes puis on enlève les lignes vides. """ lines = self.get_string(name, default) if not isseq(lines): lines = re.split(r'(?:\r?)\n', lines) if strip: lines = filter(None, map(string.strip, lines)) return lines def get_paths(self, name, strip=False, default=_marker): """Obtenir une valeur avec get_string(), la splitter sur le caractère 'os.path.pathsep'. Retourner la liste des chemins. si strip est vrai, on strip toutes les valeurs puis on enlève les valeurs vide. """ paths = self.get_string(name, default) if not isseq(paths): paths = paths.split(path.pathsep) if strip: paths = filter(None, map(string.strip, paths)) return paths def get_array(self, name, default=_marker): """Obtenir la liste des valeurs de la variable name. Si name est une valeur scalaire, retourner une liste d'un seul élément. """ return list(seqof(self.__getitem__(name, default))) ############################################################################ # partie privée RE_ANTISLASHES = re.compile(r'\\+$') def _is_cont(self, value): """Tester si value doit être fusionné avec la ligne suivante à cause de la présence d'un caractère de continuation de ligne. Par défaut, on teste si value se termine par un nombre impair de '\\' """ mo = self.RE_ANTISLASHES.search(value) if mo is None: return False return len(mo.group()) % 2 == 1 def _strip_cont(self, value): """Enlever le caractère de continuation de ligne de value. On assume que self._is_cont(value) est vrai. """ return value[:-1] def _merge_cont(self, index, value, sep=''): """Merger value située à la ligne index, et la ligne suivante, en les séparant par sep. On assume que self._is_cont(value) est vrai, et que le caractère de continuation a été enlevé avec self._strip_cont(value) Dans la valeur de retour, eof vaut True si la fin de fichier est rencontrée. @return (index+1, merged_value, eof) """ if index + 1 < len(self.lines): index += 1 value = value + sep + self.lines[index] eof = False else: eof = True return index, value, eof def _unescape(self, value, quote=''): """Traiter les séquences d'échappement dans une valeur scalaire. Si la valeur était quotée, quote contient la valeur du caractère ("'", '"' ou ''). Par défaut, ne rien faire. Cette fonction doit être surchargée en fonction du type de fichier de configuration que l'on lit. La valeur quote=='' signifie que la valeur n'était pas quotée, mais il peut quand même y avoir des séquences d'échappement à traiter. """ return value def _load_value(self, name): """charger la valeur d'une variable depuis le fichier. XXX rendre le parcours plus robuste: faire attention à ne pas lire une valeur à l'intérieur d'une autre valeur. Par exemple: var1="\ var2=bad " var2=good Avec l'implémentaion actuelle, si on demande la valeur de var2, on obtient bad. Une façon de corriger cela de parcourir *tout* le fichier, de lire les valeurs non analysées de chaque variable au fur et à mesure, puis de les placer en cache. ensuite, _load_value() se contenterai d'analyser les valeurs dans le cache. @return None si la valeur n'est pas trouvée dans le fichier. Sinon, retourner une valeur scalaire ou une séquence en fonction du type de la valeur. """ # le groupe 1 sera testé pour voir si c'est un commentaire re_varname = re.compile(r'(.*)%s%s%s' % (self._prefix, name, self._equals)) re_value = re.compile(r'.*%s%s%s(.*)' % (self._prefix, name, self._equals)) indexes = self.grepi(re_varname) if not indexes: return None # trouver d'abord la ligne appropriée comment = '' for index in indexes: comment = re_varname.match(self.lines[index]).group(1) if is_comment(comment): # si la valeur est en commentaire, ne l'accepter que si # self._comment est vrai if not self._comment: continue # nous avons trouvé l'index de la ligne break else: # aucune ligne n'a été trouvée return # ensuite lire la valeur value = re_value.match(self.lines[index]).group(1) value = self._parse_logic(index, value) self._items[name] = value def _parse_logic(self, index, value): """Implémenter la logique d'analyse de la valeur d'une variable. Il faut reimplémenter cette méthode si on veut modifier le type de valeurs supportées. _parse_scalar() permet d'analyser une valeur simple, _parse_array() permet d'analyser un tableau de valeurs. Par défaut, on ne supporte que les valeurs scalaire. Utiliser ShConfigFile pour supporter les tableaux. """ value = value.lstrip() # ignorer les espaces avant la valeur return self._parse_scalar(index, value) ## valeurs scalaires simples RE_SPACES = re.compile(r'\s+') def _parse_scalar(self, index, value): remainder = value value = '' lstrip = None rstrip = None while remainder: mo = self.RE_SPACES.match(remainder) if mo is not None: # ne pas supprimer les espaces entre les valeurs remainder = remainder[mo.end():] value += mo.group() # XXX supporter de spécifier le type de commentaires valides dans ce # fichier de configuration. A cet endroit, il faudrait pouvoir # éliminer les commentaires qui sont sur la ligne. évidemment, ce ne # serait pas forcément approprié suivant la configuration. exemple: # REM pour un fichier cmd n'est valide qu'en début de ligne. elif self._is_quoted(remainder): # valeur quotée. pas de strip if lstrip is None: lstrip = False rstrip = False index, next_value, remainder = self._parse_quoted(index, remainder) value += self._unescape(next_value) else: # valeur non quotée. lstrip si en premier. rstrip si en dernier if lstrip is None: lstrip = True rstrip = True index, next_value, remainder = self._parse_value(index, remainder) value += self._unescape(next_value) if lstrip: value = value.lstrip() if rstrip: value = value.rstrip() return value RE_VALUE = re.compile('[^\\s\'"]*') def _parse_value(self, index, value, pattern=None): """Parser une valeur simple non quotée à partir de value (qui se trouve à la position index) et des lignes suivant index si la ligne se termine par '\\'. @return index, value, remainder """ while self._is_cont(value): value = self._strip_cont(value) index, value, eof = self._merge_cont(index, value) if eof: break if pattern is None: pattern = self.RE_VALUE mo = pattern.match(value) if mo is None: return index, '', value else: remainder = value[mo.end():] value = value[:mo.end()] return index, value, remainder ## valeurs scalaires quotées def _is_quoted(self, value): """Tester si value est le début d'une valeur quotée. Ignorer les espaces avant la quote. """ return value.lstrip()[:1] in ('"', "'") def _search_next_quote(self, value, re_quote): """Chercher un match de re_quote dans value, qui ne soit pas précédé par un nombre impair de '\\'. """ pos = 0 while True: mo = re_quote.search(value, pos) if mo is None: return None if self._is_cont(value[:mo.start()]): # nombre impair de '\\', la quote est mise en échappement pos = mo.end() else: return mo RE_QUOTE = re.compile(r'[\'"]') def _parse_quoted(self, index, value): """Parser une valeur quotée à partir de value (qui se trouve à la position index) et des lignes suivant index. value *doit* commencer par la quote. si _is_quoted(value) est vrai, il faut enlever les espaces éventuels au début de value avant de la passer à cette méthode. @return index, value, remainder """ if self.RE_QUOTE.match(value) is None: raise ValueError("value must start with a quote, got %s" % repr(_s(value))) quote, value = value[:1], value[1:] re_quote = re.compile(quote) mo = self._search_next_quote(value, re_quote) while mo is None: if self._is_cont(value): value = self._strip_cont(value) index, value, eof = self._merge_cont(index, value) else: index, value, eof = self._merge_cont(index, value, self.nl) mo = self._search_next_quote(value, re_quote) if eof: break if mo is None: # valeur quotée, mais mal terminée. on fait comme si on a rien vu return index, value, '' else: remainder = value[mo.end():] value = value[:mo.start()] return index, value, remainder ## tableaux def _is_array(self, value): """Tester si value est le début d'un tableau. Ignorer les espaces avant le tableau. """ return False def _parse_array(self, index, value): """Parser un tableau à partir de value (qui se trouve à la position index) et des lignes suivant index. value *doit* commencer par le tableau. si _is_array(value) est vrai, il faut enlever les espaces éventuels au début de value avant de la passer à cette méthode. """ return [] class ShConfigFile(ConfigFile): r"""Un fichier de configuration qui est susceptible d'être lu aussi par bash (ou tout autre shell sh-like). On supporte l'évaluation de variables, et certaines séquences d'échappement pour des valeurs quotées. Il y a certaines limitations: lors de la lecture des valeurs des variables, les caractères sont traduits suivant la correspondance suivante: \ en fin de ligne: continuer sur la ligne suivante \" " \\ \ \$ $ La séquence \` n'est pas traduite. En effet, pour que cela aie un sens, il faudrait que l'on traduise aussi `cmd` De plus, on ne supporte que les variables de la forme $var et ${var} Tests ===== >>> from StringIO import StringIO >>> input = StringIO(r'''# comment ... var1=value ... var2="value" ... var3='value' ... var4=(value1 "value2" 'value3') ... var5=( ... value1 ... "value2\ ... " 'value3' ... ) ... var6=() ... var7=( ) ... var8=( ... ) ... ''') >>> from ulib.base.config import ShConfigFile >>> cf = ShConfigFile(input) >>> cf.get_string('var1') u'value' >>> cf.get_string('var2') u'value' >>> cf.get_string('var3') u'value' >>> cf.get_string('var4') u'value1' >>> cf.get_array('var4') [u'value1', u'value2', u'value3'] >>> cf.get_array('var5') [u'value1', u'value2', u'value3'] >>> [cf.get_array(name) for name in ('var6', 'var7', 'var8')] [[], [], []] >>> cf.get_array('var1') [u'value'] >>> cf.get_string('var4') u'value1' >>> cf.get_string('var6') is None True """ RE_VAR = re.compile(r'\$(?:\{([^}]+)\}|(\w+))') TRANS_MAP = {r'\"': '"', r'\\': '\\', r'\$': '$'} def __convert(self, value): # XXX rendre la conversion plus robuste: veiller à l'ordre ('\\\\' en # dernier...), et ne faire la conversion que pour un nombre impaire de # '\\'. for s, r in self.TRANS_MAP.items(): value = value.replace(s, r) return value def _unescape(self, value, quote=''): """convertir une valeur quotée, suivant les règles de bash. quote peut valoir "'", '"', '' """ # aucune traduction entre '' if quote == "'": return value # sinon appliquer les règles standards. notamment, remplacer $var et # ${var} par self._items["var"] ou os.environ["var"] splited = self.RE_VAR.split(value) value = self.__convert(splited[0]) splited = splited[1:] while splited: var0 = splited[0] var1 = splited[1] text = splited[2] splited = splited[3:] var = var0 or var1 if self.has_key(var): value = value + self.get_string(var) else: value = value + os.environ.get(var, "") value = value + self.__convert(text) return value def _parse_logic(self, index, value): value = value.lstrip() # ignorer les espaces avant la valeur if self._is_array(value): return self._parse_array(index, value) else: return self._parse_scalar(index, value) ## tableaux def _is_array(self, value): """Tester si value est le début d'un tableau. """ return value.strip()[:1] == '(' RE_ARRAY_VALUE = re.compile('[^\\s\'")]*') def _parse_next_scalar(self, index, value): """Parser la prochaine valeur scalaire XXX à faire @return index, value, remainder """ remainder = value value = '' lstrip = None rstrip = None while remainder: if self.RE_SPACES.match(remainder) is not None: # les valeurs sont séparées par des espaces break # XXX cf ConfigFile._parse_scalar pour la gestion des commentaires elif self.RE_EOA.match(remainder) is not None: # fin de tableau break elif self._is_quoted(remainder): # valeur quotée. pas de strip if lstrip is None: lstrip = False rstrip = False index, next_value, remainder = self._parse_quoted(index, remainder) value += self._unescape(next_value) else: # valeur non quotée. lstrip si en premier. rstrip si en dernier if lstrip is None: lstrip = True rstrip = True index, next_value, remainder = self._parse_value(index, remainder, self.RE_ARRAY_VALUE) value += self._unescape(next_value) if lstrip: value = value.lstrip() if rstrip: value = value.rstrip() return index, value, remainder RE_SOA = re.compile(r'\(') RE_EOA = re.compile(r'\)') def _parse_array(self, index, value): """Parser un tableau à partir de value (qui se trouve à la position index) et des lignes suivant index. @return index, values, remaining """ if self.RE_SOA.match(value) is None: raise ValueError("value must start with '(', got %s" % repr(_s(value))) remainder = value[1:] values = [] eoa = False # end of array while True: if not remainder: # nous n'avons pas encore rencontré la fin du tableau. Lire les # lignes jusqu'à ce que nous trouvions ce qui est nécessaire index, remainder, eof = self._merge_cont(index, remainder) if eof: break # ignorer les espaces entre les valeurs mo = self.RE_SPACES.match(remainder) if mo is not None: remainder = remainder[mo.end():] continue # tester si on arrive à la fin du tableau if self.RE_EOA.match(remainder) is not None: remainder = remainder[1:] eoa = True break # parser une valeur scalaire index, next_value, remainder = self._parse_next_scalar(index, remainder) values.append(next_value) # ici, eoa vaut True si le tableau a été terminé proprement. # sinon, on fait comme si on a rien vu. return values _debug = False def _print_debug(s): if _debug: print s class PListFile(TextFile): def readlines(self, raise_exception=True, close=True): TextFile.readlines(self, raise_exception, close) self.items = None self.list = None self.value = None if self.is_valid(): if self.lines and self.lines[0][:5] == '