#!/usr/bin/env python
# -*- coding: utf-8 mode: python -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8

u"""%(scriptname)s: Gérer des fichiers de sauvegarde

USAGE
    %(scriptname)s [options] bckdir

Les fichiers de sauvegarde sont de la forme [PREFIX]yyyymmdd.n[SUFFIX]
En fonction des fichiers présents dans bckdir, ce script peut afficher:
- le nom du fichier pour la prochaine sauvegarde
- le nom du fichier de la dernière sauvegarde
- le nom des fichiers de sauvegarde à supprimer selon la politque actuelle

Si dans les fichiers du répertoire, il y a plusieurs couples (PREFIX, SUFFIX),
alors les traitements sont répétés pour chacun des couples existant, qui forment
chacun un groupe. Si un préfixe et un suffixe sont spécifiés avec les options -P
et -S, alors seuls les fichiers correspondant à ce groupe sont traités.

OPTIONS
    -c  Afficher le nom à utiliser pour la prochaine sauvegarde (CURRENT)
    -p  Afficher le nom de la dernière sauvegarde effectuée (PREVIOUS), ou une
        ligne vide s'il n'y a pas encore de sauvegardes.
    -D  Afficher les noms des sauvegardes à supprimer (DELETES). L'affichage se
        fait un fichier par ligne, et se termine par une ligne vide.
    Par défaut, afficher -c -p -D
    -e  Afficher les valeurs CURRENT, PREVIOUS et DELETES comme des variables
        shell au lieu de lignes. DELETES est affiché sous forme de tableau. Par
        exemple:
            index=0
            CURRENT='backup-20120507.0.tar.gz'
            PREVIOUS='backup-20120506.0.tar.gz'
            DELETES=('backup-20120505.0.tar.gz' 'backup-20120504.0.tar.gz' 'backup-20120503.0.tar.gz')
        Chaque groupe se voit attribuer un index commençant à 0.
    --bcmd
        Dans le mode -e, spécifier une commande à insérer avant le premier
        groupe. Quand cette commande est lancée, index==-1
    --cmd
        Dans le mode -e, spécifier une commande à insérer après chaque groupe.
    --ecmd
        Dans le mode -e, spécifier une commande à insérer après le dernier
        groupe.
    -P PREFIX
    -S SUFFIX
        Si aucun fichier n'est trouvé, valeur par défaut du préfixe et du
        suffixe des fichiers de sauvegarde.
    --prefix PREFIX
    --suffix SUFFIX
        Spécifier le préfixe et le suffixe des fichiers de sauvegarde sous forme
        d'expressions régulières. Seuls les fichiers correspondant à ces valeurs
        sont considérés.
        Ces valeurs ne permettent pas de déterminer la valeur du préfixe et du
        suffixe à utiliser si aucun fichier n'existe. Il faut utiliser les
        options -P et -S pour cela.
        Ces options n'ont pas de valeur par défaut, sauf si les options -P et -S
        sont spécifiées.
    -N max_days[=%(DEFAULT_MAX_DAYS)i]
        Nombre maximum de *jours* de sauvegarde gardés, étant entendu que l'on
        peut faire plusieurs sauvegardes par jour. C'est en fonction de cette
        valeur que le calcul des fichiers à supprimer est effectué.
        Une valeur de 0 signifie que l'on ne veut garder aucune sauvegarde.
    -M max_backups[=%(DEFAULT_MAX_BACKUPS)i]
        Nombre total maximum de fichiers de sauvegarde gardés, sans tenir compte
        du jour auquel la sauvegarde a été effectuée. C'est en fonction de cette
        valeur que le calcul des fichiers à supprimer est effectué.
        Cette valeur ne doit pas être inférieure à max_days."""

DEFAULT_MAX_DAYS=15
DEFAULT_MAX_BACKUPS=30

import os, sys, re
from os import path

from ulib.all import *

