# -*- coding: utf-8 mode: python -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
import i_need_py23

"""Des méthodes pour gérer le pliage/dépliage de fichiers inclus
"""

__all__ = ()

import os, re
from os import path

from ulib.base import isstr, seqof, make_prop, make_delegate
from ulib.base.paths import KNOWN_BINARY_EXTS, dirname, abspath, relpath, ppath
from ulib.base.args import build_options, get_args
from ulib.base.input import is_yes, is_no
from ulib.base.uio import _s
from ulib.base.lines import BLines
from ulib.p.dirconf import DirConf
from spec import FileSpecs
from output import NullOutput

################################################################################
# Classes de support

class FType(object):
    """Gestion des directives et des commentaires d'un type de fichier.

    Cet objet contient les expressions régulières associées à un type de
    fichier.

    - _re_comment est l'expression régulière qui identifie un commentaire
    - _re_prefix est l'expression régulire qui identifie le préfixe d'une
      ligne de directive.
    """

    _re_comment, re_comment = make_prop('_re_comment', setter=False)[:2]
    _re_prefix, re_prefix = make_prop('_re_prefix', setter=False)[:2]

    def __init__(self, comment_pattern, prefix_pattern):
        self._re_comment = re.compile(comment_pattern)
        self._re_prefix = re.compile(prefix_pattern)

    def strip_comment(self, line):
        """enlever les commentaires à la fin de line
        """
        mo = self._re_comment.search(line)
        if mo is not None: line = line[:mo.start()]
        return line

