Use a strategy to decide conflict resolution

This commit is contained in:
I-Al-Istannen 2020-12-02 19:29:52 +01:00
parent 56ab473611
commit 9f6dc56a7b
3 changed files with 96 additions and 30 deletions

View File

@ -7,8 +7,9 @@ import filecmp
import logging import logging
import os import os
import shutil import shutil
from enum import Enum
from pathlib import Path, PurePath from pathlib import Path, PurePath
from typing import List, Optional, Set from typing import Callable, List, Optional, Set
from .download_summary import DownloadSummary from .download_summary import DownloadSummary
from .location import Location from .location import Location
@ -19,6 +20,25 @@ LOGGER = logging.getLogger(__name__)
PRETTY = PrettyLogger(LOGGER) PRETTY = PrettyLogger(LOGGER)
class FileConflictResolution(Enum):
"""
The reaction when confronted with a file conflict.
"""
OVERWRITE_EXISTING = "overwrite"
KEEP_EXISTING = "keep"
DEFAULT = "default"
PROMPT = "prompt"
FileConflictResolver = Callable[[PurePath], FileConflictResolution]
def resolve_prompt_user(_path: PurePath) -> FileConflictResolution:
"""Resolves conflicts by always asking the user."""
return FileConflictResolution.PROMPT
class FileAcceptException(Exception): class FileAcceptException(Exception):
"""An exception while accepting a file.""" """An exception while accepting a file."""
@ -26,7 +46,7 @@ class FileAcceptException(Exception):
class Organizer(Location): class Organizer(Location):
"""A helper for managing downloaded files.""" """A helper for managing downloaded files."""
def __init__(self, path: Path, no_prompt: bool = False): def __init__(self, path: Path, conflict_resolver: FileConflictResolver = resolve_prompt_user):
"""Create a new organizer for a given path.""" """Create a new organizer for a given path."""
super().__init__(path) super().__init__(path)
self._known_files: Set[Path] = set() self._known_files: Set[Path] = set()
@ -36,7 +56,7 @@ class Organizer(Location):
self.download_summary = DownloadSummary() self.download_summary = DownloadSummary()
self.not_prompting = no_prompt self.conflict_resolver = conflict_resolver
def accept_file(self, src: Path, dst: PurePath) -> Optional[Path]: def accept_file(self, src: Path, dst: PurePath) -> Optional[Path]:
""" """
@ -69,18 +89,14 @@ class Organizer(Location):
if self._is_marked(dst): if self._is_marked(dst):
PRETTY.warning(f"File {str(dst_absolute)!r} was already written!") PRETTY.warning(f"File {str(dst_absolute)!r} was already written!")
default_action: bool = False if self._resolve_conflict(f"Overwrite file?", dst_absolute, default=False):
if self.not_prompting and not default_action \
or not self.not_prompting and not prompt_yes_no(f"Overwrite file?", default=default_action):
PRETTY.ignored_file(dst_absolute, "file was written previously") PRETTY.ignored_file(dst_absolute, "file was written previously")
return None return None
# Destination file is directory # Destination file is directory
if dst_absolute.exists() and dst_absolute.is_dir(): if dst_absolute.exists() and dst_absolute.is_dir():
default_action: bool = False prompt = f"Overwrite folder {dst_absolute} with file?"
if self.not_prompting and default_action \ if self._resolve_conflict(prompt, dst_absolute, default=False):
or not self.not_prompting \
and prompt_yes_no(f"Overwrite folder {dst_absolute} with file?", default=default_action):
shutil.rmtree(dst_absolute) shutil.rmtree(dst_absolute)
else: else:
PRETTY.warning(f"Could not add file {str(dst_absolute)!r}") PRETTY.warning(f"Could not add file {str(dst_absolute)!r}")
@ -151,8 +167,20 @@ class Organizer(Location):
def _delete_file_if_confirmed(self, path: Path) -> None: def _delete_file_if_confirmed(self, path: Path) -> None:
prompt = f"Do you want to delete {path}" prompt = f"Do you want to delete {path}"
default_action: bool = False if self._resolve_conflict(prompt, path, default=False):
if self.not_prompting and default_action or \
not self.not_prompting and prompt_yes_no(prompt, default_action):
self.download_summary.add_deleted_file(path) self.download_summary.add_deleted_file(path)
path.unlink() path.unlink()
def _resolve_conflict(self, prompt: str, path: Path, default: bool) -> bool:
if not self.conflict_resolver:
return prompt_yes_no(prompt, default=default)
result = self.conflict_resolver(path)
if result == FileConflictResolution.DEFAULT:
return default
if result == FileConflictResolution.KEEP_EXISTING:
return False
if result == FileConflictResolution.OVERWRITE_EXISTING:
return True
return prompt_yes_no(prompt, default=default)

View File