class Backup(object):
    """Ensemble de fichier de sauvegardes qui partagent un même préfixe et un même suffixe
    """
    bm = None
    bckdir = None
    prefix = None
    suffix = None

    bydates = None
    valid = None
    current = None
    previous = None
    deletes = None

    def __init__(self, bm, bckdir, prefix=None, suffix=None):
        self.bm = bm
        self.bckdir = bckdir
        if prefix is None: prefix = ''
        if suffix is None: suffix = ''
        self.prefix = prefix
        self.suffix = suffix
        self.bydates = {}
        self.valid = False

    RE_BCKFILE = re.compile(r'(.*)(\d{4})(\d{2})(\d{2})\.(\d+)(.*)')
    def add(self, bckname):
        """Ajouter un fichier de sauvegarde à la liste.

        bckname est le nom du fichier situé dans self.bckdir
        """
        mo = self.RE_BCKFILE.match(bckname)
        if mo is None: raise ValueError("%s: n'est pas un fichier de sauvegarde valide" % bckname)

        prefix, year, month, day, num, suffix = mo.groups()
        if prefix != self.prefix or suffix != self.suffix:
            raise ValueError("(prefix, suffix) ne sont pas cohérents: %r != %r",
                             (prefix, suffix),
                             (self.prefix, self.suffix),
                             )

        year, month, day, num = map(int, (year, month, day, num))
        date = Date(day, month, year)

        bckinfo = {'file': path.join(self.bckdir, bckname), 'date': date, 'num': num}
        bynums = self.bydates.get(date, None)
        if bynums is None:
            bynums = {}
            self.bydates[date] = bynums
        bynums[num] = bckinfo
        self.valid = False

    def __build_bckinfo(self, date=None, num=0):
        """Contruire un dictionnaire bckinfo à partir de self.prefix,
        self.suffix et la date et le numéro spécifiés.
        """
        if date is None: date = Date()
        bckname = "%s%04i%02i%02i.%i%s" % (
            self.prefix,
            date.year, date.month, date.day, num,
            self.suffix,
            )
        bckinfo = {'file': path.join(self.bckdir, bckname), 'date': date, 'num': num}
        return bckinfo

    def __sortfunc(self, bckinfo1, bckinfo2):
        """Comparer deux dictionnaires bckinfo
        """
        c = cmp(bckinfo1['date'], bckinfo2['date'])
        if c != 0: return c
        c = cmp(bckinfo1['num'], bckinfo2['num'])
        return c

    def __sorted(self, bydates):
        """Créer une liste inversement triée de dictionnaires bckinfo à partir
        de bydates.
        """
        bckinfos = []
        dates = bydates.keys()
        dates.sort()
        for date in dates:
            nums = bydates[date].keys()
            nums.sort()
            for num in nums:
                bckinfos.append(bydates[date][num])
        bckinfos.reverse()
        return bckinfos

    def __compute_values(self):
        """Calculer current, previous et deletes en fonction de la valeur
        actuelle de self.bydates
        """
        bydates = self.bydates.copy()
        bckinfos = self.__sorted(bydates)
        now = Date()

        # calculer previnfo et curinfo
        if bckinfos:
            previnfo = bckinfos[0]
            if previnfo['date'] == now:
                curinfo = self.__build_bckinfo(now, previnfo['num'] + 1)
            else:
                curinfo = self.__build_bckinfo(now, 0)
        else:
            previnfo = None
            curinfo = self.__build_bckinfo(now, 0)

        ## calculer delinfos
        max_days, max_backups = self.bm.max_days, self.bm.max_backups
        delinfos = []
        dates = bydates.keys()
        dates.sort()
        # d'abord supprimer tous les fichiers correspondant aux dates périmées
        if len(dates) > max_days:
            if max_days > 0: dates2delete = dates[:-max_days]
            else: dates2delete = dates
            for date in dates2delete:
                for num in bydates[date].keys():
                    delinfos.append(bydates[date][num])
                    del bydates[date][num]
        # ensuite, supprimer les sauvegardes journalières supplémentaires
        # jusqu'à ce qu'on ne dépasse plus le nombre de fichiers de sauvegarde
        # maximum.
        bckinfos = self.__sorted(bydates)
        while len(bckinfos) > max_backups:
            dates = bydates.keys()
            dates.sort()
            for date in dates:
                nums = bydates[date].keys()
                nums.sort()
                if len(nums) <= 1: continue
                num = nums[0]
                delinfos.append(bydates[date][num])
                del bydates[date][num]
                break
            bckinfos = self.__sorted(bydates)
        delinfos.sort(self.__sortfunc)

        # fin du traitement
        self.current = curinfo['file']
        if previnfo is None: self.previous = None
        else: self.previous = previnfo['file']
        self.deletes = [delinfo['file'] for delinfo in delinfos]
        self.valid = True

    def get_current(self):
        if not self.valid: self.__compute_values()
        return self.current

    def get_previous(self):
        if not self.valid: self.__compute_values()
        return self.previous

    def get_deletes(self):
        if not self.valid: self.__compute_values()
        return self.deletes

