@@ -260,10 +260,111 @@ 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 ):
314+ """A date-matching scheme based on filenames."""
315+
316+ timestamp_pattern : re .Pattern = None
317+
318+ def __init__ (self , timestamp_pattern : re .Pattern ):
319+ """Make a Matcher based on a regular expression pattern to apply to filenames."""
320+ self .timestamp_pattern = timestamp_pattern
321+
322+ def search (self , location : str , entry : str ) -> FilenameMatch :
323+ """Apply the pattern to the entry's name, and return a Match if found."""
324+ if match := self .timestamp_pattern .search (entry ):
325+ return FilenameMatch (match )
326+
327+ @mutable_property
328+ def timestamp_pattern (self ):
329+ """
330+ The pattern used to extract timestamps from filenames (defaults to :data:`TIMESTAMP_PATTERN`).
331+
332+ The value of this property is a compiled regular expression object.
333+ Callers can provide their own compiled regular expression which
334+ makes it possible to customize the compilation flags (see the
335+ :func:`re.compile()` documentation for details).
336+
337+ The regular expression pattern is expected to be a Python compatible
338+ regular expression that defines the named capture groups 'year',
339+ 'month' and 'day' and optionally 'hour', 'minute' and 'second'.
340+
341+ String values are automatically coerced to compiled regular expressions
342+ by calling :func:`~humanfriendly.coerce_pattern()`, in this case only
343+ the :data:`re.VERBOSE` flag is used.
344+
345+ If the caller provides a custom pattern it will be validated
346+ to confirm that the pattern contains named capture groups
347+ corresponding to each of the required date components
348+ defined by :data:`SUPPORTED_DATE_COMPONENTS`.
349+ """
350+ return TIMESTAMP_PATTERN
351+
352+ @timestamp_pattern .setter
353+ def timestamp_pattern (self , value ):
354+ """Coerce the value of :attr:`timestamp_pattern` to a compiled regular expression."""
355+ pattern = coerce_pattern (value , re .VERBOSE )
356+ for component , required in SUPPORTED_DATE_COMPONENTS :
357+ if component not in pattern .groupindex and required :
358+ raise ValueError ("Pattern is missing required capture group! (%s)" % component )
359+ set_property (self , 'timestamp_pattern' , pattern )
360+
361+
263362class RotateBackups (PropertyManager ):
264363
265364 """Python API for the ``rotate-backups`` program."""
266365
366+ _matcher : Matcher = FilenameMatcher (TIMESTAMP_PATTERN )
367+
267368 def __init__ (self , rotation_scheme , ** options ):
268369 """
269370 Initialize a :class:`RotateBackups` object.
@@ -449,37 +550,25 @@ def strict(self):
449550
450551 @mutable_property
451552 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
553+ """Pattern to use to extract a timestamp from a filename."""
554+ return self .matcher .timestamp_pattern
474555
475556 @timestamp_pattern .setter
476557 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 )
558+ """Pattern to use to extract a timestamp from a filename."""
559+ self .matcher .timestamp_pattern = value
560+
561+ @mutable_property
562+ def matcher (self ):
563+ """Matcher to use to extract a timestamp from a file."""
564+ return self ._matcher
565+
566+ @matcher .setter
567+ def matcher (self , matcher ):
568+ """Matcher to use to extract a timestamp from a file."""
569+ if not isinstance (matcher , Matcher ):
570+ raise ValueError (f'{ matcher } is not a Matcher' )
571+ set_property (self , '_matcher' , matcher )
483572
484573 def rotate_concurrent (self , * locations , ** kw ):
485574 """
@@ -642,8 +731,9 @@ def collect_backups(self, location):
642731 location = coerce_location (location )
643732 logger .info ("Scanning %s for backups .." , location )
644733 location .ensure_readable (self .force )
734+
645735 for entry in natsort (location .context .list_entries (location .directory )):
646- match = self .timestamp_pattern .search (entry )
736+ match = self .matcher .search (location , entry )
647737 if match :
648738 if self .exclude_list and any (fnmatch .fnmatch (entry , p ) for p in self .exclude_list ):
649739 logger .verbose ("Excluded %s (it matched the exclude list)." , entry )
@@ -653,7 +743,7 @@ def collect_backups(self, location):
653743 try :
654744 backups .append (Backup (
655745 pathname = os .path .join (location .directory , entry ),
656- timestamp = self .match_to_datetime (match ),
746+ timestamp = match .match_to_datetime (),
657747 ))
658748 except ValueError as e :
659749 logger .notice ("Ignoring %s due to invalid date (%s)." , entry , e )
@@ -663,31 +753,6 @@ def collect_backups(self, location):
663753 logger .info ("Found %i timestamped backups in %s." , len (backups ), location )
664754 return sorted (backups )
665755
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-
691756 def group_backups (self , backups ):
692757 """
693758 Group backups collected by :func:`collect_backups()` by rotation frequencies.
@@ -781,25 +846,25 @@ class Location(PropertyManager):
781846
782847 """:class:`Location` objects represent a root directory containing backups."""
783848
784- @required_property
849+ @ required_property
785850 def context (self ):
786851 """An execution context created using :mod:`executor.contexts`."""
787852
788- @required_property
853+ @ required_property
789854 def directory (self ):
790855 """The pathname of a directory containing backups (a string)."""
791856
792- @lazy_property
857+ @ lazy_property
793858 def have_ionice (self ):
794859 """:data:`True` when ionice_ is available, :data:`False` otherwise."""
795860 return self .context .have_ionice
796861
797- @lazy_property
862+ @ lazy_property
798863 def have_wildcards (self ):
799864 """:data:`True` if :attr:`directory` is a filename pattern, :data:`False` otherwise."""
800865 return '*' in self .directory
801866
802- @lazy_property
867+ @ lazy_property
803868 def mount_point (self ):
804869 """
805870 The pathname of the mount point of :attr:`directory` (a string or :data:`None`).
@@ -814,17 +879,17 @@ def mount_point(self):
814879 except ExternalCommandFailed :
815880 return None
816881
817- @lazy_property
882+ @ lazy_property
818883 def is_remote (self ):
819884 """:data:`True` if the location is remote, :data:`False` otherwise."""
820885 return isinstance (self .context , RemoteContext )
821886
822- @lazy_property
887+ @ lazy_property
823888 def ssh_alias (self ):
824889 """The SSH alias of a remote location (a string or :data:`None`)."""
825890 return self .context .ssh_alias if self .is_remote else None
826891
827- @property
892+ @ property
828893 def key_properties (self ):
829894 """
830895 A list of strings with the names of the :attr:`~custom_property.key` properties.
@@ -975,15 +1040,15 @@ class Backup(PropertyManager):
9751040 :attr:`~property_manager.PropertyManager.key_properties`.
9761041 """
9771042
978- @key_property
1043+ @ key_property
9791044 def pathname (self ):
9801045 """The pathname of the backup (a string)."""
9811046
982- @key_property
1047+ @ key_property
9831048 def timestamp (self ):
9841049 """The date and time when the backup was created (a :class:`~datetime.datetime` object)."""
9851050
986- @property
1051+ @ property
9871052 def week (self ):
9881053 """The ISO week number of :attr:`timestamp` (a number)."""
9891054 return self .timestamp .isocalendar ()[1 ]
0 commit comments