@ -18,7 +18,7 @@ from .ipd import (IpdCrawler, IpdDownloader, IpdDownloadInfo,
IpdDownloadStrategy, ipd_download_new_or_modified) IpdDownloadStrategy, ipd_download_new_or_modified)
from .location import Location from .location import Location
from .logging import PrettyLogger, enable_logging from .logging import PrettyLogger, enable_logging
from .organizer import Organizer from .organizer import FileConflictResolver, Organizer, resolve_prompt_user
from .tmp_dir import TmpDir from .tmp_dir import TmpDir
from .transform import TF, Transform, apply_transform from .transform import TF, Transform, apply_transform
from .utils import PathLike, to_path from .utils import PathLike, to_path
@ -76,13 +76,13 @@ class Pferd(Location):
download_strategy: IliasDownloadStrategy, download_strategy: IliasDownloadStrategy,
timeout: int, timeout: int,
clean: bool = True, clean: bool = True,
no_prompt: bool = None file_conflict_resolver: FileConflictResolver = resolve_prompt_user
) -> Organizer: ) -> Organizer:
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
cookie_jar = CookieJar(to_path(cookies) if cookies else None) cookie_jar = CookieJar(to_path(cookies) if cookies else None)
session = cookie_jar.create_session() session = cookie_jar.create_session()
tmp_dir = self._tmp_dir.new_subdir() tmp_dir = self._tmp_dir.new_subdir()
organizer = Organizer(self.resolve(to_path(target)), no_prompt if no_prompt is not None else False) organizer = Organizer(self.resolve(to_path(target)), file_conflict_resolver)
crawler = IliasCrawler(base_url, session, authenticator, dir_filter) crawler = IliasCrawler(base_url, session, authenticator, dir_filter)
downloader = IliasDownloader(tmp_dir, organizer, session, downloader = IliasDownloader(tmp_dir, organizer, session,
@ -118,6 +118,7 @@ class Pferd(Location):
download_strategy: IliasDownloadStrategy = download_modified_or_new, download_strategy: IliasDownloadStrategy = download_modified_or_new,
clean: bool = True, clean: bool = True,
timeout: int = 5, timeout: int = 5,
file_conflict_resolver: FileConflictResolver = resolve_prompt_user
) -> Organizer: ) -> Organizer:
""" """
Synchronizes a folder with the ILIAS instance of the KIT. Synchronizes a folder with the ILIAS instance of the KIT.
@ -145,6 +146,8 @@ class Pferd(Location):
clean {bool} -- Whether to clean up when the method finishes. clean {bool} -- Whether to clean up when the method finishes.
timeout {int} -- The download timeout for opencast videos. Sadly needed due to a timeout {int} -- The download timeout for opencast videos. Sadly needed due to a
requests bug. requests bug.
file_conflict_resolver {FileConflictResolver} -- A function specifying how to deal
with overwriting or deleting files. The default always asks the user.
""" """
# This authenticator only works with the KIT ilias instance. # This authenticator only works with the KIT ilias instance.
authenticator = KitShibbolethAuthenticator(username=username, password=password) authenticator = KitShibbolethAuthenticator(username=username, password=password)
@ -160,7 +163,8 @@ class Pferd(Location):
transform=transform, transform=transform,
download_strategy=download_strategy, download_strategy=download_strategy,
clean=clean, clean=clean,
timeout=timeout timeout=timeout,
file_conflict_resolver=file_conflict_resolver
) )
self._download_summary.merge(organizer.download_summary) self._download_summary.merge(organizer.download_summary)
@ -185,6 +189,7 @@ class Pferd(Location):
download_strategy: IliasDownloadStrategy = download_modified_or_new, download_strategy: IliasDownloadStrategy = download_modified_or_new,
clean: bool = True, clean: bool = True,
timeout: int = 5, timeout: int = 5,
file_conflict_resolver: FileConflictResolver = resolve_prompt_user
) -> Organizer: ) -> Organizer:
""" """
Synchronizes a folder with the ILIAS instance of the KIT. This method will crawl the ILIAS Synchronizes a folder with the ILIAS instance of the KIT. This method will crawl the ILIAS
@ -211,6 +216,8 @@ class Pferd(Location):
clean {bool} -- Whether to clean up when the method finishes. clean {bool} -- Whether to clean up when the method finishes.
timeout {int} -- The download timeout for opencast videos. Sadly needed due to a timeout {int} -- The download timeout for opencast videos. Sadly needed due to a
requests bug. requests bug.
file_conflict_resolver {FileConflictResolver} -- A function specifying how to deal
with overwriting or deleting files. The default always asks the user.
""" """
# This authenticator only works with the KIT ilias instance. # This authenticator only works with the KIT ilias instance.
authenticator = KitShibbolethAuthenticator(username=username, password=password) authenticator = KitShibbolethAuthenticator(username=username, password=password)
@ -226,7 +233,8 @@ class Pferd(Location):
transform=transform, transform=transform,
download_strategy=download_strategy, download_strategy=download_strategy,
clean=clean, clean=clean,
timeout=timeout timeout=timeout,
file_conflict_resolver=file_conflict_resolver
) )
self._download_summary.merge(organizer.download_summary) self._download_summary.merge(organizer.download_summary)
@ -246,7 +254,7 @@ class Pferd(Location):
download_strategy: IliasDownloadStrategy = download_modified_or_new, download_strategy: IliasDownloadStrategy = download_modified_or_new,
clean: bool = True, clean: bool = True,
timeout: int = 5, timeout: int = 5,
no_prompt: bool = None file_conflict_resolver: FileConflictResolver = resolve_prompt_user
) -> Organizer: ) -> Organizer:
""" """
Synchronizes a folder with a given folder on the ILIAS instance of the KIT. Synchronizes a folder with a given folder on the ILIAS instance of the KIT.
@ -273,6 +281,8 @@ class Pferd(Location):
clean {bool} -- Whether to clean up when the method finishes. clean {bool} -- Whether to clean up when the method finishes.
timeout {int} -- The download timeout for opencast videos. Sadly needed due to a timeout {int} -- The download timeout for opencast videos. Sadly needed due to a
requests bug. requests bug.
file_conflict_resolver {FileConflictResolver} -- A function specifying how to deal
with overwriting or deleting files. The default always asks the user.
""" """
# This authenticator only works with the KIT ilias instance. # This authenticator only works with the KIT ilias instance.
authenticator = KitShibbolethAuthenticator(username=username, password=password) authenticator = KitShibbolethAuthenticator(username=username, password=password)
@ -292,7 +302,7 @@ class Pferd(Location):
download_strategy=download_strategy, download_strategy=download_strategy,
clean=clean, clean=clean,
timeout=timeout, timeout=timeout,
no_prompt=no_prompt file_conflict_resolver=file_conflict_resolver
) )
self._download_summary.merge(organizer.download_summary) self._download_summary.merge(organizer.download_summary)
@ -306,7 +316,8 @@ class Pferd(Location):
url: str, url: str,
transform: Transform = lambda x: x, transform: Transform = lambda x: x,
download_strategy: IpdDownloadStrategy = ipd_download_new_or_modified, download_strategy: IpdDownloadStrategy = ipd_download_new_or_modified,
clean: bool = True clean: bool = True,
file_conflict_resolver: FileConflictResolver = resolve_prompt_user
) -> Organizer: ) -> Organizer:
""" """
Synchronizes a folder with a DIVA playlist. Synchronizes a folder with a DIVA playlist.
@ -322,6 +333,8 @@ class Pferd(Location):
be downloaded. Can save bandwidth and reduce the number of requests. be downloaded. Can save bandwidth and reduce the number of requests.
(default: {diva_download_new}) (default: {diva_download_new})
clean {bool} -- Whether to clean up when the method finishes. clean {bool} -- Whether to clean up when the method finishes.
file_conflict_resolver {FileConflictResolver} -- A function specifying how to deal
with overwriting or deleting files. The default always asks the user.
""" """
tmp_dir = self._tmp_dir.new_subdir() tmp_dir = self._tmp_dir.new_subdir()
@ -332,7 +345,7 @@ class Pferd(Location):
if isinstance(target, Organizer): if isinstance(target, Organizer):
organizer = target organizer = target
else: else:
organizer = Organizer(self.resolve(to_path(target))) organizer = Organizer(self.resolve(to_path(target)), file_conflict_resolver)
PRETTY.starting_synchronizer(organizer.path, "IPD", url) PRETTY.starting_synchronizer(organizer.path, "IPD", url)
@ -360,7 +373,8 @@ class Pferd(Location):
playlist_location: str, playlist_location: str,
transform: Transform = lambda x: x, transform: Transform = lambda x: x,
download_strategy: DivaDownloadStrategy = diva_download_new, download_strategy: DivaDownloadStrategy = diva_download_new,
clean: bool = True clean: bool = True,
file_conflict_resolver: FileConflictResolver = resolve_prompt_user
) -> Organizer: ) -> Organizer:
""" """
Synchronizes a folder with a DIVA playlist. Synchronizes a folder with a DIVA playlist.
@ -377,6 +391,8 @@ class Pferd(Location):
be downloaded. Can save bandwidth and reduce the number of requests. be downloaded. Can save bandwidth and reduce the number of requests.
(default: {diva_download_new}) (default: {diva_download_new})
clean {bool} -- Whether to clean up when the method finishes. clean {bool} -- Whether to clean up when the method finishes.
file_conflict_resolver {FileConflictResolver} -- A function specifying how to deal
with overwriting or deleting files. The default always asks the user.
""" """
tmp_dir = self._tmp_dir.new_subdir() tmp_dir = self._tmp_dir.new_subdir()
@ -392,7 +408,7 @@ class Pferd(Location):
if isinstance(target, Organizer): if isinstance(target, Organizer):
organizer = target organizer = target
else: else:
organizer = Organizer(self.resolve(to_path(target))) organizer = Organizer(self.resolve(to_path(target)), file_conflict_resolver)
PRETTY.starting_synchronizer(organizer.path, "DIVA", playlist_id) PRETTY.starting_synchronizer(organizer.path, "DIVA", playlist_id)