class BackupManager(object):
    """Objet permettant de classer un ensemble de fichiers de sauvegarde qui ont
    le même préfixe et le même suffixe.
    """
    bckdir = None
    re_prefix = None
    re_suffix = None
    def_prefix = None
    def_suffix = None
    max_days = DEFAULT_MAX_DAYS
    max_backups = DEFAULT_MAX_BACKUPS
    backups = None

    def __init__(self, bckdir, re_prefix=None, re_suffix=None, def_prefix=None, def_suffix=None):
        self.bckdir = bckdir
        if re_prefix is not None: self.re_prefix = re_prefix
        if re_suffix is not None: self.re_suffix = re_suffix
        if def_prefix is not None: self.def_prefix = def_prefix
        if def_suffix is not None: self.def_suffix = def_suffix

    def __fix_max_vars(self):
        max_days, max_backups = self.max_days, self.max_backups
        if max_days < 0: max_days = 0
        if max_backups < max_days: max_backups = max_days
        self.max_days, self.max_backups = max_days, max_backups

    RE_BCKFILE = re.compile(r'(.*)(\d{8}\.\d+)(.*)')
    def __build_backups(self):
        backups = {}
        for bckname in os.listdir(self.bckdir):
            # vérifier si le fichier a la forme d'un fichier de sauvegarde
            mo = self.RE_BCKFILE.match(bckname)
            if mo is None: continue

            # si re_prefix et re_suffix sont spécifiés, il doivent correspondre
            prefix, tag, suffix = mo.groups()
            if self.re_prefix is not None:
                if self.re_prefix.match(prefix) is None: continue
            if self.re_suffix is not None:
                if self.re_suffix.match(suffix) is None: continue

            # enregistrer le fichier trouvé
            backup = backups.get((prefix, suffix), None)
            if backup is None:
                backup = Backup(self, self.bckdir, prefix, suffix)
                backups[(prefix, suffix)] = backup
            backup.add(bckname)

        # si aucun fichier n'a été trouvé,
        if not backups:
            prefix = self.def_prefix
            suffix = self.def_suffix
            backups[(prefix, suffix)] = Backup(self, self.bckdir, prefix, suffix)
        return backups

    def get_backups(self):
        if self.backups is None:
            self.__fix_max_vars()
            self.backups = self.__build_backups()
        return self.backups.values()

class OutputManager(object):
    """Affichage de current, previous, deletes
    """
    shell = None
    bcmd = None
    cmd = None
    ecmd = None

    def __init__(self, shell=False, bcmd=None, cmd=None, ecmd=None):
        self.shell = shell
        if bcmd is not None: self.bcmd = bcmd
        if cmd is not None: self.cmd = cmd
        if ecmd is not None: self.ecmd = ecmd

    def header(self, index, first=False):
        if self.shell:
            print "index=%i" % index
            if first and self.bcmd is not None:
                print self.bcmd

    def quote_shell(self, s):
        return "'%s'" % s.replace("'", "'\\''")

    def print_current(self, current):
        if self.shell:
            print "CURRENT=%s" % (self.quote_shell(current))
        else:
            print current

    def print_previous(self, previous):
        if previous is None: previous = ""
        if self.shell:
            print "PREVIOUS=%s" % (self.quote_shell(previous))
        else:
            print previous

    def print_deletes(self, deletes):
        deletes = seqof(deletes)
        if self.shell:
            deletes = [self.quote_shell(s) for s in deletes]
            print "DELETES=(%s)" % " ".join(deletes)
        else:
            for delete in deletes:
                print delete
            print

    def footer(self, index, last=False):
        if self.shell:
            if not last and self.cmd is not None:
                print self.cmd
            elif last and self.ecmd is not None:
                print self.ecmd