class IncLine(object):
    """Gestionnaire de lignes de directives.

    Le caractères d'inclusion est '@' ou '*'. Dans les exemples, il est affiché
    comme '@'.

    Les lignes de directives sont des lignes de commentaires d'un format
    spécial. Les formes suivantes sont reconnues:

        REM@
        ##@
        ;;@
        //@
        ''@

    Note: le caractère d'inclusion '@*' existe aussi, mais il est particulier:
    une fois déplié, il ne peut pas être replié. C'est la propriété locked.

    Une fois qu'un type de ligne de commentaire est reconnue, toutes les lignes
    de directives suivantes doivent être de ce type.

    Les directives valides sont:

    - include F    un fichier à déplier à l'endroit de la ligne de directive
    - inc[F        début du fichier déplié
    - inc]F        fin du fichier déplié
    - require F    requière la présence du fichier F dans les inclusions
                   parentes
    - provide F    indiquer que le fichier F est fourni par ce fichier
    - cooked OPTS  spécifier certaines options pour le dépliage. les options
                   doivent êtres séparées par des espaces:
                   - nocomments: supprimer les commentaires lors du dépliage
                   - comments: garder les commentaires [par défaut]
                   - shell, conf, c, cmd, vbs: spécifier le type de commentaires
                     à traiter avec l'option nocomments
    """

    SHELL_FTYPE = FType(r'\s*#', r'\s*##')
    CONF_FTYPE = FType(r'\s*;', r'\s*;;')
    C_FTYPE = FType(r'\s*//', r'\s*//')
    CMD_FTYPE = FType(r'\s*(?:r|R)(?:e|E)(?:m|M)\s?', r'\s*(?:r|R)(?:e|E)(?:m|M)')
    VBS_FTYPE = FType(r"\s*'", r"\s*''")
    FTYPES = [SHELL_FTYPE, CONF_FTYPE, C_FTYPE, CMD_FTYPE, VBS_FTYPE]
    FTYPE_MAP = {'shell':SHELL_FTYPE,
                 'conf': CONF_FTYPE,
                 'c': C_FTYPE,
                 'cmd': CMD_FTYPE,
                 'vbs': VBS_FTYPE,
                 }

    def is_valid_ftype(cls, ftype):
        return cls.FTYPE_MAP.has_key(ftype)
    is_valid_ftype = classmethod(is_valid_ftype)

    def is_any_prefix(cls, line):
        """Tester si line est un des types de ligne de directive supportés
        """
        for ftype in cls.FTYPES:
            if ftype.re_prefix.match(line) is not None: return True
        return False
    is_any_prefix = classmethod(is_any_prefix)

    def is_any_comment(cls, line):
        """Tester si line est un des types de ligne de commentaire supportés
        """
        for ftype in cls.FTYPES:
            if ftype.re_comment.match(line) is not None: return True
        return False
    is_any_comment = classmethod(is_any_comment)

    _ftype = None
    def set_ftype(self, name):
        ftype = self.FTYPE_MAP.get(name, None)
        if ftype is None: raise ValueError("invalide ftype: %s" % name)
        self._ftype = ftype

    def is_prefix(self, line):
        """Tester si la ligne est une ligne de directive.

        Dès qu'un type de commentaire est reconnu, il est fixé définitivement,
        et toutes les invocations de cette méthode ne retournent True que pour
        ce type de commentaire.
        """
        ftypes = seqof(self._ftype, self.FTYPES)
        for ftype in ftypes:
            if ftype.re_prefix.match(line) is not None:
                if self._ftype is None:
                    # à la première correspondance, sélectioner définitivement
                    # ce type de commentaire
                    self._ftype = ftype
                return True
        return False

    def is_comment(self, line):
        """Tester si la ligne est une ligne de commentaire
        """
        ftypes = seqof(self._ftype, self.FTYPES)
        for ftype in ftypes:
            if ftype.re_comment.match(line) is not None: return True
        return False

    def strip_comment(self, line):
        """Supprimer le commentaire à la fin de line
        """
        ftypes = seqof(self._ftype, self.FTYPES)
        for ftype in ftypes:
            if ftype.re_comment.search(line) is not None:
                return ftype.strip_comment(line)
        return line

    INCLUDE = 'include'
    START_INC = 'inc['
    END_INC = 'inc]'
    REQUIRE = 'require'
    PROVIDE = 'provide'
    COOKED = 'cooked'
    TEMPLATES = {
        INCLUDE: u"%%s%%s%s %%s" % INCLUDE,
        START_INC: u"%%s%%s%s%%s" % START_INC,
        END_INC: u"%%s%%s%s%%s" % END_INC,
        REQUIRE: u"%%s%%s%s %%s" % REQUIRE,
        PROVIDE: u"%%s%%s%s %%s" % PROVIDE,
        COOKED: u"%%s%%s%s %%s" % COOKED,
    }
    _RE_SPECIAL = re.compile(r'([[\]])')
    _RE_SPACES = re.compile(r'\s+')
    _NO_SPACES = {START_INC: True, END_INC: True}
    ACTIONS = [INCLUDE, START_INC, END_INC, REQUIRE, PROVIDE, COOKED]

    # caractère d'inclusion. une ligne de directive est de la forme "COMMENT C"
    # où COMMENT est la chaine qui indique un commentaire, et C le caractère
    # d'inclusion.
    _c, c = make_prop('_c', None)[:2]
    _orig_c = None # valeur de c dans le constructeur
    # expression régulière permettant de matcher une ligne de directive
    _re_il, re_il = make_prop('_re_il', None)[:2]

    # après analyse d'une ligne, indentation et commentaire avant la directive
    _prefix, prefix = make_prop('_prefix', None)[:2]
    # après analyse d'une ligne, est-ce une inclusion verrouillée?
    _locked, locked = make_prop('_locked', None)[:2]
    # après analyse d'une ligne, nom de la directive
    _action, action = make_prop('_action', None)[:2]
    # après analyse d'une ligne, arguments de la directive
    _args, args = make_prop('_args', None)[:2]

    def __init__(self, c=None):
        il_pattern = r'(.*)' # prefix
        if c in ('*', '@'):
            self._c = self._orig_c = c
            il_pattern += r'((?:@|\*)\*?)'
        else:
            self._orig_c = None
            self._c = '@'
            il_pattern += r'(@\*?)'
        # action
        il_pattern += r'(%s)' % '|'.join([self._RE_SPECIAL.sub(r'\\\1', action)
                                          for action in self.ACTIONS])
        il_pattern += r'(\s*)'
        # args
        il_pattern += r'(.+)'
        self._re_il = re.compile(il_pattern)

    def copy(self, copy_ftype=True):
        """Faire une copie de cet objet.
        """
        # initialiser la copie avec _orig_c plutôt que _c. sinon, ce n'est pas
        # une copie conforme.
        il = self.__class__(self._orig_c)
        il._prefix = self._prefix
        il._action = self._action
        il._args = self._args
        if copy_ftype: il._ftype = self._ftype
        return il

    def match(self, line):
        mo = self._re_il.match(line)
        if mo is not None:
            self._prefix = mo.group(1)
            self._locked = mo.group(2) in ('@*', '**')
            if self.is_prefix(self._prefix):
                self._action = mo.group(3)
                spaces = mo.group(4)
                self._args = mo.group(5)
                if self._NO_SPACES.get(self._action, False):
                    # il ne doit pas y avoir d'espaces entre cette directive et
                    # son argument
                    if not spaces: return True
                else:
                    # il doit y avoir un espace entre cette directive et son
                    # argument
                    if self._RE_SPACES.match(spaces): return True
        self._prefix = None
        self._locked = None
        self._action = None
        self._args = None
        return False

    def is_include(self): return self._action == self.INCLUDE
    def is_start_inc(self): return self._action == self.START_INC
    def is_end_inc(self, name=None):
        return self._action == self.END_INC and (name is None or name == self._args)
    def is_require(self): return self._action == self.REQUIRE
    def is_provide(self): return self._action == self.PROVIDE
    def is_cooked(self): return self._action == self.COOKED

    def __c_or_locked(self):
        if self._locked: return '@*'
        else: return self._c

    def get_include(self, name=None):
        if name is None: name = self._args
        return self.TEMPLATES[self.INCLUDE] % (self._prefix, self.__c_or_locked(), name)
    def get_start_inc(self, name=None):
        if name is None: name = self._args
        return self.TEMPLATES[self.START_INC] % (self._prefix, self.__c_or_locked(), name)
    def get_end_inc(self, name=None):
        if name is None: name = self._args
        return self.TEMPLATES[self.END_INC] % (self._prefix, self.__c_or_locked(), name)
    def get_require(self, name=None):
        if name is None: name = self._args
        return self.TEMPLATES[self.REQUIRE] % (self._prefix, self.__c_or_locked(), name)
    def get_provide(self, name=None):
        if name is None: name = self._args
        return self.TEMPLATES[self.PROVIDE] % (self._prefix, self.__c_or_locked(), name)
    def get_cooked(self, args=None):
        if args is None: args = self._args
        return self.TEMPLATES[self.COOKED] % (self._prefix, self.__c_or_locked(), args)

