Skip to content

Commit c9c76df

Browse files
committed
Refactor filename matching logic to separate classes
Signed-off-by: Olivier Mehani <shtrom@ssji.net>
1 parent 32e966c commit c9c76df

File tree

1 file changed

+131
-66
lines changed

1 file changed

+131
-66
lines changed

rotate_backups/__init__.py

Lines changed: 131 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
263362
class 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

Comments
 (0)