|
27 | 27 | from executor.concurrent import CommandPool |
28 | 28 | from executor.contexts import RemoteContext, create_context |
29 | 29 | from humanfriendly import Timer, coerce_boolean, format_path, parse_path, pluralize |
30 | | -from humanfriendly.text import compact, concatenate, split |
| 30 | +from humanfriendly.text import concatenate, split |
31 | 31 | from natsort import natsort |
32 | 32 | from property_manager import ( |
33 | 33 | PropertyManager, |
@@ -294,6 +294,28 @@ def exclude_list(self): |
294 | 294 | """ |
295 | 295 | return [] |
296 | 296 |
|
| 297 | + @mutable_property |
| 298 | + def force(self): |
| 299 | + """ |
| 300 | + :data:`True` to continue if sanity checks fail, :data:`False` to raise an exception. |
| 301 | +
|
| 302 | + Sanity checks are performed before backup rotation starts to ensure |
| 303 | + that the given location exists, is readable and is writable. If |
| 304 | + :attr:`removal_command` is customized then the last sanity check (that |
| 305 | + the given location is writable) is skipped (because custom removal |
| 306 | + commands imply custom semantics, see also `#18`_). If a sanity check |
| 307 | + fails an exception is raised, but you can set :attr:`force` to |
| 308 | + :data:`True` to continue with backup rotation instead (the default is |
| 309 | + obviously :data:`False`). |
| 310 | +
|
| 311 | + .. seealso:: :func:`Location.ensure_exists()`, |
| 312 | + :func:`Location.ensure_readable()` and |
| 313 | + :func:`Location.ensure_writable()` |
| 314 | +
|
| 315 | + .. _#18: https://github.com/xolox/python-rotate-backups/issues/18 |
| 316 | + """ |
| 317 | + return False |
| 318 | + |
297 | 319 | @cached_property(writable=True) |
298 | 320 | def include_list(self): |
299 | 321 | """ |
@@ -477,7 +499,7 @@ class together to implement backup rotation with an easy to use Python |
477 | 499 | # https://github.com/xolox/python-rotate-backups/issues/18 for |
478 | 500 | # more details about one such use case). |
479 | 501 | if not self.dry_run and (self.removal_command == DEFAULT_REMOVAL_COMMAND): |
480 | | - location.ensure_writable() |
| 502 | + location.ensure_writable(self.force) |
481 | 503 | most_recent_backup = sorted_backups[-1] |
482 | 504 | # Group the backups by the rotation frequencies. |
483 | 505 | backups_by_frequency = self.group_backups(sorted_backups) |
@@ -563,7 +585,7 @@ def collect_backups(self, location): |
563 | 585 | backups = [] |
564 | 586 | location = coerce_location(location) |
565 | 587 | logger.info("Scanning %s for backups ..", location) |
566 | | - location.ensure_readable() |
| 588 | + location.ensure_readable(self.force) |
567 | 589 | for entry in natsort(location.context.list_entries(location.directory)): |
568 | 590 | match = TIMESTAMP_PATTERN.search(entry) |
569 | 591 | if match: |
@@ -733,49 +755,105 @@ def key_properties(self): |
733 | 755 | """ |
734 | 756 | return ['ssh_alias', 'directory'] if self.is_remote else ['directory'] |
735 | 757 |
|
736 | | - def ensure_exists(self): |
737 | | - """Make sure the location exists.""" |
738 | | - if not self.context.is_directory(self.directory): |
739 | | - # This can also happen when we don't have permission to one of the |
740 | | - # parent directories so we'll point that out in the error message |
741 | | - # when it seems applicable (so as not to confuse users). |
742 | | - if self.context.have_superuser_privileges: |
743 | | - msg = "The directory %s doesn't exist!" |
744 | | - raise ValueError(msg % self) |
745 | | - else: |
746 | | - raise ValueError(compact(""" |
747 | | - The directory {location} isn't accessible, most likely |
748 | | - because it doesn't exist or because of permissions. If |
749 | | - you're sure the directory exists you can use the |
750 | | - --use-sudo option. |
751 | | - """, location=self)) |
752 | | - |
753 | | - def ensure_readable(self): |
754 | | - """Make sure the location exists and is readable.""" |
755 | | - self.ensure_exists() |
756 | | - if not self.context.is_readable(self.directory): |
757 | | - if self.context.have_superuser_privileges: |
758 | | - msg = "The directory %s isn't readable!" |
759 | | - raise ValueError(msg % self) |
| 758 | + def ensure_exists(self, override=False): |
| 759 | + """ |
| 760 | + Sanity check that the location exists. |
| 761 | +
|
| 762 | + :param override: :data:`True` to log a message, :data:`False` to raise |
| 763 | + an exception (when the sanity check fails). |
| 764 | + :returns: :data:`True` if the sanity check succeeds, |
| 765 | + :data:`False` if it fails (and `override` is :data:`True`). |
| 766 | + :raises: :exc:`~exceptions.ValueError` when the sanity |
| 767 | + check fails and `override` is :data:`False`. |
| 768 | +
|
| 769 | + .. seealso:: :func:`ensure_readable()`, :func:`ensure_writable()` and :func:`add_hints()` |
| 770 | + """ |
| 771 | + if self.context.is_directory(self.directory): |
| 772 | + logger.verbose("Confirmed that location exists: %s", self) |
| 773 | + return True |
| 774 | + elif override: |
| 775 | + logger.notice("It seems %s doesn't exist but --force was given so continuing anyway ..", self) |
| 776 | + return False |
| 777 | + else: |
| 778 | + message = "It seems %s doesn't exist or isn't accessible due to filesystem permissions!" |
| 779 | + raise ValueError(self.add_hints(message % self)) |
| 780 | + |
| 781 | + def ensure_readable(self, override=False): |
| 782 | + """ |
| 783 | + Sanity check that the location exists and is readable. |
| 784 | +
|
| 785 | + :param override: :data:`True` to log a message, :data:`False` to raise |
| 786 | + an exception (when the sanity check fails). |
| 787 | + :returns: :data:`True` if the sanity check succeeds, |
| 788 | + :data:`False` if it fails (and `override` is :data:`True`). |
| 789 | + :raises: :exc:`~exceptions.ValueError` when the sanity |
| 790 | + check fails and `override` is :data:`False`. |
| 791 | +
|
| 792 | + .. seealso:: :func:`ensure_exists()`, :func:`ensure_writable()` and :func:`add_hints()` |
| 793 | + """ |
| 794 | + # Only sanity check that the location is readable when its |
| 795 | + # existence has been confirmed, to avoid multiple notices |
| 796 | + # about the same underlying problem. |
| 797 | + if self.ensure_exists(override): |
| 798 | + if self.context.is_readable(self.directory): |
| 799 | + logger.verbose("Confirmed that location is readable: %s", self) |
| 800 | + return True |
| 801 | + elif override: |
| 802 | + logger.notice("It seems %s isn't readable but --force was given so continuing anyway ..", self) |
760 | 803 | else: |
761 | | - raise ValueError(compact(""" |
762 | | - The directory {location} isn't readable, most likely |
763 | | - because of permissions. Consider using the --use-sudo |
764 | | - option. |
765 | | - """, location=self)) |
766 | | - |
767 | | - def ensure_writable(self): |
768 | | - """Make sure the directory exists and is writable.""" |
769 | | - self.ensure_exists() |
770 | | - if not self.context.is_writable(self.directory): |
771 | | - if self.context.have_superuser_privileges: |
772 | | - msg = "The directory %s isn't writable!" |
773 | | - raise ValueError(msg % self) |
| 804 | + message = "It seems %s isn't readable!" |
| 805 | + raise ValueError(self.add_hints(message % self)) |
| 806 | + return False |
| 807 | + |
| 808 | + def ensure_writable(self, override=False): |
| 809 | + """ |
| 810 | + Sanity check that the directory exists and is writable. |
| 811 | +
|
| 812 | + :param override: :data:`True` to log a message, :data:`False` to raise |
| 813 | + an exception (when the sanity check fails). |
| 814 | + :returns: :data:`True` if the sanity check succeeds, |
| 815 | + :data:`False` if it fails (and `override` is :data:`True`). |
| 816 | + :raises: :exc:`~exceptions.ValueError` when the sanity |
| 817 | + check fails and `override` is :data:`False`. |
| 818 | +
|
| 819 | + .. seealso:: :func:`ensure_exists()`, :func:`ensure_readable()` and :func:`add_hints()` |
| 820 | + """ |
| 821 | + # Only sanity check that the location is readable when its |
| 822 | + # existence has been confirmed, to avoid multiple notices |
| 823 | + # about the same underlying problem. |
| 824 | + if self.ensure_exists(override): |
| 825 | + if self.context.is_writable(self.directory): |
| 826 | + logger.verbose("Confirmed that location is writable: %s", self) |
| 827 | + return True |
| 828 | + elif override: |
| 829 | + logger.notice("It seems %s isn't writable but --force was given so continuing anyway ..", self) |
774 | 830 | else: |
775 | | - raise ValueError(compact(""" |
776 | | - The directory {location} isn't writable, most likely due |
777 | | - to permissions. Consider using the --use-sudo option. |
778 | | - """, location=self)) |
| 831 | + message = "It seems %s isn't writable!" |
| 832 | + raise ValueError(self.add_hints(message % self)) |
| 833 | + return False |
| 834 | + |
| 835 | + def add_hints(self, message): |
| 836 | + """ |
| 837 | + Provide hints about failing sanity checks. |
| 838 | +
|
| 839 | + :param message: The message to the user (a string). |
| 840 | + :returns: The message including hints (a string). |
| 841 | +
|
| 842 | + When superuser privileges aren't being used a hint about the |
| 843 | + ``--use-sudo`` option will be added (in case a sanity check failed |
| 844 | + because we don't have permission to one of the parent directories). |
| 845 | +
|
| 846 | + In all cases a hint about the ``--force`` option is added (in case the |
| 847 | + sanity checks themselves are considered the problem, which is obviously |
| 848 | + up to the operator to decide). |
| 849 | +
|
| 850 | + .. seealso:: :func:`ensure_exists()`, :func:`ensure_readable()` and :func:`ensure_writable()` |
| 851 | + """ |
| 852 | + sentences = [message] |
| 853 | + if not self.context.have_superuser_privileges: |
| 854 | + sentences.append("If filesystem permissions are the problem consider using the --use-sudo option.") |
| 855 | + sentences.append("To continue despite this failing sanity check you can use --force.") |
| 856 | + return " ".join(sentences) |
779 | 857 |
|
780 | 858 | def match(self, location): |
781 | 859 | """ |
|
0 commit comments