Skip to content

Commit c3072d0

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

File tree

1 file changed

+117
-56
lines changed

1 file changed

+117
-56
lines changed

rotate_backups/__init__.py

Lines changed: 117 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
263356
class 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

Comments
 (0)