class Env(object):
    """Un environnement pour plier ou déplier un fichier.

    - 'basedir' est le répertoire de base à partir duquel ont été cherchés les
      fichiers à mettre à jour. Ne sert que pour l'affichage.
    - 'included' permet de spécifier si le fichier doit être déplié tel quel
      (False), ou si des traitements supplémentaires spécifiés par la directive
      @cooked sont autorisés (True). typiquement, le fichier initial doit être
      déplié tel quel, et les fichiers inclus sont récursivement traités avec
      cooked.
    - 'cooked' contient les options de cooked
    - 'il' est l'objet permettant de gérer les lignes de directives.
    """

    COOKED_MAP = {
        'nc': 'nocomments',
        'comment': 'comments', 'nocomment': 'nocomments',
        }

    _stack = None

    def __init__(self, basedir=None, c=None):
        super(Env, self).__init__()
        self._stack = []
        self._provided = {}
        self.push(basedir, False, IncLine(c))

    def get_basedir(self): return self._stack[-1]['basedir']
    basedir = property(get_basedir)
    def is_included(self): return self._stack[-1]['included']
    included = property(is_included)
    def get_cooked(self): return self._stack[-1]['cooked']
    cooked = property(get_cooked)
    def get_il(self): return self._stack[-1]['il']
    il = property(get_il)

    def push(self, basedir, included=True, il=None):
        """Placer un nouveau contexte sur la pile.
        """
        if il is None:
            if self._stack: il = self.get_il().copy(False)
            else: il = IncLine()
        else: il = il.copy(False)
        item = {'basedir': basedir,
                'included': included,
                'cooked': {'comments': True},
                'il': il,
                }
        self._stack.append(item)

    def pop(self):
        self._stack.pop()

    _cooked = None
    def set_cooked(self, args):
        # avec la directive @cooked, les commentaires sont autorisés. il faut
        # donc les enlever.
        args = self.get_il().strip_comment(args)
        # les options sont séparées par des espaces
        if isstr(args): args = args.strip().split()
        for arg in args:
            arg = self.COOKED_MAP.get(arg, arg)
            if IncLine.is_valid_ftype(arg):
                self.get_il().set_ftype(arg)
            else:
                value = True
                if arg.startswith('no'):
                    arg = arg[2:]
                    value = False
                cooked = self.get_cooked()
                if cooked.has_key(arg): cooked[arg] = value

    def cook_nocomments(self):
        cooked = self.get_cooked()
        return not cooked['comments']

    def cook(self, line):
        il = self.get_il()
        if self.cook_nocomments() and il.is_comment(line):
            line = None
        return line

    _provided = None
    def set_provided(self, ap): self._provided[ap] = True
    def is_provided(self, ap): return self._provided.get(ap, False)

################################################################################
# Resource

class Resource(object):
    """Un fichier à traiter, ou un répertoire dans lequel se trouve des fichiers
    à traiter. Si le fichier n'existe pas, la resource est invalide.

    - updater est l'instance de Updater courante
    - parent est la resource parente.
    - ap est le chemin absolu vers le fichier/répertoire.
    - dirname est le chemin du répertoire de ap
    - filename est le nom de ap
    - valid vaut True si la resource existe
    - modified indique si un fichier a été modifié. seul les fichiers modifiés
      sont re-écrits sur le disque.
    """

    def __init__(self, p, parent=None):
        super(Resource, self).__init__()
        self._parent = parent
        self._ap = path.abspath(p)
        self._dirname, self._filename = path.split(self._ap)

    _updater, updater = make_prop('_updater')[:2]
    _parent, parent = make_prop('_parent', setter=False)[:2]
    _ap, ap = make_prop('_ap', setter=False)[:2]
    _dirname, dirname = make_prop('_dirname', setter=False)[:2]
    _filename, filename = make_prop('_filename', setter=False)[:2]
    _modified, modified = make_prop('_modified')[:2]

    def is_valid(self): return path.exists(self._ap)
    valid = property(is_valid)

    out = make_delegate('_updater.out')
    def get_resource(self, name, included=True):
        return self._updater.get_resource(name, self._dirname, included)

    def get_dirconf(self): return None
    dirconf = property(get_dirconf)

    def get_orig(self): return None
    orig = property(get_orig)
    def update(self, action=None, c=None, env=None): return None
    def get_lines(self, c=None, env=None): return None
    lines = property(get_lines)
    def get_folded(self, c=None, env=None): return None
    folded = property(get_folded)
    def get_unfolded(self, c=None, env=None): return None
    unfolded = property(get_unfolded)
    def load(self): pass
    def save(self): pass