View File

@ -5,24 +5,35 @@ A simple script to download a course by name from ILIAS.
""" """
import argparse import argparse
from pathlib import Path from pathlib import Path, PurePath
from urllib.parse import urlparse from urllib.parse import urlparse
from PFERD import Pferd from PFERD import Pferd
from PFERD.cookie_jar import CookieJar from PFERD.cookie_jar import CookieJar
from PFERD.ilias import (IliasCrawler, IliasElementType, from PFERD.ilias import (IliasCrawler, IliasElementType,
KitShibbolethAuthenticator) KitShibbolethAuthenticator)
from PFERD.organizer import FileConflictResolution, resolve_prompt_user
from PFERD.transform import sanitize_windows_path from PFERD.transform import sanitize_windows_path
from PFERD.utils import to_path from PFERD.utils import to_path
def _resolve_overwrite(_path: PurePath) -> FileConflictResolution:
return FileConflictResolution.OVERWRITE_EXISTING
def _resolve_default(_path: PurePath) -> FileConflictResolution:
return FileConflictResolution.DEFAULT
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--test-run", action="store_true") parser.add_argument("--test-run", action="store_true")
parser.add_argument('-c', '--cookies', nargs='?', default=None, help="File to store cookies in") parser.add_argument('-c', '--cookies', nargs='?', default=None, help="File to store cookies in")
parser.add_argument('--no-videos', nargs='?', default=None, help="Don't download videos") parser.add_argument('--no-videos', nargs='?', default=None, help="Don't download videos")
parser.add_argument('-p', '--passive', action="store_true", parser.add_argument('-d', '--default', action="store_true",
help="Don't prompt for confirmations and use sane defaults") help="Don't prompt for confirmations and use sane defaults")
parser.add_argument('-r', '--remove', action="store_true",
help="Remove and overwrite files without prompting for confirmation")
parser.add_argument('url', help="URL to the course page") parser.add_argument('url', help="URL to the course page")
parser.add_argument('folder', nargs='?', default=None, help="Folder to put stuff into") parser.add_argument('folder', nargs='?', default=None, help="Folder to put stuff into")
args = parser.parse_args() args = parser.parse_args()
@ -39,13 +50,17 @@ def main() -> None:
folder = Path(args.folder) folder = Path(args.folder)
if args.folder is None: if args.folder is None:
folder = Path(crawler.find_element_name(args.url)) element_name = crawler.find_element_name(args.url)
if not element_name:
print("Error, could not get element name. Please specify a folder yourself.")
return
folder = Path(element_name)
cookie_jar.save_cookies() cookie_jar.save_cookies()
# files may not escape the pferd_root with relative paths # files may not escape the pferd_root with relative paths
# note: Path(Path.cwd, Path(folder)) == Path(folder) if it is an absolute path # note: Path(Path.cwd, Path(folder)) == Path(folder) if it is an absolute path
pferd_root = Path(Path.cwd(), Path(folder)).parent pferd_root = Path(Path.cwd(), Path(folder)).parent
folder = folder.name target = folder.name
pferd = Pferd(pferd_root, test_run=args.test_run) pferd = Pferd(pferd_root, test_run=args.test_run)
def dir_filter(_: Path, element: IliasElementType) -> bool: def dir_filter(_: Path, element: IliasElementType) -> bool:
@ -53,15 +68,22 @@ def main() -> None:
return element not in [IliasElementType.VIDEO_FILE, IliasElementType.VIDEO_FOLDER] return element not in [IliasElementType.VIDEO_FILE, IliasElementType.VIDEO_FOLDER]
return True return True
if args.default:
file_confilict_resolver = _resolve_default
elif args.remove:
file_confilict_resolver = _resolve_overwrite
else:
file_confilict_resolver = resolve_prompt_user
pferd.enable_logging() pferd.enable_logging()
# fetch # fetch
pferd.ilias_kit_folder( pferd.ilias_kit_folder(
target=folder, target=target,
full_url=args.url, full_url=args.url,
cookies=args.cookies, cookies=args.cookies,
dir_filter=dir_filter, dir_filter=dir_filter,
transform=sanitize_windows_path, transform=sanitize_windows_path,
no_prompt=args.passive file_conflict_resolver=file_confilict_resolver
) )