def display_help():
    uprint(__doc__ % globals())

def run_plbck():
    options, longoptions = build_options([
        (None, 'help', "Afficher l'aide"),
        ('c', 'current'),
        ('p', 'previous'),
        ('D', 'deletes'),
        ('e', 'shell'),
        (None, 'bcmd='),
        (None, 'cmd='),
        (None, 'ecmd='),
        ('P:', 'def-prefix='),
        ('S:', 'def-suffix='),
        (None, ('prefix=', 're-prefix=')),
        (None, ('suffix=', 're-suffix=')),
        ('N:', ('days=', 'max-days=')),
        ('M:', ('count=', 'max-backups=')),
        ])
    options, args = get_args(None, options, longoptions)

    show_current = False
    show_previous = False
    show_deletes = False
    show_auto = True
    shell_vars = False
    bcmd = None
    cmd = None
    ecmd = None
    def_prefix = None
    def_suffix = None
    re_suffix = None
    re_prefix = None
    max_days = None
    max_backups = None
    for option, value in options:
        if option in ('--help', ):
            display_help()
            sys.exit(0)
        elif option in ('-c', '--current'):
            show_current = True
            show_auto = False
        elif option in ('-p', '--previous'):
            show_previous = True
            show_auto = False
        elif option in ('-D', '--deletes'):
            show_deletes = True
            show_auto = False
        elif option in ('-e', '--shell'):
            shell_vars = True
        elif option in ('--bcmd', ):
            bcmd = value
        elif option in ('--cmd', ):
            cmd = value
        elif option in ('--ecmd', ):
            ecmd = value
        elif option in ('-P', '--def-prefix'):
            def_prefix = value
            if re_prefix is None:
                re_prefix = re.compile('%s$' % re.escape(def_prefix))
        elif option in ('-S', '--def-suffix'):
            def_suffix = value
            if re_suffix is None:
                re_suffix = re.compile('%s$' % re.escape(def_suffix))
        elif option in ('--prefix', '--re-prefix'):
            # la correspondance avec re.match() et doit toujours être complète
            if value[-1:] != '$': value += '$'
            re_prefix = re.compile(value)
        elif option in ('--suffix', '--re-suffix'):
            # la correspondance avec re.match() et doit toujours être complète
            if value[-1:] != '$': value += '$'
            re_suffix = re.compile(value)
        elif option in ('-N', '--days', '--max-days'):
            max_days = int(value)
        elif option in ('-M', '--count', '--max-backups'):
            max_backups = int(value)

    if show_auto:
        show_current = True
        show_previous = True
        show_deletes = True

    if args: bckdir = args[0]
    else: bckdir = '.'

    bm = BackupManager(bckdir, re_prefix, re_suffix, def_prefix, def_suffix)
    if max_days is not None: bm.max_days = max_days
    if max_backups is not None: bm.max_backups = max_backups
    om = OutputManager(shell_vars, bcmd, cmd, ecmd)

    index = -1
    om.header(index, True)
    for backup in bm.get_backups():
        index += 1
        om.header(index)
        if show_current: om.print_current(backup.get_current())
        if show_previous: om.print_previous(backup.get_previous())
        if show_deletes: om.print_deletes(backup.get_deletes())
        om.footer(index)
    om.footer(index, True)

if __name__ == '__main__':
    run_plbck()