class Directory(Resource):
    def __init__(self, dp, parent=None):
        super(Directory, self).__init__(dp, parent)

    def get_dirconf(self):
        if self._dirconf is None: self._dirconf = DirConf(self._ap)
        return self._dirconf
    _dirconf, dirconf = make_prop('_dirconf', getter=get_dirconf, setter=False)[:2]

class File(Resource):
    def __init__(self, fp, parent=None):
        super(File, self).__init__(fp, parent)

    _orig = None # contenu original du fichier
    def get_orig(self):
        """Obtenir le contenu original du fichier"""
        if self._orig is None: self.load()
        return self._orig
    orig = property(get_orig)

    _lines = None # contenu actuel, après pliage/dépliage
    def update(self, action=None, env=None):
        """Mettre à jour le fichier en le pliant ou le dépliant suivant la
        valeur de action(=_updater.action par défaut).

        Le résultat du calcul est mis en cache.
        """
        if action is None: action = self._updater.action
        if action is None: action = self._updater.UNFOLD
        if self._lines is None:
            _, self._lines, self._modified = self._update(action, env)
        return self._lines

    def get_lines(self, env=None): return self.update(env=env)
    lines = property(get_lines)

    def get_folded(self, env=None):
        _, lines, _ = self._update(self._updater.FOLD, env)
        return lines
    folded = property(get_folded)

    def get_unfolded(self, env=None):
        _, lines, _ = self._update(self._updater.UNFOLD, env)
        return lines
    unfolded = property(get_unfolded)

    def load(self):
        """Charger le fichier
        """
        self._orig = BLines()
        if self.is_valid():
            self._orig.readlines(self._ap)
        self._lines = None
        self._modified = False

    def save(self):
        """Ecrire le fichier sur disque
        """
        self._lines.writelines(self._ap)
        self._orig = self._lines
        self._modified = False

    def _update(self, action, env):
        """Mettre à jour le fichier en le pliant ou le dépliant suivant la
        valeur de action
        """
        if env is None: env = Env(self._dirname)
        lines = None
        modified = False

        try:
            if self._lines is None:
                # pour éviter les inclusions récursives
                self._lines = BLines()

            if action == self._updater.FOLD: lines = self._fold(env)
            elif action == self._updater.UNFOLD: lines = self._unfold(env)
            else: raise ValueError("unknown action: %s" % action)

            # utiliser self._orig parce que l'appel des méthode _fold() ou
            # _unfold() a provoqué la création de cette variable. de plus, si le
            # fichier n'est pas lisible, cela évite de provoquer une deuxième
            # exception
            modified = lines != self._orig
        except:
            self.out.eerror()
            # ici, on utilise self._orig au lieu de self.get_orig() parce que
            # l'exception vient peut-être du fait que le fichier n'est pas
            # lisible.
            lines = self._orig
            modified = False

        return env, lines, modified

    def _fold(self, env):
        """Plier les inclusions de self._orig et retourner le résultat.
        """
        self.out.beginf(ppath(self._ap, cwd=env.basedir))
        try:
            il = env.il
            folding = False
            name = None
            lines = BLines()

            index = 0
            incindex = 0
            for line in self.get_orig():
                index += 1
                il.match(line)
                if not folding:
                    ## nous ne somme pas encore dans une section à replier
                    # soit nous débutons une section à replier
                    if il.is_start_inc() and not il.locked:
                        folding = True
                        incindex = index
                        name = il.args
                        line = il.get_include(name)
                    # sinon, copier les lignes sans modification
                    lines.append(line)
                else:
                    ## nous sommes dans une section à replier
                    # soit c'est la fin de la section à replier
                    if il.is_end_inc(name):
                        folding = False
                        incindex = 0
                        name = None
                    # soit nous ignorons toutes les lignes jusqu'à la fin de la
                    # section
                    pass

            if folding:
                # nous avons trouvé une inclusion non terminée. ne pas modifier le
                # fichier, il est peut-être corrompu ou mal construit.
                lines = self._orig
                self.out.ignored(u"include line started at line %i is not properly ended" % incindex)
            else:
                self.out.folded()
            return lines
        finally:
            self.out.endf()

    def _unfold(self, env):
        """Déplier les inclusions de self._orig et retourner le résultat
        """
        self.out.beginf(ppath(self._ap, cwd=env.basedir))
        try:
            il = env.il
            unfolding = False
            keep_unfolded = False
            name = None
            resource = None # resource correspondant à name
            lines = BLines()

            index = 0
            incindex = 0
            for line in self.get_orig():
                index += 1
                il.match(line)
                if not unfolding:
                    ## nous ne sommes pas encore dans une section à déplier
                    if il.is_include(): ##@include name
                        unfolding = True
                        incindex = index
                        name = il.args
                        resource = self.get_resource(name)

                        if resource.valid:
                            env.push(self._dirname, True)
                            lines.append(il.get_start_inc(name))
                            lines.extend(resource.get_unfolded(env=env))
                            lines.append(il.get_end_inc(name))
                            env.pop()
                            env.set_provided(resource.ap)
                        else:
                            self.out.notfound(name)
                            line = il.get_include(name)
                            lines.append(line) # ne pas toucher à la ligne
                            env.set_provided(path.abspath(path.join(self._dirname, name)))

                        unfolding = False
                        incindex = 0
                        name = None
                        resource = None
                        line = None

                    elif il.is_start_inc(): ##@inc[name
                        unfolding = True
                        incindex = index
                        keep_unfolded = False
                        name = il.args
                        resource = self.get_resource(name)

                        if resource.valid:
                            env.push(self._dirname, True)
                            lines.append(il.get_start_inc(name))
                            lines.extend(resource.get_unfolded(env=env))
                            lines.append(il.get_end_inc(name))
                            env.pop()
                            env.set_provided(resource.ap)
                        else:
                            self.out.notfound(name)
                            # si le fichier n'est pas trouvé, garder la version
                            # dépliée actuelle
                            line = il.get_start_inc(name)
                            lines.append(line)
                            keep_unfolded = True
                            env.set_provided(path.abspath(path.join(self._dirname, name)))

                        line = None

                    elif il.is_require(): ##@require name
                        name = il.args
                        resource = self.get_resource(name)
                        if not resource.valid:
                            self.out.notfound(name)
                        elif not env.is_provided(resource.ap):
                            self.out.needed(name)
                        line = il.get_require(name)

                    elif il.is_provide(): ##@provide name
                        name = il.args
                        resource = self.get_resource(name)
                        if resource.valid:
                            env.set_provided(resource.ap)
                        else:
                            self.out.notfound(name)
                        line = il.get_provide(name)

                    elif il.is_cooked(): ##@cooked params
                        args = il.args
                        env.set_cooked(args)
                        if env.included: line = None
                        else: line = il.get_cooked(args)

                    else:
                        if env.included: line = env.cook(line)

                    # copier la ligne éventuellement traitée
                    if line is not None: lines.append(line)
                else:
                    ## nous sommes dans une section à déplier
                    # si c'est la fin de la section à déplier, on arrête
                    if il.is_end_inc(name):
                        if keep_unfolded:
                            line = il.get_end_inc(name)
                            lines.append(line)
                        unfolding = False
                        incindex = 0
                        name = None
                        resource = None
                    # si keep_unfolded==True, toujours copier les lignes actuelles
                    elif keep_unfolded: lines.append(line)
                    # sinon, ignorer les lignes, car elles ont déjà été inclues
                    # ci-dessus
                    else: pass

            if unfolding:
                # nous avons trouvé une inclusion non terminée. ne pas modifier le
                # fichier, il est peut-être corrompu ou mal construit.
                lines = self._orig
                self.out.ignored(u"include line started at line %i is not properly ended" % incindex)
            else:
                self.out.unfolded()
            return lines
        finally:
            self.out.endf()

