@@ -260,6 +260,99 @@ def rotate_backups(directory, rotation_scheme, **options):
260260 program .rotate_backups (directory )
261261
262262
263+ class Match :
264+ """An interface-like class for a date match."""
265+
266+ def match_to_datetime (self ) -> datetime .datetime :
267+ """Return a datetime from the Match."""
268+ pass
269+
270+
271+ class Matcher :
272+ """An interface-like class for a date-matching scheme."""
273+
274+ def search (self , location : str , entry : str ) -> Match :
275+ """Process an entry in a location and return a Match."""
276+ pass
277+
278+
279+ class FilenameMatch (Match ):
280+ """A match based on a filename pattern."""
281+
282+ match : re .Match = None
283+
284+ def __init__ (self , match : re .Match ):
285+ """Make a Match from a regular expression match."""
286+ self .match = match
287+
288+ def match_to_datetime (self ) -> datetime .datetime :
289+ """
290+ Convert the regular expression match to a :class:`~datetime.datetime` value.
291+
292+ :returns: A :class:`~datetime.datetime` value.
293+ :raises: :exc:`exceptions.ValueError` when a required date component is
294+ not captured by the pattern, the captured value is an empty
295+ string or the captured value cannot be interpreted as a
296+ base-10 integer.
297+
298+ .. seealso:: :data:`SUPPORTED_DATE_COMPONENTS`
299+ """
300+ kw = {}
301+ captures = self .match .groupdict ()
302+ for component , required in SUPPORTED_DATE_COMPONENTS :
303+ value = captures .get (component )
304+ if value :
305+ kw [component ] = int (value , 10 )
306+ elif required :
307+ raise ValueError ("Missing required date component! (%s)" % component )
308+ else :
309+ kw [component ] = 0
310+ return datetime .datetime (** kw )
311+
312+
313+ class FilenameMatcher (Matcher , PropertyManager ):
314+ """A date-matching scheme based on filenames."""
315+
316+ def search (self , location : str , entry : str ) -> FilenameMatch :
317+ """Apply the pattern to the entry's name, and return a Match if found."""
318+ if match := self .timestamp_pattern .search (entry ):
319+ return FilenameMatch (match )
320+
321+ @mutable_property
322+ def timestamp_pattern (self ):
323+ """
324+ The pattern used to extract timestamps from filenames (defaults to :data:`TIMESTAMP_PATTERN`).
325+
326+ The value of this property is a compiled regular expression object.
327+ Callers can provide their own compiled regular expression which
328+ makes it possible to customize the compilation flags (see the
329+ :func:`re.compile()` documentation for details).
330+
331+ The regular expression pattern is expected to be a Python compatible
332+ regular expression that defines the named capture groups 'year',
333+ 'month' and 'day' and optionally 'hour', 'minute' and 'second'.
334+
335+ String values are automatically coerced to compiled regular expressions
336+ by calling :func:`~humanfriendly.coerce_pattern()`, in this case only
337+ the :data:`re.VERBOSE` flag is used.
338+
339+ If the caller provides a custom pattern it will be validated
340+ to confirm that the pattern contains named capture groups
341+ corresponding to each of the required date components
342+ defined by :data:`SUPPORTED_DATE_COMPONENTS`.
343+ """
344+ return TIMESTAMP_PATTERN
345+
346+ @timestamp_pattern .setter
347+ def timestamp_pattern (self , value ):
348+ """Coerce the value of :attr:`timestamp_pattern` to a compiled regular expression."""
349+ pattern = coerce_pattern (value , re .VERBOSE )
350+ for component , required in SUPPORTED_DATE_COMPONENTS :
351+ if component not in pattern .groupindex and required :
352+ raise ValueError ("Pattern is missing required capture group! (%s)" % component )
353+ set_property (self , 'timestamp_pattern' , pattern )
354+
355+
263356class RotateBackups (PropertyManager ):
264357
265358 """Python API for the ``rotate-backups`` program."""
@@ -447,39 +540,31 @@ def strict(self):
447540 """
448541 return True
449542
450- @mutable_property
543+ @property
451544 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 defines the named capture groups 'year',
462- 'month' and 'day' and optionally '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
545+ """Pattern to use to extract a timestamp from a filename."""
546+ if not isinstance (self .matcher , FilenameMatcher ):
547+ raise ValueError ('Current matcher is not a FilenameMatcher' )
548+ return self .matcher .timestamp_pattern
474549
475550 @timestamp_pattern .setter
476551 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 )
552+ """Pattern to use to extract a timestamp from a filename."""
553+ if not isinstance (self .matcher , FilenameMatcher ):
554+ raise ValueError ('Current matcher is not a FilenameMatcher' )
555+ self .matcher .timestamp_pattern = value
556+
557+ @cached_property
558+ def matcher (self ):
559+ """Matcher to use to extract a timestamp from a file."""
560+ return FilenameMatcher (timestamp_pattern = TIMESTAMP_PATTERN )
561+
562+ @matcher .setter
563+ def matcher (self , matcher ):
564+ """Matcher to use to extract a timestamp from a file."""
565+ if not isinstance (matcher , Matcher ):
566+ raise ValueError (f'{ matcher } is not a Matcher' )
567+ set_property (self , '_matcher' , matcher )
483568
484569 def rotate_concurrent (self , * locations , ** kw ):
485570 """
@@ -642,8 +727,9 @@ def collect_backups(self, location):
642727 location = coerce_location (location )
643728 logger .info ("Scanning %s for backups .." , location )
644729 location .ensure_readable (self .force )
730+
645731 for entry in natsort (location .context .list_entries (location .directory )):
646- match = self .timestamp_pattern .search (entry )
732+ match = self .matcher .search (location , entry )
647733 if match :
648734 if self .exclude_list and any (fnmatch .fnmatch (entry , p ) for p in self .exclude_list ):
649735 logger .verbose ("Excluded %s (it matched the exclude list)." , entry )
@@ -653,7 +739,7 @@ def collect_backups(self, location):
653739 try :
654740 backups .append (Backup (
655741 pathname = os .path .join (location .directory , entry ),
656- timestamp = self .match_to_datetime (match ),
742+ timestamp = match .match_to_datetime (),
657743 ))
658744 except ValueError as e :
659745 logger .notice ("Ignoring %s due to invalid date (%s)." , entry , e )
@@ -663,31 +749,6 @@ def collect_backups(self, location):
663749 logger .info ("Found %i timestamped backups in %s." , len (backups ), location )
664750 return sorted (backups )
665751
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- captures = match .groupdict ()
681- for component , required in SUPPORTED_DATE_COMPONENTS :
682- value = captures .get (component )
683- if value :
684- kw [component ] = int (value , 10 )
685- elif required :
686- raise ValueError ("Missing required date component! (%s)" % component )
687- else :
688- kw [component ] = 0
689- return datetime .datetime (** kw )
690-
691752 def group_backups (self , backups ):
692753 """
693754 Group backups collected by :func:`collect_backups()` by rotation frequencies.
0 commit comments