11# rotate-backups: Simple command line interface for backup rotation.
22#
33# Author: Peter Odding <peter@peterodding.com>
4- # Last Change: February 13 , 2020
4+ # Last Change: February 14 , 2020
55# URL: https://github.com/xolox/python-rotate-backups
66
77"""
2626from executor import ExternalCommandFailed
2727from executor .concurrent import CommandPool
2828from executor .contexts import RemoteContext , create_context
29- from humanfriendly import Timer , coerce_boolean , format_path , parse_path , pluralize
29+ from humanfriendly import Timer , coerce_boolean , coerce_pattern , format_path , parse_path , pluralize
3030from humanfriendly .text import concatenate , split
3131from natsort import natsort
3232from property_manager import (
3636 lazy_property ,
3737 mutable_property ,
3838 required_property ,
39+ set_property ,
3940)
4041from simpleeval import simple_eval
4142from six import string_types
4849# Initialize a logger for this module.
4950logger = VerboseLogger (__name__ )
5051
52+ DEFAULT_REMOVAL_COMMAND = ['rm' , '-fR' ]
53+ """The default removal command (a list of strings)."""
54+
5155ORDERED_FREQUENCIES = (
5256 ('minutely' , relativedelta (minutes = 1 )),
5357 ('hourly' , relativedelta (hours = 1 )),
5761 ('yearly' , relativedelta (years = 1 )),
5862)
5963"""
60- A list of tuples with two values each:
64+ An iterable of tuples with two values each:
6165
6266- The name of a rotation frequency (a string like 'hourly', 'daily', etc.).
6367- A :class:`~dateutil.relativedelta.relativedelta` object.
6468
6569The tuples are sorted by increasing delta (intentionally).
6670"""
6771
72+ SUPPORTED_DATE_COMPONENTS = (
73+ ('year' , True ),
74+ ('month' , True ),
75+ ('day' , True ),
76+ ('hour' , False ),
77+ ('minute' , False ),
78+ ('second' , False ),
79+ )
80+ """
81+ An iterable of tuples with two values each:
82+
83+ - The name of a date component (a string).
84+ - :data:`True` for required components, :data:`False` for optional components.
85+ """
86+
6887SUPPORTED_FREQUENCIES = dict (ORDERED_FREQUENCIES )
6988"""
7089A dictionary with rotation frequency names (strings) as keys and
89108filenames.
90109"""
91110
92- DEFAULT_REMOVAL_COMMAND = ['rm' , '-fR' ]
93- """The default removal command (a list of strings)."""
94-
95111
96112def coerce_location (value , ** options ):
97113 """
@@ -197,15 +213,21 @@ def load_config_file(configuration_file=None, expand=True):
197213 rotation_scheme = dict ((name , coerce_retention_period (items [name ]))
198214 for name in SUPPORTED_FREQUENCIES
199215 if name in items )
200- options = dict (include_list = split (items .get ('include-list' , '' )),
201- exclude_list = split (items .get ('exclude-list' , '' )),
202- io_scheduling_class = items .get ('ionice' ),
203- strict = coerce_boolean (items .get ('strict' , 'yes' )),
204- prefer_recent = coerce_boolean (items .get ('prefer-recent' , 'no' )))
216+ options = dict (
217+ exclude_list = split (items .get ('exclude-list' , '' )),
218+ include_list = split (items .get ('include-list' , '' )),
219+ io_scheduling_class = items .get ('ionice' ),
220+ prefer_recent = coerce_boolean (items .get ('prefer-recent' , 'no' )),
221+ strict = coerce_boolean (items .get ('strict' , 'yes' )),
222+ )
205223 # Don't override the value of the 'removal_command' property unless the
206224 # 'removal-command' configuration file option has a value set.
207225 if items .get ('removal-command' ):
208226 options ['removal_command' ] = shlex .split (items ['removal-command' ])
227+ # Don't override the value of the 'timestamp_pattern' property unless the
228+ # 'timestamp-pattern' configuration file option has a value set.
229+ if items .get ('timestamp-pattern' ):
230+ options ['timestamp_pattern' ] = items ['timestamp-pattern' ]
209231 # Expand filename patterns?
210232 if expand and location .have_wildcards :
211233 logger .verbose ("Expanding filename pattern %s on %s .." , location .directory , location .context )
@@ -425,6 +447,40 @@ def strict(self):
425447 """
426448 return True
427449
450+ @mutable_property
451+ def timestamp_pattern (self ):
452+ """
453+ The pattern used to extract timestamps from filenames (defaults to :data:`TIMESTAMP_PATTERN`).
454+
455+ The value of this property is a compiled regular expression object.
456+ Callers can provide their own compiled regular expression which
457+ makes it possible to customize the compilation flags (see the
458+ :func:`re.compile()` documentation for details).
459+
460+ The regular expression pattern is expected to be a Python compatible
461+ regular expression that contains the named capture groups 'year',
462+ 'month', 'day', 'hour', 'minute' and 'second'.
463+
464+ String values are automatically coerced to compiled regular expressions
465+ by calling :func:`~humanfriendly.coerce_pattern()`, in this case only
466+ the :data:`re.VERBOSE` flag is used.
467+
468+ If the caller provides a custom pattern it will be validated
469+ to confirm that the pattern contains named capture groups
470+ corresponding to each of the required date components
471+ defined by :data:`SUPPORTED_DATE_COMPONENTS`.
472+ """
473+ return TIMESTAMP_PATTERN
474+
475+ @timestamp_pattern .setter
476+ def timestamp_pattern (self , value ):
477+ """Coerce the value of :attr:`timestamp_pattern` to a compiled regular expression."""
478+ pattern = coerce_pattern (value , re .VERBOSE )
479+ for component , required in SUPPORTED_DATE_COMPONENTS :
480+ if component not in pattern .groupindex and required :
481+ raise ValueError ("Pattern is missing required capture group! (%s)" % component )
482+ set_property (self , 'timestamp_pattern' , pattern )
483+
428484 def rotate_concurrent (self , * locations , ** kw ):
429485 """
430486 Rotate the backups in the given locations concurrently.
@@ -587,7 +643,7 @@ def collect_backups(self, location):
587643 logger .info ("Scanning %s for backups .." , location )
588644 location .ensure_readable (self .force )
589645 for entry in natsort (location .context .list_entries (location .directory )):
590- match = TIMESTAMP_PATTERN .search (entry )
646+ match = self . timestamp_pattern .search (entry )
591647 if match :
592648 if self .exclude_list and any (fnmatch .fnmatch (entry , p ) for p in self .exclude_list ):
593649 logger .verbose ("Excluded %s (it matched the exclude list)." , entry )
@@ -597,7 +653,7 @@ def collect_backups(self, location):
597653 try :
598654 backups .append (Backup (
599655 pathname = os .path .join (location .directory , entry ),
600- timestamp = datetime . datetime ( * ( int ( group , 10 ) for group in match . groups ( '0' )) ),
656+ timestamp = self . match_to_datetime ( match ),
601657 ))
602658 except ValueError as e :
603659 logger .notice ("Ignoring %s due to invalid date (%s)." , entry , e )
@@ -607,6 +663,30 @@ def collect_backups(self, location):
607663 logger .info ("Found %i timestamped backups in %s." , len (backups ), location )
608664 return sorted (backups )
609665
666+ def match_to_datetime (self , match ):
667+ """
668+ Convert a regular expression match to a :class:`~datetime.datetime` value.
669+
670+ :param match: A regular expression match object.
671+ :returns: A :class:`~datetime.datetime` value.
672+ :raises: :exc:`exceptions.ValueError` when a required date component is
673+ not captured by the pattern, the captured value is an empty
674+ string or the captured value cannot be interpreted as a
675+ base-10 integer.
676+
677+ .. seealso:: :data:`SUPPORTED_DATE_COMPONENTS`
678+ """
679+ kw = {}
680+ for component , required in SUPPORTED_DATE_COMPONENTS :
681+ value = match .group (component )
682+ if value :
683+ kw [component ] = int (value , 10 )
684+ elif required :
685+ raise ValueError ("Missing required date component! (%s)!" % component )
686+ else :
687+ kw [component ] = 0
688+ return datetime .datetime (** kw )
689+
610690 def group_backups (self , backups ):
611691 """
612692 Group backups collected by :func:`collect_backups()` by rotation frequencies.
0 commit comments