################################################################################
# Updater

class Updater(object):
    """Un objet permet de plier/déplier les inclusions.

    Si on spécifie un contexte, la mise à jour n'est effectuée que si on se
    trouve dans le bon contexte. Le contexte actuel est indiqué dans le fichier
    .udir ou .uinst.conf et n'est lu qu'en mode automatique:

    - uinc==True signifie qu'il y a toujours correspondance. Quelque soit le
      contexte spécifié, la mise à jour sera faite.
    - uinc=False signifie qu'il n'y a jamais correspondance. La mise à jour ne
      sera faite que si le contexte n'est pas spécifié.
    - uinc=release (par exemple) signifie de ne faire la mise à jour que si le
      contexte spécifié est 'release'.

    Si on ne spécifie pas de contexte, la mise à jour est toujours effectuée.
    """

    def fix_xxclude(spec):
        """Corriger une spécification de fichier à inclure ou à exclure.

        - Si spec commence par './', enlever ce préfixe et laisser la valeur
          comme telle.
        - Sinon, si spec contient un caractère '/', ne pas le modifier
        - Sinon, rajouter '**/' à la spécification.
        """
        if spec.startswith('./'): return spec[2:]
        elif spec.find('/') != -1: return spec
        else: return '**/' + spec
    fix_xxclude = staticmethod(fix_xxclude)

    def fix_xxcludes(specs):
        """Corriger les spécifications de fichier à inclure ou à exclure.

        L'argugment est une chaine de spécifications séparés par ':'
        """
        specs = filter(None, ':'.split(specs))
        specs = map(Updater.fix_xxclude, specs)
        return specs
    fix_xxcludes = staticmethod(fix_xxcludes)

    def parse_options(cls, args=None,
                      ignore_autoconf=False, ignore_options=None):
        """Parser les options de la ligne de commande.

        Les arguments ignore_* permettent de spécifier des arguments qui ne
        doivent pas être parsés et sont retournés dans la variable options.

        Par défaut, seul '--help' est ignoré. Avec ignore_autoconf, '-f',
        '--fold', '-u', '--fold', '--auto', '--noauto', '-C', '--context',
        '--refdir' sont ajoutés à la liste des options à ignorer.

        ignore_options permet de spécifier une liste supplémentaire d'options à
        ignorer.

        @return (action, options, args, params)

        - action est l'action à effectuer: fold ou unfold
        - options est une liste d'options qui n'ont pas été traités, et doivent
          être traités par l'utilisateur.
        - args est la liste des arguments qui ne sont pas des options, notamment
          les fichiers et répertoires à traiter.
        - params est un dictionnaire avec les paramètres qui ont été spécifiés
          sur la ligne de commande. ces paramètres peuvent être utilisés pour
          appeler le constructeur de Updater.

        les clés suivantes sont valides pour params:
        - refdir: répertoire de référence
        - includes: patterns à inclure
        - excludes: patterns à exclure
        - auto_excludes: faut-il une liste par défaut d'exclusions
        - auto: faut-il faire la configuration automatique?
        - context: contexte pour le pliage/dépliage
        - c: caractère d'inclusion, '@' ou '*'
        """
        ignore_options = list(seqof(ignore_options, []))
        ignore_options.append('--help')
        if ignore_autoconf:
            ignore_options.extend(['-f', '--fold', '-u', '--fold',
                                   '--auto', '--noauto',
                                   '-C', '--context',
                                   '--refdir'])

        options, longoptions = build_options([
            (None, 'help', u"Afficher l'aide"),
            ('f', 'fold', u"Plier les inclusions"),
            ('u', 'unfold', u"Déplier les inclusions"),
            ('@', None, u"Forcer le caractère '@' pour le pliage/dépliage"),
            ('*', None, u"Forcer le caractère '*' pour le pliage/dépliage"),
            (None, 'auto', u"Forcer la recherche automatique de paramètres."),
            (None, 'noauto', u"Désactiver la recherche automatique de paramètres."),
            ('C:', 'context=', u"Spécifier le contexte. La mise à jour n'est faite que si le contexte lu dans .udir correspond au contexte spécifié."),
            (None, 'refdir=', u"Spécifier le répertoire de référence. Par défaut, il s'agit du répertoire de départ."),
            ('I:', 'include=', u"Spécifier les fichiers à inclure dans les répertoires spécifiés, séparés par ':'. Par défaut, tous les fichiers sont inclus."),
            ('X:', 'exclude=', u"Spécifier les fichiers à exclure des répertoirs spécifiés, séparés par ':'. Par défaut, seuls les fichiers de VCS et les fichiers ayant une extension binaire connue sont exclus."),
            (None, 'no-auto-excludes', u"Ne pas utiliser une liste par défaut de fichiers à exclure."),
            ])
        options, args = get_args(args, options, longoptions)
        action = Updater.UNFOLD
        params = {}
        includes = None
        excludes = None
        retoptions = []
        for option, value in options:
            if option in ignore_options:
                retoptions.append((option, value))
            elif option in ('-f', '--fold'):
                action = Updater.FOLD
            elif option in ('-u', '--unfold'):
                action = Updater.UNFOLD
            elif option in ('-@',):
                params['c'] = '@'
            elif option in ('-*',):
                params['c'] = '*'
            elif option in ('--auto',):
                params['auto'] = True
            elif option in ('--noauto',):
                params['auto'] = False
            elif option in ('-C', '--context'):
                params['context'] = value
            elif option in ('--refdir',):
                params['refdir'] = value
            elif option in ('-I', '--include'):
                includes = seqof(includes, [])
                includes.extend(cls.fix_xxcludes(value))
            elif option in ('-X', '--exclude'):
                excludes = seqof(excludes, [])
                excludes.extend(cls.fix_xxcludes(value))
            elif option in ('--no-auto-excludes',):
                params['auto_excludes'] = False
        if includes is not None: params['includes'] = includes
        if excludes is not None: params['excludes'] = excludes
        return (action, retoptions, args, params)
    parse_options = classmethod(parse_options)

    FOLD = 'fold'
    UNFOLD = 'unfold'
    DEFAULT_INCLUDES = ('**',)
    DEFAULT_EXCLUDES = ('**/CVS/', '**/.svn/', '.git/') + \
        tuple(['**/*' + ext for ext in KNOWN_BINARY_EXTS])

    # instance utilisé pour l'affichage des messages
    _out, out = make_prop('_out', NullOutput())[:2]
    # contexte dans lequel on opère.
    _context, context = make_prop('_context', None)[:2]
    # action à effectuer
    _action, action = make_prop('_action', None)[:2]
    # caractère à utiliser pour les inclusions
    _c, c = make_prop('_c', None)[:2]
    # liste des fichiers/répertoires à mettre à jour
    _args, args = make_prop('_args')[:2]
    # les arguments de args ont-ils été modifiés automatiquement? C'est le cas
    # si par exemple args contient un fichier de configuration .udir ou
    # .uinst.conf, ou si le répertoire à traiter a été calculé automatiquement
    _auto, auto = make_prop('_auto', None)[:2]
    # répertoire de base des fichiers que nous sommes en train de traiter en ce
    # moment avec update(). Cette propriété n'est valide que dans le contexte de
    # la méthode, et obtient successivement tous les répertoires mentionnés dans
    # args, ou les répertoires parents des fichiers mentionnés dans args
    _basedir, basedir = make_prop('_basedir')[:2]
    # répertoire de référence correspondant à _basedir. Si _refdir==None, alors
    # le répertoire de référence est le répertoire de base.
    _refdir, refdir = make_prop('_refdir')[:2]
    # instance de FileSpecs contenant les spécifications de fichiers à inclure
    # et à exclure pour les arguments de type répertoires
    _fs, fs = make_prop('_fs')[:2]
    # Liste de répertoires fournie par l'utilisateur, dans lesquels sont
    # recherchés les fichiers à inclure
    _uincdirs, uincdirs = make_prop('_uincdirs', None)[:2]

    # correspondance chemin absolu --> Resource
    _resources, resources = make_prop('_resources')[:2]

    def __init__(self, args=None, refdir=None, includes=None, excludes=None, auto_excludes=True, auto=True, context=None, c=None):
        # self._refdir, self._context et self._resources sont utilisés
        # directement ou indirectement par self.autoconf()
        if refdir is not None: refdir = path.abspath(refdir)
        self._refdir = refdir
        self._context = context
        self._uincdirs = []
        self._resources = {}

        if auto:
            args, includes, excludes = \
                self.autoconf(args, includes=includes, excludes=excludes)

        includes = list(seqof(includes, []))
        if not includes: includes = self.DEFAULT_INCLUDES
        excludes = list(seqof(excludes, []))
        if auto_excludes: excludes += self.DEFAULT_EXCLUDES

        self._c = c
        self._args = seqof(args, [])
        self._fs = FileSpecs(includes, excludes)

    ############################################################################
    # autoconfiguration

    def _first_resource_with_dirconf(self, dir):
        """Chercher à partir de dir et en montant dans les parents le premier
        répertoire qui contient une configuration udir/uinst.conf valide.

        dir doit être un répertoire absolu normalisé.
        """
        resource = self._get_resource(dir)
        while not resource.dirconf.valid:
            resource = resource.parent
            if resource is None: return None
        return resource

    def autoconf(self, args=None, includes=None, excludes=None):
        """Faire l'autoconfiguration des paramètres.

        L'autoconfiguration est faite pour chaque répertoire mentionné dans
        args. Si aucun répertoire n'est spécifié, rechercher le premier
        répertoire à partir du répertoire courant, en allant vers la racine, qui
        contient un fichier de configuration '.udir' ou '.uinst.conf'. Sinon,
        prendre le répertoire courant.

        Pour chacun des répertoire mentionnés, s'il contient un fichier de
        configuration, parser le fichier. S'il n'y a qu'un seul répertoire dans
        args, prendre les paramètres mentionnés dans le fichier de
        configuration. Dans tous les cas, corriger la liste des fichiers à
        traiter à partir du fichier de configuration.

        Notamment, le paramètre uinc dans .udir et le paramètre update_inc dans
        .uinst.conf détermine le type de configuration automatique. Ces
        paramètres sont nommés $uinc dans ce qui suit.
        De même, les paramètres uinc_options et uinc_args dans .udir et
        update_inc_options et update_inc_args dans .uinst.conf sont utilisés
        pour spécifier la configuration automatique. Ces paramètres sont nommés
        $uinc_options et $uinc_args dans ce qui suit.

        Après examen du contenu de $uinc et si le contexte correspond:
        - Si args==None, alors il obtient la valeur $uinc_args.
        - $uinc_options est parsé, et est utilisé pour mettre à jour includes et
          excludes.

        @return (args, includes, excludes, auto_excludes)
        """
        self._auto = False

        if not args:
            cwd = os.getcwd()
            resource = self._first_resource_with_dirconf(cwd)
            if resource is None:
                args = [cwd]
            else:
                args = [resource.ap]
                self._auto = True

        destargs = []
        use_options = len(args) == 1

        for arg in args:
            arg = path.abspath(arg)
            resource = self._get_resource(arg)
            dirconf = resource.dirconf

            if dirconf is None:
                # c'est un fichier. parce que pour les répertoires, dirconf ne
                # peut être None
                destargs.append(arg)
            else:
                uinc_args = dirconf.uinc_args
                if len(uinc_args) == 1 and abspath(uinc_args[0], arg) == arg:
                    # uinc_args==['.'] est équivalent à pas de valeur du tout.
                    uinc_args = ()
                if uinc_args:
                    basedir = arg
                    destargs.extend([path.join(basedir, arg) for arg in uinc_args])
                    self._auto = True
                else:
                    destargs.append(arg)

                if use_options:
                    destargs = list(dirconf.uinc_options) + destargs

        args = destargs
        includes = None
        excludes = None

        if use_options:
            _, _, args, params = self.parse_options(args, ignore_autoconf=True)
            add_includes = params.get('includes', None)
            add_excludes = params.get('excludes', None)
            if add_includes is not None:
                includes = list(seqof(includes, [])) + list(add_includes)
            if add_excludes is not None:
                excludes = list(seqof(excludes, [])) + list(add_excludes)

        return args, includes, excludes

    ############################################################################
    # resources

    def _is_under(self, ap, basedir):
        """Tester si ap est un fichier situé dans l'arborescence à partir de basedir

        ap et basedir doivent être des chemins absolus normalisés
        """
        return basedir is not None and (ap == basedir or ap.startswith(basedir + '/'))

    def _get_resource(self, ap, included=False):
        """Obtenir l'instance de Resource correspondant au fichier ou au
        répertoire ap

        ap doit être un chemin absolu déjà normalisé.
        """
        resource = self._resources.get(ap, None)
        if resource is None:
            refdir = self._refdir
            basedir = self._basedir
            if included and refdir is not None and refdir != basedir:
                if self._is_under(ap, basedir): pass
                elif self._is_under(ap, refdir): pass
                else:
                    # fichier à l'extérieur de basedir, à reexprimer à partir de refdir
                    ap = path.abspath(path.join(refdir, relpath(ap, cwd=basedir)))

            parentdir = dirname(ap)
            if parentdir == ap: parent = None
            else: parent = self._get_resource(parentdir)

            if path.isdir(ap): resource = Directory(ap, parent)
            else: resource = File(ap, parent)
            resource.updater = self

            self._resources[ap] = resource
        return resource

    def get_resource(self, name, basedir=None, included=False):
        """Obtenir la resource correspondant au fichier nommé name.

        Si ce fichier est relatif, il est d'abord exprimé par rapport au
        répertoire basedir, puis recherché dans self._uincdirs

        @return resource

        Si le fichier n'existe pas, resource.valid==False
        """
        if not path.isabs(name):
            uincdirs = []
            if basedir is None: basedir = self._basedir
            if basedir is not None: uincdirs.append(basedir)
            uincdirs.extend(self._uincdirs)
            for p in uincdirs:
                ap = path.abspath(path.join(p, name))
                if path.exists(ap):
                    name = ap
                    break
            else:
                name = path.abspath(path.join(basedir, name))
        return self._get_resource(name, included)

    ############################################################################
    # Mise à jour des fichiers

    def _should_update(self, dir):
        """Vérifier s'il faut mettre à jour le répertoire arg en consultant la
        configuration avec .uinst.conf ou .udir

        Si dir est un fichier, toujours retourner True.

        @return should_update, reason

        reason est la raison pour laquelle la mise à jour ne doit pas se faire
        si should_update==False
        """
        if path.isfile(dir): return True, None
        elif path.isdir(dir):
            dirconf = self._get_resource(path.abspath(dir)).dirconf
            uinc = dirconf.uinc
            if is_yes(uinc): return True, None
            elif is_no(uinc):
                return False, 'uinc=false in %s' % dirconf.configname
            elif self._context is not None:
                return uinc == self._context, 'expected context "%s", got "%s"' % (uinc, self._context)
            else:
                # Si on ne spécifie pas le contexte, il faut toujours faire la
                # mise à jour
                return True, None
        else: return False, '%s is invalid or not found' % ppath(dir)

    def should_update(self, arg=None):
        """Vérifier s'il faut mettre à jour le répertoire ou le fichier arg.
        Si arg==None, faire le test pour tous les arguments de self.args

        @return should_update, reason

        reason est la raison pour laquelle la mise à jour ne doit pas se faire
        si should_update==False
        """
        if arg is not None:
            return self._should_update(arg)
        else:
            should_update = False
            reason = 'no args found'
            for arg in self._args:
                should_update, reason = self._should_update(arg)
                if should_update: break
            return should_update, reason

    def update(self, action=UNFOLD, args=None, c=None):
        """Effectuer l'action

        @return: True si l'action a eu pour résultat une modifications dans les
            fichiers.
        """
        if action not in (self.FOLD, self.UNFOLD):
            raise ValueError("unknown action: %s" % action)
        self._action = action

        if args is None:
            # filtrer les fichiers/répertoires qui ne doivent pas être traités
            args = list(map(path.abspath, self._args))
            destargs = []
            for arg in args:
                should_update, reason = self._should_update(arg)
                if should_update:
                    destargs.append(arg)
                else:
                    self.out.ewarn(u"%s: filtered because %s" % (ppath(arg), reason))
            args = destargs
        else:
            # ne pas filtrer si on donne une liste de fichiers
            args = list(map(path.abspath, seqof(args)))

        if c is None: c = self._c

        # d'abord calculer quels fichiers mettre à jour
        self.out.etitle(u"Calcul des mises à jour à effectuer")
        try:
            updated_files = []
            for arg in args:
                self.out.edebug(ppath(arg))
                if path.isdir(arg):
                    self._basedir = arg
                    fps = self._fs.walk(arg)
                else:
                    self._basedir = dirname(arg)
                    fps = [arg]

                for fp in fps:
                    resource = self._get_resource(fp)
                    if resource.valid:
                        env = Env(self._basedir, c)
                        self.out.deferred = True
                        resource.update(env=env)
                        resource.printed = self.out.printed
                        self.out.deferred = False
                        updated_files.append(fp)
                    else:
                        self.out.eerror(u"file not found: %s" % fp)
        finally:
            self.out.eend()

        # ensuite, écrire les fichiers modifiés
        self.out.etitle(u"Mise à jour des fichiers")
        try:
            modified = False
            for fp in updated_files:
                resource = self._get_resource(fp)
                if resource.modified:
                    self.out.estep_(resource.printed)
                    resource.save()
                    modified = True
            return modified
        finally:
            self.out.eend()
